diff --git a/app/build.gradle b/app/build.gradle index b2a2d91d..2cadee69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -79,6 +79,7 @@ dependencies { testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version" testCompile 'junit:junit:4.12' // しばらくはjunitと併用 + compile 'uk.co.chrisjenx:calligraphy:2.3.0' compile 'com.github.woxthebox:draglistview:1.5.1' compile 'com.github.omadahealth:swipy:1.2.3@aar' @@ -86,6 +87,8 @@ dependencies { compile 'com.github.kenglxn.QRGen:android:2.3.0' compile 'com.squareup.okhttp3:okhttp:3.9.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:3.9.1' + androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.9.1' compile 'commons-io:commons-io:2.6' diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt new file mode 100644 index 00000000..f17c559b --- /dev/null +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestDuplicateMap.kt @@ -0,0 +1,234 @@ +package jp.juggler.subwaytooter.api + +import android.support.test.runner.AndroidJUnit4 +import android.test.mock.MockContext +import jp.juggler.subwaytooter.api.entity.* +import jp.juggler.subwaytooter.table.SavedAccount +import org.json.JSONObject + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull + +@RunWith(AndroidJUnit4::class) +class TestDuplicateMap { + + private val parser = TootParser( + MockContext(), + SavedAccount( + db_id = 1, + acct = "user1@host1", + hostArg = null + ) + ) + + private val generatedItems = ArrayList() + + private fun genStatus( + parser : TootParser, + accountJson : JSONObject, + statusId : Long, + uri : String?, + url : String? + ):TootStatus{ + val itemJson = JSONObject() + + itemJson.apply { + put("account", accountJson) + put("id", statusId) + if(uri != null) put("uri", uri) + if(url != null) put("url", url) + } + + return TootStatus( + parser, + itemJson, + serviceType = ServiceType.MASTODON + ) + } + + private fun checkStatus( + map : DuplicateMap, + parser : TootParser, + accountJson : JSONObject, + statusId : Long, + uri : String?, + url : String? + ) { + val item = genStatus(parser,accountJson,statusId,uri,url) + assertNotNull(item) + generatedItems.add(item) + assertEquals(false, map.isDuplicate(item)) + assertEquals(true, map.isDuplicate(item)) + } + + private fun testDuplicateStatus() { + + val account1Json = JSONObject() + account1Json.apply { + put("username", "user1") + put("acct", "user1") + put("id", 1L) + put("url", "http://${parser.accessInfo.host}/@user1") + } + + val account1 = TootAccount( + parser.context, + parser.accessInfo, + src = account1Json, + serviceType = ServiceType.MASTODON + ) + assertNotNull(account1) + + val map = DuplicateMap() + + // 普通のステータス + checkStatus( + map, + parser, + account1Json, + 1L, + "http://${parser.accessInfo.host}/@${account1.username}/1", + "http://${parser.accessInfo.host}/@${account1.username}/1" + ) + // 別のステータス + checkStatus( + map, + parser, + account1Json, + 2L, + "http://${parser.accessInfo.host}/@${account1.username}/2", + "http://${parser.accessInfo.host}/@${account1.username}/2" + ) + // 今度はuriがない + checkStatus( + map, + parser, + account1Json, + 3L, + null, // "http://${parser.accessInfo.host}/@${account1.username}/3", + "http://${parser.accessInfo.host}/@${account1.username}/3" + ) + // 今度はuriとURLがない + checkStatus( + map, + parser, + account1Json, + 4L, + null, // "http://${parser.accessInfo.host}/@${account1.username}/4", + null //"http://${parser.accessInfo.host}/@${account1.username}/4" + ) + // 今度はIDがおかしい + checkStatus( + map, + parser, + account1Json, + TootStatus.INVALID_ID, + null, // "http://${parser.accessInfo.host}/@${account1.username}/4", + null //"http://${parser.accessInfo.host}/@${account1.username}/4" + ) + + } + + + private fun checkNotification( + map : DuplicateMap, + parser : TootParser, + id : Long + ) { + val itemJson = JSONObject() + + itemJson.apply { + put("type", TootNotification.TYPE_MENTION) + put("id", id) + } + + val item = TootNotification( parser,itemJson ) + assertNotNull(item) + generatedItems.add(item) + assertEquals(false, map.isDuplicate(item)) + assertEquals(true, map.isDuplicate(item)) + } + + private fun testDuplicateNotification() { + val map = DuplicateMap() + checkNotification(map,parser,0L) + checkNotification(map,parser,1L) + checkNotification(map,parser,2L) + checkNotification(map,parser,3L) + } + + private fun checkReport( + map : DuplicateMap, + id : Long + ) { + val item = TootReport( id,"eat" ) + + assertNotNull(item) + generatedItems.add(item) + assertEquals(false, map.isDuplicate(item)) + assertEquals(true, map.isDuplicate(item)) + } + + + private fun testDuplicateReport() { + val map = DuplicateMap() + checkReport(map,0L) + checkReport(map,1L) + checkReport(map,2L) + checkReport(map,3L) + } + + private fun checkAccount( + map : DuplicateMap, + parser : TootParser, + id : Long + ) { + + val itemJson = JSONObject() + itemJson.apply { + put("username", "user$id") + put("acct", "user$id") + put("id", id) + put("url", "http://${parser.accessInfo.host}/@user$id") + } + + val item = TootAccount( + parser.context, + parser.accessInfo, + src = itemJson, + serviceType = ServiceType.MASTODON + ) + assertNotNull(item) + generatedItems.add(item) + assertEquals(false, map.isDuplicate(item)) + assertEquals(true, map.isDuplicate(item)) + } + + + private fun testDuplicateAccount() { + val map = DuplicateMap() + checkAccount(map,parser,0L) + checkAccount(map,parser,1L) + checkAccount(map,parser,2L) + checkAccount(map,parser,3L) + } + + @Test fun testFilterList(){ + generatedItems.clear() + testDuplicateStatus() + testDuplicateNotification() + testDuplicateReport() + testDuplicateAccount() + + val map = DuplicateMap() + + val dst = map.filterDuplicate( generatedItems) + assertEquals( generatedItems.size,dst.size) + + val dst2 = map.filterDuplicate( generatedItems) + assertEquals( 0,dst2.size) + } +} diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt new file mode 100644 index 00000000..bdaf0946 --- /dev/null +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/api/TestTootApiClient.kt @@ -0,0 +1,1043 @@ +package jp.juggler.subwaytooter.api + +import android.support.test.InstrumentationRegistry +import android.support.test.runner.AndroidJUnit4 +import jp.juggler.subwaytooter.util.CurrentCallCallback +import jp.juggler.subwaytooter.util.SimpleHttpClient +import okhttp3.* +import okio.Buffer +import okio.BufferedSource +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TestTootApiClient { + + private val appContext = InstrumentationRegistry.getTargetContext() !! + + class SimpleHttpClientMock( + private val responseGenerator : (request : Request) -> Response, + val webSocketGenerator : (request : Request, ws_listener : WebSocketListener) -> WebSocket + ) : SimpleHttpClient { + + override var currentCallCallback : CurrentCallCallback? = null + + override fun getResponse(request : Request) : Response { + return responseGenerator(request) + } + + override fun getWebSocket(request : Request, webSocketListener : WebSocketListener) : WebSocket { + return webSocketGenerator(request, webSocketListener) + } + } + + @Test + fun testSimplifyErrorHtml() { + var request : Request + var response : Response + var message : String + + // json error + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("This is test") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"error":"Error!"}""" + )) + .build() + + message = TootApiClient.simplifyErrorHtml(response, response.body()?.string() ?: "") + assertEquals("Error!", message) + + // HTML error + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + val mediaTypeHtml = "text/html" + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("This is test") + .header("Content-Type", mediaTypeHtml) + .body(ResponseBody.create( + MediaType.parse(mediaTypeHtml), + """Error!""" + )) + .build() + + message = TootApiClient.simplifyErrorHtml(response, response.body()?.string() ?: "") + assertEquals("Error!", message) + + // other error + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("This is test") + .body(ResponseBody.create( + MediaType.parse("text/plain"), + """Error!""" + )) + .build() + + message = TootApiClient.simplifyErrorHtml(response, response.body()?.string() ?: "") + assertEquals("Error!", message) + + // empty body + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("This is test") + .body(ResponseBody.create( + MediaType.parse("text/plain"), + "" + )) + .build() + + message = TootApiClient.simplifyErrorHtml(response, response.body()?.string() ?: "") + assertEquals("", message) + } + + @Test + fun testFormatResponse() { + + var request : Request + var response : Response + var bodyString : String? + var message : String + + // without response body + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("This is test") + .build() + + message = TootApiClient.formatResponse(response, "caption", null) + + assertEquals("(HTTP 500 This is test) caption", message) + + // json error + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"error":"Error!"}""" + )) + .build() + + message = TootApiClient.formatResponse(response, "caption", null) + assertEquals("Error! (HTTP 500 status-message) caption", message) + + // json error (after reading body) + + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"error":"Error!"}""" + )) + .build() + + bodyString = response.body()?.string() + + message = TootApiClient.formatResponse(response, "caption", bodyString) + assertEquals("Error! (HTTP 500 status-message) caption", message) + + // without status message + request = Request.Builder() + .url("https://dummy-url.net/") + .build() + + response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"error":"Error!"}""" + )) + .build() + + bodyString = response.body()?.string() + + message = TootApiClient.formatResponse(response, "caption", bodyString) + assertEquals("Error! (HTTP 500) caption", message) + + } + + @Test + fun testIsApiCancelled() { + + val httpClient = SimpleHttpClientMock( + responseGenerator = { request : Request -> + throw NotImplementedError() + }, + webSocketGenerator = { request : Request, ws_listener : WebSocketListener -> + throw NotImplementedError() + } + ) + + var flag = 0 + var progressString : String? = null + var progressValue : Int? = null + var progressMax : Int? = null + + val client = TootApiClient( + appContext, + httpClient = httpClient, + callback = object : TootApiCallback { + override val isApiCancelled : Boolean + get() { + ++ flag + return true + } + + override fun publishApiProgress(s : String) { + ++ flag + progressString = s + } + + override fun publishApiProgressRatio(value : Int, max : Int) { + ++ flag + progressValue = value + progressMax = max + } + } + ) + val isApiCancelled = client.isApiCancelled + client.publishApiProgress("testing") + client.publishApiProgressRatio(50, 100) + assertEquals(3, flag) + assertEquals(true, isApiCancelled) + assertEquals("testing", progressString) + assertEquals(50, progressValue) + assertEquals(100, progressMax) + + } + + private fun requestBodyString(request : Request?) : String? { + try { + val copyBody = request?.newBuilder()?.build()?.body() ?: return null + val buffer = Buffer() + copyBody.writeTo(buffer) + return buffer.readUtf8() + } catch(ex : Throwable) { + ex.printStackTrace() + return null + } + } + + val mediaTypeTextPlain = MediaType.parse("text/plain") + + private fun createHttpClientNormal() : SimpleHttpClient { + return SimpleHttpClientMock( + responseGenerator = { request : Request -> + + val bodyString = requestBodyString(request) + + val path = request.url().encodedPath() + when(path) { + + // クライアント登録 + "/api/v1/apps" -> + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"DUMMY_ID","client_secret":"DUMMY_SECRET"}""" + )) + .build() + + // client credentialの検証 + "/api/v1/apps/verify_credentials" -> Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"DUMMY_ID","client_secret":"DUMMY_SECRET"}""" + )) + .build() + + "/oauth/token" -> when { + // client credential の作成 + bodyString?.contains("grant_type=client_credentials") == true -> { + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"access_token":"DUMMY_CLIENT_CREDENTIAL"}""" + )) + .build() + + } + // アクセストークンの作成 + bodyString?.contains("grant_type=authorization_code") == true -> { + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"access_token":"DUMMY_ACCESS_TOKEN"}""" + )) + .build() + } + + else -> { + createResponseErrorCode() + } + } + // ログインユーザの情報 + "/api/v1/accounts/verify_credentials" -> { + val instance = request.url().host() + val account1Json = JSONObject() + account1Json.apply { + put("username", "user1") + put("acct", "user1") + put("id", 1L) + put("url", "http://$instance/@user1") + } + + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + account1Json.toString() + )) + .build() + } + + else -> + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("status-message") + .body(ResponseBody.create( + TootApiClient.MEDIA_TYPE_JSON, + """{"error":"Error!"}""" + )) + .build() + } + + }, + + webSocketGenerator = { _ : Request, _ : WebSocketListener -> + throw NotImplementedError() + } + ) + } + + private fun createHttpClientNotImplemented() : SimpleHttpClient { + return SimpleHttpClientMock( + responseGenerator = { request : Request -> + throw NotImplementedError() + }, + webSocketGenerator = { request : Request, ws_listener : WebSocketListener -> + throw NotImplementedError() + } + ) + } + + class ProgressRecordTootApiCallback : TootApiCallback { + + var cancelled : Boolean = false + + var progressString : String? = null + + override val isApiCancelled : Boolean + get() = cancelled + + override fun publishApiProgress(s : String) { + progressString = s + } + } + + private val requestSimple = Request.Builder() + .url("https://dummy-url.net/") + .build() !! + + private val strJsonOk = """{"a":"A!"}""" + private val strJsonError = """{"error":"Error!"}""" + private fun createResponseOk() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonOk)) + .build() + + private fun createResponseOkButJsonError() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonError)) + .build() + + private fun createResponseErrorCode() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonError)) + .build() + + private fun createResponseEmptyBody() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, "")) + .build() + + private fun createResponseWithoutBody() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + // without body + .build() + + private fun createResponseExceptionBody() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body( + object : ResponseBody() { + override fun contentLength() = 10L + + override fun contentType() : MediaType? { + return TootApiClient.MEDIA_TYPE_JSON + } + + override fun source() : BufferedSource? { + return null + } + } + ) + .build() + + private val strJsonArray1 = """["A!"]""" + private val strJsonArray2 = """ [ "A!" ] """ + private val strJsonObject2 = """ { "a" : "A!" } """ + private val strPlainText = "Hello!" + + private fun createResponseJsonArray1() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonArray1)) + .build() + + private fun createResponseJsonArray2() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonArray2)) + .build() + + private fun createResponseJsonObject2() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(TootApiClient.MEDIA_TYPE_JSON, strJsonObject2)) + .build() + + private fun createResponsePlainText() = Response.Builder() + .request(requestSimple) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("status-message") + .body(ResponseBody.create(MediaType.parse("text/plain"), strPlainText)) + .build() + + @Test + fun testSendRequest() { + + val callback = ProgressRecordTootApiCallback() + + // 正常ケースではResponseが返ってくること + run { + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + + callback.progressString = null + val bOk = client.sendRequest(result) { requestSimple } + assertEquals(true, bOk) + assertEquals("取得中: GET /", callback.progressString) + assertEquals(null, result.error) + assertNotNull(result.response) + } + + // httpClient.getResponseが例外を出す場合に対応できること + run { + val client = TootApiClient( + appContext, + httpClient = createHttpClientNotImplemented(), + callback = callback + ) + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + + callback.progressString = null + val bOk = client.sendRequest(result) { requestSimple } + assertEquals(false, bOk) + assertEquals("取得中: GET /", callback.progressString) + assertEquals("instance: 通信エラー :NotImplementedError An operation is not implemented.", result.error) + assertNull(result.response) + + } + + // progressPath を指定したらpublishApiProgressに渡されること + run { + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + + callback.progressString = null + val bOk = client.sendRequest(result, progressPath = "XXX") { requestSimple } + assertEquals(true, bOk) + assertEquals("取得中: GET XXX", callback.progressString) + assertEquals(null, result.error) + assertNotNull(result.response) + } + + } + + @Test + fun testReadBodyString() { + + val callback = ProgressRecordTootApiCallback() + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + + // キャンセルされてたらnullを返すこと + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + callback.progressString = null + callback.cancelled = true + val bodyString = client.readBodyString(result) + callback.cancelled = false + assertNull(bodyString) + assertNull(result.bodyString) + assertNull(result.data) + assertNull(result.error) + assertNull(callback.progressString) + } + + // 正常ケースなら progressを更新してbodyStringを返す + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + + callback.progressString = null + val bodyString = client.readBodyString(result) + assertEquals(strJsonOk, bodyString) + assertEquals(strJsonOk, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + assertNull(result.data) + } + + // レスポンスコードがエラーなら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseErrorCode() + + callback.progressString = null + val bodyString = client.readBodyString(result) + assertEquals(null, bodyString) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("Error! (HTTP 500 status-message) instance", result.error) + assertNull(result.data) + } + + // ボディが空なら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseEmptyBody() + callback.progressString = null + val bodyString = client.readBodyString(result) + assertEquals(null, bodyString) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + // ボディがnullなら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseWithoutBody() + + callback.progressString = null + val bodyString = client.readBodyString(result) + assertEquals(null, bodyString) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + // string() が例外 + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseExceptionBody() + + var catched : Throwable? = null + val bodyString = try { + client.readBodyString(result) + } catch(ex : Throwable) { + ex.printStackTrace() + catched = ex + null + } + assertEquals(null, bodyString) + assertNotNull(catched) + } + } + + @Test + fun testParseString() { + val callback = ProgressRecordTootApiCallback() + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + + // キャンセルされてたらnullを返すこと + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + callback.progressString = null + callback.cancelled = true + val r2 = client.parseString(result) + callback.cancelled = false + assertNull(r2) + assertNull(result.bodyString) + assertNull(result.data) + assertNull(result.error) + assertNull(callback.progressString) + } + + // 正常ケースなら progressを更新してbodyStringを返す + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + + callback.progressString = null + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(strJsonOk, result.string) + assertEquals(strJsonOk, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + // 正常レスポンスならJSONにエラーがあってもreadStringは関知しない + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOkButJsonError() + + callback.progressString = null + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(strJsonError, result.string) + assertEquals(strJsonError, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + + // レスポンスコードがエラーなら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseErrorCode() + callback.progressString = null + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(null, result.string) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("Error! (HTTP 500 status-message) instance", result.error) + } + + // ボディが空なら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseEmptyBody() + callback.progressString = null + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(null, result.string) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + // ボディがnullなら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseWithoutBody() + + callback.progressString = null + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(null, result.string) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + // string() が例外 + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseExceptionBody() + val r2 = client.parseString(result) + assertNotNull(r2) + assertEquals(null, result.string) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + } + + @Test + fun testParseJson() { + val callback = ProgressRecordTootApiCallback() + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + + // キャンセルされてたらnullを返すこと + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + callback.progressString = null + callback.cancelled = true + val r2 = client.parseJson(result) + callback.cancelled = false + assertNull(r2) + assertNull(result.bodyString) + assertNull(result.data) + assertNull(result.error) + assertNull(callback.progressString) + } + + // 正常ケースなら progressを更新してbodyStringを返す + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOk() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals("A!", result.jsonObject?.optString("a")) + assertEquals(strJsonOk, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + // 正常ケースでもjsonデータにerror項目があれば + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseOkButJsonError() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(strJsonError, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertEquals("Error!", result.error) + } + + // レスポンスコードがエラーなら + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseErrorCode() + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("Error! (HTTP 500 status-message) instance", result.error) + } + + // ボディが空なら + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseEmptyBody() + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + // ボディがnullなら + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseWithoutBody() + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + // string() が例外 + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseExceptionBody() + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(null, result.bodyString) + assertEquals("読込中: GET instance", callback.progressString) + assertEquals("(no information) (HTTP 200 status-message) instance", result.error) + assertNull(result.data) + } + + // JSON Arrayを処理する + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseJsonArray1() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals("A!", result.jsonArray?.optString(0)) + assertEquals(strJsonArray1, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + + // 空白が余計に入ってるJSON Arrayを処理する + run { + + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseJsonArray2() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals("A!", result.jsonArray?.optString(0)) + assertEquals(strJsonArray2, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + + // 空白が余計に入ってるJSON Objectを処理する + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponseJsonObject2() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals("A!", result.jsonObject?.optString("a")) + assertEquals(strJsonObject2, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertNull(result.error) + } + // JSONじゃない + run { + val result = TootApiResult.makeWithCaption("instance") + assertEquals(null, result.error) + result.response = createResponsePlainText() + + callback.progressString = null + val r2 = client.parseJson(result) + assertNotNull(r2) + assertEquals(null, result.data) + assertEquals(strPlainText, result.bodyString) + assertEquals("応答の解析中…", callback.progressString) + assertEquals("APIの応答がJSONではありません\nHello!", result.error) + } + } + + @Test + fun testRegisterClient() { + val callback = ProgressRecordTootApiCallback() + val client = TootApiClient( + appContext, + httpClient = createHttpClientNormal(), + callback = callback + ) + val instance = "unit-test" + client.instance = instance + val clientName = "SubwayTooterUnitTest" + + // まずクライアント情報を作らないとcredentialのテストができない + var result = client.registerClient(clientName) + assertNotNull(result) + assertEquals(null, result?.error) + var jsonObject = result?.jsonObject + assertNotNull(jsonObject) + if(jsonObject == null) return@testRegisterClient + val clientInfo = jsonObject + + // clientCredential の作成 + result = client.getClientCredential(clientInfo) + assertNotNull(result) + assertEquals(null, result?.error) + val clientCredential = result?.string + assertNotNull(clientCredential) + if(clientCredential == null) return@testRegisterClient + clientInfo.put(TootApiClient.KEY_CLIENT_CREDENTIAL, clientCredential) + + // clientCredential の検証 + result = client.verifyClientCredential(clientCredential) + assertNotNull(result) + assertEquals(null, result?.error) + jsonObject = result?.jsonObject + assertNotNull(jsonObject) // 中味は別に見てない。jsonObjectなら良いらしい + if(jsonObject == null) return + + var url : String? + + // ブラウザURLの作成 + url = client.prepareBrowserUrl(clientInfo) + assertNotNull(url) + println(url) + + // ここまでと同じことをauthorize1でまとめて行う + result = client.authorize1(clientName) + url = result?.string + assertNotNull(url) + if(url == null) return + println(url) + + // ブラウザからコールバックで受け取ったcodeを処理する + result = client.authorize2(clientName, "DUMMY_CODE") + jsonObject = result?.jsonObject + assertNotNull(jsonObject) + if(jsonObject == null) return + println(jsonObject.toString()) + + } + + @Test + fun testGetClientCredential() { + + } +} + diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/mock/MockSharedPreferences.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/mock/MockSharedPreferences.kt new file mode 100644 index 00000000..d8533f88 --- /dev/null +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/mock/MockSharedPreferences.kt @@ -0,0 +1,96 @@ +package jp.juggler.subwaytooter.mock + +import android.content.SharedPreferences + +class MockSharedPreferences( + val map : HashMap = HashMap() +) : SharedPreferences { + + override fun contains(key : String?) = map.contains(key) + + override fun getBoolean(key : String?, defValue : Boolean) + = map.get(key) as? Boolean ?: defValue + + override fun getInt(key : String?, defValue : Int) + = map.get(key) as? Int ?: defValue + + override fun getLong(key : String?, defValue : Long) + = map.get(key) as? Long ?: defValue + + override fun getFloat(key : String?, defValue : Float) + = map.get(key) as? Float ?: defValue + + override fun getString(key : String?, defValue : String?) + = map.get(key) as? String ?: defValue + + override fun getStringSet(key : String?, defValues : MutableSet?) + = map.get(key) as? MutableSet ?: defValues + + + override fun edit() : SharedPreferences.Editor { + return Editor(this) + } + + override fun getAll() : MutableMap { + TODO("not implemented") + } + + override fun registerOnSharedPreferenceChangeListener( + listener : SharedPreferences.OnSharedPreferenceChangeListener? + ) { + TODO("not implemented") + } + + override fun unregisterOnSharedPreferenceChangeListener( + listener : SharedPreferences.OnSharedPreferenceChangeListener? + ) { + TODO("not implemented") + } + + companion object { + val REMOVED_OBJECT = Any() + } + + class Editor(private val pref : MockSharedPreferences) : SharedPreferences.Editor { + private val changeSet = HashMap() + + override fun commit() : Boolean { + for((k, v) in changeSet) { + if(v === REMOVED_OBJECT) { + pref.map.remove(k) + } else { + pref.map.put(k, v) + } + } + return true + } + + override fun apply() { + commit() + } + + override fun clear() : SharedPreferences.Editor { + changeSet.clear() + return this + } + + override fun remove(key : String) : SharedPreferences.Editor { + changeSet.put(key, REMOVED_OBJECT) + return this + } + + private fun putAny(k : String, v : Any?) : SharedPreferences.Editor { + changeSet.put(k, v ?: REMOVED_OBJECT) + return this + } + + override fun putLong(key : String, value : Long) = putAny(key, value) + override fun putInt(key : String, value : Int) = putAny(key, value) + override fun putBoolean(key : String, value : Boolean) = putAny(key, value) + override fun putFloat(key : String, value : Float) = putAny(key, value) + override fun putString(key : String, value : String?) = putAny(key, value) + override fun putStringSet(key : String, value : MutableSet?) = putAny(key, value) + + } + +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActCustomStreamListener.kt b/app/src/main/java/jp/juggler/subwaytooter/ActCustomStreamListener.kt index eadb7cbd..4a0c7e4d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActCustomStreamListener.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActCustomStreamListener.kt @@ -12,6 +12,7 @@ import android.text.TextWatcher import android.view.View import android.widget.EditText import android.widget.TextView +import jp.juggler.subwaytooter.api.TootApiClient import org.hjson.JsonValue @@ -185,28 +186,21 @@ class ActCustomStreamListener : AppCompatActivity(), View.OnClickListener, TextW var call = App1.ok_http_client.newCall(builder.build()) val response = call.execute() - if(! response.isSuccessful) { - addLog(Utils.formatResponse(response, "Can't get configuration from URL.")) - break - } - val bodyString : String? - try { - bodyString = response.body()?.string() + val bodyString : String? = try { + response.body()?.string() } catch(ex : Throwable) { log.trace(ex) - addLog("Can't get content body") + null + } + + if(! response.isSuccessful || bodyString?.isEmpty() != false ){ + addLog(TootApiClient.formatResponse(response, "Can't get configuration from URL.",bodyString)) break } - if(bodyString == null) { - addLog("content body is null") - break - } - - val jv : JsonValue - try { - jv = JsonValue.readHjson(bodyString) + val jv : JsonValue = try { + JsonValue.readHjson(bodyString) } catch(ex : Throwable) { log.trace(ex) addLog(Utils.formatError(ex, "Can't parse configuration data.")) diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt index b8ee93f4..4549fcf9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt @@ -1394,7 +1394,7 @@ class ActMain : AppCompatActivity() val sa = SavedAccount.loadAccount(this@ActMain, dataId) ?: return TootApiResult("missing account db_id=" + dataId) this.sa = sa - client.setAccount(sa) + client.account = sa } catch(ex : Throwable) { log.trace(ex) return TootApiResult(Utils.formatError(ex, "invalid state")) @@ -1402,7 +1402,7 @@ class ActMain : AppCompatActivity() } else if(sv.startsWith("host:")) { val host = sv.substring(5) - client.setInstance(host) + client.instance =host } if(client.instance == null) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt index 492289f4..e5477056 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt @@ -376,7 +376,7 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { } if(! response.isSuccessful) { - return TootApiResult(Utils.formatResponse(response, "response error")) + return TootApiResult(TootApiClient.formatResponse(response, "response error")) } return try { diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index 55bb554b..41818dfd 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -187,7 +187,7 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba if(response.isSuccessful) { return true } - log.e(Utils.formatResponse(response, "check_exist failed.")) + log.e(TootApiClient.formatResponse(response, "check_exist failed.")) } catch(ex : Throwable) { log.trace(ex) } @@ -1739,7 +1739,7 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba this.account = account // アカウントがあるなら基本的にはすべての情報を復元できるはずだが、いくつか確認が必要だ - val api_client = TootApiClient(this@ActPost, object : TootApiCallback { + val api_client = TootApiClient(this@ActPost, callback=object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled @@ -1749,7 +1749,7 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba } }) - api_client.setAccount(account) + api_client.account = account if(in_reply_to_id != - 1L) { val result = api_client.request("/api/v1/statuses/" + in_reply_to_id) diff --git a/app/src/main/java/jp/juggler/subwaytooter/App1.kt b/app/src/main/java/jp/juggler/subwaytooter/App1.kt index ddf81cc9..5321d71c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/App1.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/App1.kt @@ -21,6 +21,7 @@ import com.bumptech.glide.load.engine.executor.GlideExecutor import com.bumptech.glide.load.engine.executor.GlideExecutor.newDiskCacheExecutor import com.bumptech.glide.load.engine.executor.GlideExecutor.newSourceExecutor import com.bumptech.glide.load.model.GlideUrl +import jp.juggler.subwaytooter.api.TootApiClient import java.io.File import java.io.InputStream @@ -451,7 +452,7 @@ class App1 : Application() { } if(! response.isSuccessful) { - log.e(Utils.formatResponse(response, "getHttp response error.")) + log.e(TootApiClient.formatResponse(response, "getHttp response error.")) return null } @@ -480,7 +481,7 @@ class App1 : Application() { } if(! response.isSuccessful) { - log.e(Utils.formatResponse(response, "getHttp response error.")) + log.e(TootApiClient.formatResponse(response, "getHttp response error.")) return null } diff --git a/app/src/main/java/jp/juggler/subwaytooter/Column.kt b/app/src/main/java/jp/juggler/subwaytooter/Column.kt index df2682de..c82ab94f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Column.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Column.kt @@ -41,7 +41,7 @@ class Column( val context : Context, val access_info : SavedAccount, val column_type : Int -) : StreamReader.Callback { +) { companion object { private val log = LogCategory("Column") @@ -1227,7 +1227,12 @@ class Column( internal var list_tmp : ArrayList? = null internal fun getInstanceInformation(client : TootApiClient, instance_name : String?) : TootApiResult? { - if(instance_name != null) client.setInstance(instance_name) + if( instance_name != null ){ + // 「インスタンス情報」カラムをNAアカウントで開く場合 + client.instance = instance_name + }else{ + // カラムに紐付けられたアカウントのタンスのインスタンス情報 + } val result = client.request("/api/v1/instance") val jsonObject = result?.jsonObject if(jsonObject != null) { @@ -1267,7 +1272,7 @@ class Column( // val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' while(true) { - if(client.isCancelled) { + if(client.isApiCancelled) { log.d("loading-statuses: cancelled.") break } @@ -1367,7 +1372,7 @@ class Column( // val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' while(true) { - if(client.isCancelled) { + if(client.isApiCancelled) { log.d("loading-notifications: cancelled.") break } @@ -1413,7 +1418,7 @@ class Column( } override fun doInBackground(vararg params : Void) : TootApiResult? { - val client = TootApiClient(context, object : TootApiCallback { + val client = TootApiClient(context, callback=object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() @@ -1426,7 +1431,7 @@ class Column( } }) - client.setAccount(access_info) + client.account =access_info try { var result : TootApiResult? @@ -1454,7 +1459,9 @@ class Column( TAB_STATUS -> { var instance = access_info.instance - if(access_info.isPseudo || instance == null) { + // まだ取得してない + // 疑似アカウントの場合は過去のデータが別タンスかもしれない? + if( instance == null || access_info.isPseudo ) { val r2 = getInstanceInformation(client, null) if(instance_tmp != null) { instance = instance_tmp @@ -1587,23 +1594,11 @@ class Column( list_tmp = ArrayList() result = TootApiResult() } else { - result = MSPClient.search(context, search_query, max_id, object : TootApiCallback { - - override val isApiCancelled : Boolean - get() = isCancelled || is_dispose.get() - - override fun publishApiProgress(s : String) { - Utils.runOnMainThread { - if(isCancelled) return@runOnMainThread - task_progress = s - fireShowContent() - } - } - }) + result = client.searchMsp( search_query, max_id ) val jsonArray = result?.jsonArray if(jsonArray != null) { // max_id の更新 - max_id = MSPClient.getMaxId(jsonArray, max_id) + max_id = TootApiClient.getMspMaxId(jsonArray, max_id) // リストデータの用意 val search_result = TootStatus.parseList(parser, jsonArray, serviceType = ServiceType.MSP) list_tmp = addWithFilterStatus(null, search_result) @@ -1619,22 +1614,11 @@ class Column( list_tmp = ArrayList() result = TootApiResult() } else { - result = TootsearchClient.search(context, search_query, max_id, object : TootApiCallback { - override val isApiCancelled : Boolean - get() = isCancelled || is_dispose.get() - - override fun publishApiProgress(s : String) { - Utils.runOnMainThread { - if(isCancelled) return@runOnMainThread - task_progress = s - fireShowContent() - } - } - }) + result = client.searchTootsearch( search_query, max_id) val jsonObject = result?.jsonObject if(jsonObject != null) { // max_id の更新 - max_id = TootsearchClient.getMaxId(jsonObject, max_id) + max_id = TootApiClient.getTootsearchMaxId(jsonObject, max_id) // リストデータの用意 val search_result = TootStatus.parseListTootsearch(parser, jsonObject) this.list_tmp = addWithFilterStatus(null, search_result) @@ -2256,7 +2240,7 @@ class Column( } override fun doInBackground(vararg params : Void) : TootApiResult? { - val client = TootApiClient(context, object : TootApiCallback { + val client = TootApiClient(context, callback=object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() @@ -2269,7 +2253,7 @@ class Column( } }) - client.setAccount(access_info) + client.account =access_info try { return when(column_type) { @@ -2342,22 +2326,11 @@ class Column( list_tmp = ArrayList() result = TootApiResult(context.getString(R.string.end_of_list)) } else { - result = MSPClient.search(context, search_query, max_id, object : TootApiCallback { - override val isApiCancelled : Boolean - get() = isCancelled || is_dispose.get() - - override fun publishApiProgress(s : String) { - Utils.runOnMainThread { - if(isCancelled) return@runOnMainThread - task_progress = s - fireShowContent() - } - } - }) + result = client.searchMsp( search_query, max_id) val jsonArray = result?.jsonArray if(jsonArray != null) { // max_id の更新 - max_id = MSPClient.getMaxId(jsonArray, max_id) + max_id = TootApiClient.getMspMaxId(jsonArray, max_id) // リストデータの用意 val search_result = TootStatus.parseList(parser, jsonArray, serviceType = ServiceType.MSP) list_tmp = addWithFilterStatus(list_tmp, search_result) @@ -2375,22 +2348,11 @@ class Column( list_tmp = ArrayList() result = TootApiResult(context.getString(R.string.end_of_list)) } else { - result = TootsearchClient.search(context, search_query, max_id, object : TootApiCallback { - override val isApiCancelled : Boolean - get() = isCancelled || is_dispose.get() - - override fun publishApiProgress(s : String) { - Utils.runOnMainThread { - if(isCancelled) return@runOnMainThread - task_progress = s - fireShowContent() - } - } - }) + result = client.searchTootsearch( search_query, max_id) val jsonObject = result?.jsonObject if(jsonObject != null) { // max_id の更新 - max_id = TootsearchClient.getMaxId(jsonObject, max_id) + max_id = TootApiClient.getTootsearchMaxId(jsonObject, max_id) // リストデータの用意 val search_result = TootStatus.parseListTootsearch(parser, jsonObject) list_tmp = addWithFilterStatus(list_tmp, search_result) @@ -2746,7 +2708,7 @@ class Column( } override fun doInBackground(vararg params : Void) : TootApiResult? { - val client = TootApiClient(context, object : TootApiCallback { + val client = TootApiClient(context, callback=object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() @@ -2759,7 +2721,7 @@ class Column( } }) - client.setAccount(access_info) + client.account = access_info try { return when(column_type) { @@ -2948,31 +2910,6 @@ class Column( } } - override fun onStreamingMessage(event_type : String, item : Any?) { - if(is_dispose.get()) return - - if("delete" == event_type) { - if(item is Long) { - removeStatus(access_info, item) - } - } else { - if(item is TootNotification) { - if(column_type != TYPE_NOTIFICATIONS) return - if(isFiltered(item)) return - } else if(item is TootStatus) { - if(column_type == TYPE_NOTIFICATIONS) return - if(column_type == TYPE_LOCAL && item.account.acct.indexOf('@') != - 1) return - if(isFiltered(item)) return - - if(this.enable_speech) { - App1.getAppState(context).addSpeech(item.reblog ?: item) - } - } - stream_data_queue.addFirst(item) - proc_stream_data.run() - } - } - internal fun onStart(callback : Callback) { this.callback_ref = WeakReference(callback) @@ -3118,7 +3055,7 @@ class Column( stream_data_queue.clear() app_state.stream_reader.register( - access_info, stream_path, highlight_trie, this + access_info, stream_path, highlight_trie, onStreamingMessage ) } @@ -3128,18 +3065,47 @@ class Column( val stream_path = streamPath if(stream_path != null) { app_state.stream_reader.unregister( - access_info, stream_path, this + access_info, stream_path, onStreamingMessage ) } } - private val proc_stream_data = object : Runnable { + private val onStreamingMessage = fun(event_type : String, item : Any?) { + if(is_dispose.get()) return + if("delete" == event_type) { + if(item is Long) { + removeStatus(access_info, item) + } + return + } + + if(item is TootNotification) { + if(column_type != TYPE_NOTIFICATIONS) return + if(isFiltered(item)) return + } else if(item is TootStatus) { + if(column_type == TYPE_NOTIFICATIONS) return + if(column_type == TYPE_LOCAL && item.account.acct.indexOf('@') != - 1) return + if(isFiltered(item)) return + + if(this.enable_speech) { + App1.getAppState(context).addSpeech(item.reblog ?: item) + } + } + + stream_data_queue.addFirst(item) + mergeStreamingMessage.run() + } + + private val mergeStreamingMessage = object : Runnable { override fun run() { App1.getAppState(context).handler.removeCallbacks(this) + val now = SystemClock.elapsedRealtime() + + // 前回マージしてから暫くは待機する val remain = last_show_stream_data + 333L - now if(remain > 0) { - App1.getAppState(context).handler.postDelayed(this, 333L) + App1.getAppState(context).handler.postDelayed(this, remain) return } last_show_stream_data = now @@ -3147,29 +3113,42 @@ class Column( val list_new = duplicate_map.filterDuplicate(stream_data_queue) stream_data_queue.clear() - if(list_new.isEmpty()) { - return - } else { - if(column_type == TYPE_NOTIFICATIONS) { - val list = ArrayList() - for(o in list_new) { - if(o is TootNotification) { - list.add(o) - } - } - if(! list.isEmpty()) { - PollingWorker.injectData(context, access_info.db_id, list) + if(list_new.isEmpty()) return + + // 通知カラムならストリーミング経由で届いたデータを通知ワーカーに伝達する + if(column_type == TYPE_NOTIFICATIONS) { + val list = ArrayList() + for(o in list_new) { + if(o is TootNotification) { + list.add(o) } } - - try { - since_id = getId(list_new[0]).toString() - } catch(ex : Throwable) { - // ストリームに来るのは通知かステータスだから、多分ここは通らない - log.e(ex, "getId() failed. o=", list_new[0]) + if(! list.isEmpty()) { + PollingWorker.injectData(context, access_info.db_id, list) } - } + + // 最新のIDをsince_idとして覚える(ソートはしない) + var new_id_max = Long.MIN_VALUE + var new_id_min = Long.MAX_VALUE + for(o in list_new) { + try { + val id = getId(o) + if(id < 0) continue + if(id > new_id_max) new_id_max = id + if(id < new_id_min) new_id_min = id + } catch(ex : Throwable) { + // IDを取得できないタイプのオブジェクトだった + // ストリームに来るのは通知かステータスだから、多分ここは通らない + log.trace(ex) + } + } + if(new_id_max != Long.MAX_VALUE) { + since_id = new_id_max.toString() + // XXX: コレはリフレッシュ時に取得漏れを引き起こすのでは…? + // しかしコレなしだとリフレッシュ時に大量に読むことになる… + } + val holder = viewHolder // 事前にスクロール位置を覚えておく @@ -3191,14 +3170,14 @@ class Column( } } + // 画面復帰時の自動リフレッシュではギャップが残る可能性がある if(bPutGap) { bPutGap = false try { - if(list_new.size > 0 && list_data.size > 0) { - val max = getId(list_new[list_new.size - 1]) + if(list_data.size > 0 && new_id_min != Long.MAX_VALUE) { val since = getId(list_data[0]) - if(max > since) { - val gap = TootGap(max, since) + if(new_id_min > since) { + val gap = TootGap(new_id_min, since) list_new.add(gap) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/PollingWorker.kt b/app/src/main/java/jp/juggler/subwaytooter/PollingWorker.kt index 209a8af8..2ce7d0ea 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/PollingWorker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/PollingWorker.kt @@ -51,12 +51,7 @@ import jp.juggler.subwaytooter.table.MutedApp import jp.juggler.subwaytooter.table.MutedWord import jp.juggler.subwaytooter.table.NotificationTracking import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.util.LogCategory -import jp.juggler.subwaytooter.util.NotificationHelper -import jp.juggler.subwaytooter.util.TaskList -import jp.juggler.subwaytooter.util.Utils -import jp.juggler.subwaytooter.util.WordTrieTree -import jp.juggler.subwaytooter.util.WorkerBase +import jp.juggler.subwaytooter.util.* import okhttp3.Call import okhttp3.Request import okhttp3.RequestBody @@ -755,7 +750,7 @@ class PollingWorker private constructor(c : Context) { val body = response.body()?.string() if(! response.isSuccessful || body?.isEmpty() != false ) { - log.e(Utils.formatResponse(response, "getInstallId: get /counter failed.")) + log.e(TootApiClient.formatResponse(response, "getInstallId: get /counter failed.")) return null } @@ -980,11 +975,11 @@ class PollingWorker private constructor(c : Context) { } } - internal inner class AccountThread(val account : SavedAccount) : Thread(), TootApiClient.CurrentCallCallback { + internal inner class AccountThread(val account : SavedAccount) : Thread(), CurrentCallCallback { private var current_call : Call? = null - val client = TootApiClient(context, object : TootApiCallback { + val client = TootApiClient(context, callback=object : TootApiCallback { override val isApiCancelled : Boolean get() = job.isJobCancelled }) @@ -996,7 +991,7 @@ class PollingWorker private constructor(c : Context) { private var nid_last_show = - 1L init { - client.setCurrentCallCallback(this) + client.currentCallCallback = this } override fun onCallCreated(call : Call) { @@ -1228,8 +1223,7 @@ class PollingWorker private constructor(c : Context) { if(now - nr.last_load >= 60000L * 2) { nr.last_load = now - client.setAccount(account) - + client.account = account for(nTry in 0 .. 3) { if(job.isJobCancelled) return diff --git a/app/src/main/java/jp/juggler/subwaytooter/StreamReader.kt b/app/src/main/java/jp/juggler/subwaytooter/StreamReader.kt index c2968cdb..bd6d3e9f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/StreamReader.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/StreamReader.kt @@ -42,10 +42,6 @@ internal class StreamReader( private val reader_list = LinkedList() - internal interface Callback { - fun onStreamingMessage(event_type : String, item : Any?) - } - private inner class Reader( internal val access_info : SavedAccount, internal val end_point : String, @@ -55,7 +51,7 @@ internal class StreamReader( internal val bDisposed = AtomicBoolean() internal val bListening = AtomicBoolean() internal val socket = AtomicReference(null) - internal val callback_list = LinkedList() + internal val callback_list = LinkedList< (event_type : String, item : Any?)->Unit >() internal val parser : TootParser init { @@ -76,14 +72,14 @@ internal class StreamReader( this.parser.setHighlightTrie(highlight_trie) } - @Synchronized internal fun addCallback(stream_callback : Callback) { + @Synchronized internal fun addCallback(stream_callback : (event_type : String, item : Any?)->Unit ) { for(c in callback_list) { if(c === stream_callback) return } callback_list.add(stream_callback) } - @Synchronized internal fun removeCallback(stream_callback : Callback) { + @Synchronized internal fun removeCallback(stream_callback : (event_type : String, item : Any?)->Unit) { val it = callback_list.iterator() while(it.hasNext()) { val c = it.next() @@ -120,15 +116,14 @@ internal class StreamReader( } Utils.runOnMainThread { - if(bDisposed.get()) return@runOnMainThread synchronized(this) { + if(bDisposed.get()) return@runOnMainThread for(callback in callback_list) { try { - callback.onStreamingMessage(event, payload) + callback(event, payload) } catch(ex : Throwable) { log.trace(ex) } - } } } @@ -239,7 +234,7 @@ internal class StreamReader( accessInfo : SavedAccount, endPoint : String, highlightTrie : WordTrieTree?, - streamCallback : Callback + streamCallback : (event_type : String, item : Any?)->Unit ) { val reader = prepareReader(accessInfo, endPoint, highlightTrie) @@ -255,7 +250,7 @@ internal class StreamReader( fun unregister( accessInfo : SavedAccount, endPoint : String, - streamCallback : Callback + streamCallback : (event_type : String, item : Any?)->Unit ) { synchronized(reader_list) { val it = reader_list.iterator() diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/DuplicateMap.kt b/app/src/main/java/jp/juggler/subwaytooter/api/DuplicateMap.kt index e81acdd9..44547a2c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/DuplicateMap.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/DuplicateMap.kt @@ -24,18 +24,27 @@ class DuplicateMap { set_status_uri.clear() } - private fun isDuplicate(o : Any) : Boolean { + fun isDuplicate(o : Any) : Boolean { when(o) { is TootStatus ->{ val uri = o.uri - if( uri != null && uri.isNotEmpty() ){ - if(set_status_uri.contains(o.uri)) return true - set_status_uri.add(o.uri) - }else{ - if(set_status_id.contains(o.id)) return true - set_status_id.add(o.id) + val url = o.url + when { + uri?.isNotEmpty() == true -> { + if(set_status_uri.contains(uri)) return true + set_status_uri.add(uri) + } + url?.isNotEmpty() == true -> { + // URIとURLで同じマップを使いまわすが、害はないと思う… + if(set_status_uri.contains(url)) return true + set_status_uri.add(url) + } + else -> { + if(set_status_id.contains(o.id)) return true + set_status_id.add(o.id) + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/MSPClient.kt b/app/src/main/java/jp/juggler/subwaytooter/api/MSPClient.kt deleted file mode 100644 index 93dd2514..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/api/MSPClient.kt +++ /dev/null @@ -1,174 +0,0 @@ -package jp.juggler.subwaytooter.api - -import android.content.Context -import android.net.Uri - -import org.json.JSONArray -import org.json.JSONObject - -import jp.juggler.subwaytooter.App1 -import jp.juggler.subwaytooter.Pref -import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.util.LogCategory -import jp.juggler.subwaytooter.util.Utils -import okhttp3.Request -import okhttp3.Response - -object MSPClient { - private val log = LogCategory("MSPClient") - - private val url_token = "http://mastodonsearch.jp/api/v1.0.1/utoken" - private val url_search = "http://mastodonsearch.jp/api/v1.0.1/cross" - private val api_key = "e53de7f66130208f62d1808672bf6320523dcd0873dc69bc" - - private val ok_http_client = App1.ok_http_client - - fun search( - context : Context, - query : String, - max_id : String, - callback : TootApiCallback - ) : TootApiResult? { - - // ユーザトークンを読む - val pref = Pref.pref(context) - var user_token = pref.getString(Pref.KEY_MASTODON_SEARCH_PORTAL_USER_TOKEN, null) - - var response : Response - - for(nTry in 0 .. 9) { - // ユーザトークンがなければ取得する - if(user_token == null || user_token.isEmpty() ) { - - callback.publishApiProgress("get MSP user token...") - - val url = url_token + "?apikey=" + Uri.encode(api_key) - - try { - val request = Request.Builder() - .url(url) - .build() - val call = ok_http_client.newCall(request) - response = call.execute() - } catch(ex : Throwable) { - log.trace(ex) - return TootApiResult(Utils.formatError(ex, context.resources, R.string.network_error)) - } - - if(callback.isApiCancelled) return null - val bodyString = response.body()?.string() - if(callback.isApiCancelled) return null - - if(! response.isSuccessful) { - val result = TootApiResult( 0,response = response) - val code = response.code() - - if(response.code() < 400 || bodyString == null ) { - result.error = Utils.formatResponse(response, "マストドン検索ポータル", bodyString ?: "(no information)") - return result - } - - result.bodyString = bodyString - - try { - val obj = JSONObject(bodyString) - val type = Utils.optStringX(obj,"type") - val error = Utils.optStringX(obj,"error") - if( error != null && error.isNotEmpty() ){ - result.error = "API returns error. $code, $type, $error" - return result - } - } catch(ex : Throwable) { - log.trace(ex) - } - result.error = Utils.formatResponse(response,"マストドン検索ポータル",bodyString) - return result - } - - try { - user_token = JSONObject(bodyString).getJSONObject("result").getString("token") - if( user_token == null || user_token.isEmpty() ) { - return TootApiResult("Can't get MSP user token. response=$bodyString") - } - pref.edit().putString(Pref.KEY_MASTODON_SEARCH_PORTAL_USER_TOKEN, user_token).apply() - } catch(ex : Throwable) { - log.trace(ex) - return TootApiResult(Utils.formatError(ex, "API data error")) - } - } - - // ユーザトークンを使って検索APIを呼び出す - callback.publishApiProgress("waiting search result...") - val url = (url_search - + "?apikey=" + Uri.encode(api_key) - + "&utoken=" + Uri.encode(user_token) - + "&q=" + Uri.encode(query) - + "&max=" + Uri.encode(max_id)) - - try { - val request = Request.Builder() - .url(url) - .build() - val call = ok_http_client.newCall(request) - response = call.execute() - } catch(ex : Throwable) { - log.trace(ex) - return TootApiResult(Utils.formatError(ex, context.resources, R.string.network_error)) - } - - if(callback.isApiCancelled) return null - val bodyString = response.body()?.string() - if(callback.isApiCancelled) return null - - if(! response.isSuccessful) { - val result = TootApiResult( 0,response = response) - val code = response.code() - if(response.code() < 400 || bodyString == null ) { - result.error = Utils.formatResponse(response, "マストドン検索ポータル", bodyString ?: "(no information)") - return result - } - try { - val error = JSONObject(bodyString).getJSONObject("error") - val detail = error.optString("detail") - val type = error.optString("type") - // ユーザトークンがダメなら生成しなおす - if("utoken" == detail ) { - user_token = null - continue - } - result.error = "API returns error. $code, $type, $detail" - return result - } catch(ex : Throwable) { - log.trace(ex) - } - result.error = Utils.formatResponse(response,"マストドン検索ポータル",bodyString) - return result - } - - try{ - if( bodyString != null ) { - val array = JSONArray(bodyString) - return TootApiResult(response = response, bodyString = bodyString, data = array) - } - } catch(ex : Throwable) { - log.trace(ex) - } - return TootApiResult( response, Utils.formatResponse(response,"マストドン検索ポータル",bodyString)) - } - return TootApiResult("MSP user token retry exceeded.") - } - - fun getMaxId(array : JSONArray, max_id : String) : String { - // 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!= null && sv.isNotEmpty() ) return sv - } - } - return max_id - } - -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt index 4b7a9247..1a671acd 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt @@ -1,64 +1,182 @@ package jp.juggler.subwaytooter.api import android.content.Context +import android.content.SharedPreferences import android.net.Uri import org.json.JSONException import org.json.JSONObject import jp.juggler.subwaytooter.App1 +import jp.juggler.subwaytooter.Pref 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 jp.juggler.subwaytooter.util.* +import okhttp3.* import org.json.JSONArray +import java.util.regex.Pattern class TootApiClient( - private val context : Context, - private val callback : TootApiCallback + internal val context : Context, + internal val httpClient : SimpleHttpClient = SimpleHttpClientImpl(App1.ok_http_client), + internal 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 + // 認証に関する設定を保存する + 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") + + 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" + internal 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/" + + private const val NO_INFORMATION = "(no information)" + + private val reStartJsonArray = Pattern.compile("\\A\\s*\\[") + private val reStartJsonObject = Pattern.compile("\\A\\s*\\{") + + private val mspTokenUrl = "http://mastodonsearch.jp/api/v1.0.1/utoken" + private val mspSearchUrl = "http://mastodonsearch.jp/api/v1.0.1/cross" + private val mspApiKey = "e53de7f66130208f62d1808672bf6320523dcd0873dc69bc" + + fun getMspMaxId(array : JSONArray, max_id : String) : String { + // 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 + } + } + return max_id + } + + fun getTootsearchHits(root : JSONObject) : JSONArray? { + val hits = root.optJSONObject("hits") + return hits?.optJSONArray("hits") + } + + // returns the number for "from" parameter of next page. + // returns "" if no more next page. + fun getTootsearchMaxId(root : JSONObject, old : String) : String { + val old_from = Utils.parse_int(old, 0) + val hits2 = getTootsearchHits(root) + if(hits2 != null) { + val size = hits2.length() + return if(size == 0) "" else Integer.toString(old_from + hits2.length()) + } + return "" + } + + val DEFAULT_JSON_ERROR_PARSER = { json : JSONObject -> + Utils.optStringX(json, "error") + } + + internal fun simplifyErrorHtml( + response : Response, + sv : String, + jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER + ) : String { + + // JSONObjectとして解釈できるならエラーメッセージを検出する + try { + val data = JSONObject(sv) + val error_message = jsonErrorParser(data) + 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") { + return DecodeOptions().decodeHTML(null, null, sv).toString() + } + + // XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない + + return sv + } + + 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 != null && message.isNotEmpty()) { + sb.append(' ').append(message) + } + sb.append(")") + + if(caption.isNotEmpty()) { + sb.append(' ').append(caption) + } + + } catch(ex : Throwable) { + log.trace(ex) + } + + return sb.toString().replace("\n+".toRegex(), "\n") + } + + } @Suppress("unused") - val isApiCancelled : Boolean - get() = callback.isApiCancelled - - val isCancelled : Boolean + internal val isApiCancelled : Boolean get() = callback.isApiCancelled fun publishApiProgress(s : String) { @@ -69,179 +187,266 @@ class TootApiClient( 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) + // リクエストをokHttpに渡してレスポンスを取得する + internal inline fun sendRequest( + result : TootApiResult, + progressPath : String? = null, + block : () -> Request + ) : Boolean { + return try { + result.response = null + result.bodyString = null + result.data = null - if(access_token != null && access_token.isNotEmpty()) { - request_builder.header("Authorization", "Bearer " + access_token) - } + val request = block() + + callback.publishApiProgress( + context.getString( + R.string.request_api + , request.method() + , progressPath ?: request.url().encodedPath() + ) + ) + + result.response = httpClient.getResponse(request) + + null == result.error - sendRequest(request_builder.build()) } catch(ex : Throwable) { - log.trace(ex) - return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error)) + result.setError(result.caption + ": " + Utils.formatError(ex, context.resources, R.string.network_error)) + false } - 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 + // レスポンスがエラーかボディがカラならエラー状態を設定する + // 例外を出すかも + 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() + if(request != null) { + publishApiProgress(context.getString(R.string.reading_api, request.method(), progressPath ?: result.caption)) + } + + val bodyString = response.body()?.string() + if(isApiCancelled) return null + + 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 + } + } + + internal fun parseString( + result : TootApiResult, + progressPath : String? = null, + jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER + ) : TootApiResult? { + + val response = result.response !! // nullにならないはず try { - val account = this.account ?: return TootApiResult("account is null") - val access_token = account.getAccessToken() + val bodyString = readBodyString(result, progressPath, jsonErrorParser) + ?: return if(isApiCancelled) null else result - var url = "wss://" + instance + path + result.data = bodyString - 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) + result.error = formatResponse(response, result.caption, result.bodyString ?: NO_INFORMATION) + } + return result + } + + // レスポンスから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 { + val bodyString = readBodyString(result, progressPath, jsonErrorParser) + ?: return if(isApiCancelled) null else result + + if(reStartJsonArray.matcher(bodyString).find()) { + result.data = JSONArray(bodyString) + + } else if(reStartJsonObject.matcher(bodyString).find()) { + val json = JSONObject(bodyString) + val error_message = jsonErrorParser(json) + if(error_message != null) { + result.error = error_message + } else { + result.data = json + } + } else { + result.error = context.getString(R.string.response_not_json) + "\n" + bodyString + } + + } catch(ex : Throwable) { + log.trace(ex) + result.error = formatResponse(response, result.caption, result.bodyString ?: NO_INFORMATION) } return result } + ////////////////////////////////////////////////////////////////////// + + fun request(path : String, request_builder : Request.Builder = Request.Builder()) : TootApiResult? { + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + + val account = this.account ?: return result.setError("account is null") + + 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 + + return parseJson(result) + } finally { + val error = result.error + if(error != null) log.d("error: $error") + } + } + // 疑似アカウントの追加時に、インスタンスの検証を行う 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) + if(! sendRequest(result) { + Request.Builder().url("https://$instance/api/v1/instance").build() + }) return result + return parseJson(result) } + // クライアントをタンスに登録 + internal fun registerClient(clientName : String) : TootApiResult? { + val result = TootApiResult.makeWithCaption(this.instance) + if(result.error != null) return result + val instance = result.caption // same to instance + + // OAuth2 クライアント登録 + if(! sendRequest(result) { + Request.Builder() + .url("https://$instance/api/v1/apps") + .post(RequestBody.create(MEDIA_TYPE_FORM_URL_ENCODED, "client_name=" + Uri.encode(clientName) + + "&redirect_uris=" + Uri.encode(REDIRECT_URL) + + "&scopes=read write follow" + )) + .build() + }) return result + + return parseJson(result) + } + // クライアントアプリの登録を確認するためのトークンを生成する // oAuth2 Client Credentials の取得 // https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow // このトークンはAPIを呼び出すたびに新しく生成される… - private fun getClientCredential(client_info : JSONObject) : TootApiResult? { + internal 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() + if(! sendRequest(result) { + 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)) - } + }) return result - val r2 = readJson(result, response) + val r2 = parseJson(result) 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." + result.error = "missing client credential." } return result } // client_credentialがまだ有効か調べる - private fun verifyClientCredential(client_credential : String) : TootApiResult? { + internal 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() + if(! sendRequest(result) { + 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 result - return readJson(result, response) + return parseJson(result) } - private fun prepareBrowserUrl(client_info : JSONObject) : String { + internal fun prepareBrowserUrl(client_info : JSONObject) : String { val account = this.account // 認証ページURLを作る - val browser_url = ("https://" + instance + "/oauth/authorize" + + return ("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" + + "&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) @@ -256,7 +461,7 @@ class TootApiClient( var client_credential = Utils.optStringX(client_info, KEY_CLIENT_CREDENTIAL) // client_credential をまだ取得していないなら取得する - if(client_credential == null || client_credential.isEmpty()) { + if(client_credential?.isEmpty() != false) { val resultSub = getClientCredential(client_info) client_credential = resultSub?.string if(client_credential?.isNotEmpty() == true) { @@ -278,29 +483,14 @@ class TootApiClient( } } - // 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 r2 = registerClient(client_name) 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 } @@ -313,57 +503,44 @@ class TootApiClient( 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 { + if(! sendRequest(result) { 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") + + "&scope=read+write+follow" + + "&scopes=read+write+follow") - val request = Request.Builder() + 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)) - } + }) return result - val token_info : JSONObject - - val r2 = readJson(result, response) - val jsonObject = r2?.jsonObject ?: return r2 + val r2 = parseJson(result) + val token_info = 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 + token_info.put(KEY_AUTH_VERSION, AUTH_VERSION) + result.token_info = token_info 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() + // 認証されたアカウントのユーザ名を取得する + if(! sendRequest(result) { + 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 result - return readJson(result, response) + return parseJson(result) } // アクセストークン手動入力でアカウントを更新する場合 @@ -372,140 +549,172 @@ class TootApiClient( 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() + // 認証されたアカウントのユーザ名を取得する + if(! sendRequest(result) { + 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 result - val r2 = readJson(result, response) + val r2 = parseJson(result) r2?.jsonObject ?: return r2 // credentialを読めたならtoken_infoを保存したい + val token_info = JSONObject() + token_info.put("access_token", access_token) result.token_info = token_info + return result } + fun searchMsp(query : String, max_id : String) : TootApiResult? { + + // ユーザトークンを読む + var user_token = pref.getString(Pref.KEY_MASTODON_SEARCH_PORTAL_USER_TOKEN, null) + + for(nTry in 0 until 3) { + if(callback.isApiCancelled) return null + + // ユーザトークンがなければ取得する + if(user_token == null || user_token.isEmpty()) { + + 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=" + Uri.encode(mspApiKey)) + .build() + + }) return result + + val r2 = parseJson(result) { json -> + val error = Utils.optStringX(json, "error") + if(error == null) { + null + } else { + val type = Utils.optStringX(json, "type") + "API returns error: $type $error" + } + } + 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}") + } + + pref.edit().putString(Pref.KEY_MASTODON_SEARCH_PORTAL_USER_TOKEN, user_token).apply() + } + + // ユーザトークンを使って検索APIを呼び出す + val result : TootApiResult = TootApiResult.makeWithCaption("Mastodon Search Portal") + if(result.error != null) return result + + if(! sendRequest(result) { + + val url = (mspSearchUrl + + "?apikey=" + Uri.encode(mspApiKey) + + "&utoken=" + Uri.encode(user_token) + + "&q=" + Uri.encode(query) + + "&max=" + Uri.encode(max_id)) + + Request.Builder().url(url).build() + + }) return result + + var isUserTokenError = false + val r2 = parseJson(result) { json -> + val error = Utils.optStringX(json, "error") + if(error == null) { + null + } else { + // ユーザトークンがダメなら生成しなおす + val detail = json.optString("detail") + if("utoken" == detail) { + isUserTokenError = true + } + + val type = Utils.optStringX(json, "type") + "API returns error: $type $error" + } + } + if(r2 == null || ! isUserTokenError) return r2 + } + return TootApiResult("MSP user token retry exceeded.") + } + + fun searchTootsearch( + query : String, + max_id : String // 空文字列、もしくはfromに指定するパラメータ + ) : TootApiResult? { + + val result = TootApiResult.makeWithCaption("Tootsearch") + if(result.error != null) return result + + if(! sendRequest(result) { + val url = ("https://tootsearch.chotto.moe/api/v1/search" + + "?sort=" + Uri.encode("created_at:desc") + + "&from=" + max_id + + "&q=" + Uri.encode(query)) + + Request.Builder() + .url(url) + .build() + + }) return result + + return parseJson(result) + } + + //////////////////////////////////////////////////////////////////////// + // JSONデータ以外を扱うリクエスト + + // 疑似アカウントでステータスURLからステータスIDを取得するためにHTMLを取得する 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 - val request = response.request() - if( request != null ){ - callback.publishApiProgress(context.getString(R.string.reading_api, request.method(), url)) - } - result.readBodyString(response) - - if(callback.isApiCancelled) return null - 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 + if(! sendRequest(result, progressPath = url) { + Request.Builder().url(url).build() + }) return result + return parseString(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? { + fun webSocket(path : String, request_builder : Request.Builder, ws_listener : WebSocketListener) : TootApiResult? { + val result = TootApiResult.makeWithCaption(instance) + if(result.error != null) return result + val account = this.account ?: return TootApiResult("account is null") + try { - if(callback.isApiCancelled) return null - val request = response.request() - if( request != null ){ - callback.publishApiProgress(context.getString(R.string.reading_api, request.method(), request.url().encodedPath())) - } - result.readBodyString(response) - - if(callback.isApiCancelled) return null - callback.publishApiProgress(context.getString(R.string.parsing_response)) - if(result.isErrorOrEmptyBody()) return result + var url = "wss://$instance$path" - 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 + val access_token = account.getAccessToken() + if(access_token?.isNotEmpty() == true) { + 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() + 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 = Utils.formatResponse(response, result.caption, result.bodyString ?: "no information") + result.error = result.caption + ": " + Utils.formatError(ex, context.resources, R.string.network_error) } 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")) - // } - // - // } - -} \ No newline at end of file +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiResult.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiResult.kt index 54bcf0fb..0d72758e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiResult.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiResult.kt @@ -6,26 +6,55 @@ import org.json.JSONObject import java.util.regex.Pattern import jp.juggler.subwaytooter.util.LogCategory -import jp.juggler.subwaytooter.util.Utils import okhttp3.Response import okhttp3.WebSocket open class TootApiResult( - @Suppress("unused") val dummy :Int =0, + @Suppress("unused") val dummy : Int = 0, var error : String? = null, var response : Response? = null, var bodyString : String? = null ) { + var token_info : JSONObject? = null - + var data : Any? = null - set(value){ + set(value) { if(value is JSONArray) { parseLinkHeader(response, value) } field = value } + val jsonObject : JSONObject? + get() = data as? JSONObject + + val jsonArray : JSONArray? + get() = data as? JSONArray + + val string : String? + get() = data as? String + + var link_older : String? = null // より古いデータへのリンク + var link_newer : String? = null // より新しいデータへの + var caption : String = "?" + + constructor() : this(0) + + constructor(error : String) : this(0, error = error) + + constructor(response : Response, error : String) + : this(0, error, response) + + constructor(response : Response, bodyString : String, data : Any?) + : this(0, response = response, bodyString = bodyString) { + this.data = data + } + + constructor(socket : WebSocket) : this(0) { + this.data = socket + } + companion object { private val log = LogCategory("TootApiResult") @@ -35,7 +64,6 @@ open class TootApiResult( private const val MIMUMEDON_ERROR = "mimumedon.comには対応しません" private const val NO_INSTANCE = "missing instance name" - const val NO_INFORMATION = "(no information)" fun makeWithCaption(caption : String?) : TootApiResult { val result = TootApiResult() @@ -49,89 +77,39 @@ open class TootApiResult( } return result } + } - var link_older : String? = null // より古いデータへのリンク - var link_newer : String? = null // より新しいデータへの - var caption : String = "?" - - constructor():this(0) - - constructor( error : String) : this(0,error=error) - - constructor( response : Response, error : String ) - : this(0,error,response) - - constructor( response : Response, bodyString : String, data : Any? ) - : this(0,response = response,bodyString = bodyString) - { - this.data = data - } - - constructor( socket : WebSocket) : this(0){ - this.data = socket - } - - // return result.setError(...) と書きたい - fun setError(error:String) :TootApiResult{ + fun setError(error : String) : TootApiResult { this.error = error return this } - // レスポンスボディを読む - fun readBodyString(response : Response) { - this.response = response - this.bodyString = response.body()?.string() - } - // レスポンスがエラーかボディがカラならエラー状態を設定する - // エラーがあれば真を返す - fun isErrorOrEmptyBody() :Boolean{ - val response = this.response ?: throw NotImplementedError("not calling readBodyString") - if(! response.isSuccessful || bodyString == null ) { - error = Utils.formatResponse(response, caption, bodyString ?: NO_INFORMATION) - } - return error != null - } + - - - private fun parseLinkHeader( - response : Response?, - array : JSONArray - ) { - if( response != null){ - log.d("array size=%s", array.length() ) - - val sv = response.header("Link") - if(sv == null) { - log.d("missing Link header") - } else { - // Link: ; rel="next", - // ; rel="prev" - val m = reLinkURL.matcher(sv) - while(m.find()) { - val url = m.group(1) - val rel = m.group(2) - // log.d("Link %s,%s",rel,url); - if("next" == rel) link_older = url - if("prev" == rel) link_newer = url - } + private fun parseLinkHeader( response : Response?, array : JSONArray ) { + response ?: return + + log.d("array size=%s", array.length()) + + val sv = response.header("Link") + if(sv == null) { + log.d("missing Link header") + } else { + // Link: ; rel="next", + // ; rel="prev" + val m = reLinkURL.matcher(sv) + while(m.find()) { + val url = m.group(1) + val rel = m.group(2) + // log.d("Link %s,%s",rel,url); + if("next" == rel) link_older = url + if("prev" == rel) link_newer = url } } } - - val jsonObject :JSONObject? - get() = data as? JSONObject - - val jsonArray :JSONArray? - get() = data as? JSONArray - - val string: String? - get() = data as? String - - } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootTaskRunner.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootTaskRunner.kt index ff73799b..6103d332 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootTaskRunner.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootTaskRunner.kt @@ -95,7 +95,7 @@ class TootTaskRunner @JvmOverloads constructor( init { this.refContext = WeakReference(context) this.handler = Handler() - this.client = TootApiClient(context, this) + this.client = TootApiClient(context, callback=this) this.task = MyTask(this) } @@ -108,12 +108,12 @@ class TootTaskRunner @JvmOverloads constructor( } fun run(access_info : SavedAccount, callback : TootTask) { - client.setAccount(access_info) + client.account =access_info run(callback) } fun run(instance : String, callback : TootTask) { - client.setInstance(instance) + client.instance = instance run(callback) } @@ -122,8 +122,6 @@ class TootTaskRunner @JvmOverloads constructor( return this } - - ////////////////////////////////////////////////////// // implements TootApiClient.Callback diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootsearchClient.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootsearchClient.kt deleted file mode 100644 index 2a88f662..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootsearchClient.kt +++ /dev/null @@ -1,78 +0,0 @@ -package jp.juggler.subwaytooter.api - -import android.content.Context -import android.net.Uri - -import org.json.JSONArray -import org.json.JSONObject - -import jp.juggler.subwaytooter.App1 -import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.util.LogCategory -import jp.juggler.subwaytooter.util.Utils -import okhttp3.Request -import okhttp3.Response - -object TootsearchClient { - private val log = LogCategory("TootsearchClient") - - private val ok_http_client = App1.ok_http_client - - fun search( - context : Context, - query : String, - max_id : String, // 空文字列、もしくはfromに指定するパラメータ - callback : TootApiCallback - ) : TootApiResult? { - val url = ("https://tootsearch.chotto.moe/api/v1/search" - + "?sort=" + Uri.encode("created_at:desc") - + "&from=" + max_id - + "&q=" + Uri.encode(query)) - - val response : Response - try { - val request = Request.Builder() - .url(url) - .build() - - callback.publishApiProgress("waiting search result...") - val call = ok_http_client.newCall(request) - response = call.execute() - } catch(ex : Throwable) { - log.trace(ex) - return TootApiResult(Utils.formatError(ex, context.resources, R.string.network_error)) - } - - if(callback.isApiCancelled) return null - val bodyString = response.body()?.string() - if(! response.isSuccessful || bodyString == null ) { - log.d("response failed.") - return TootApiResult(Utils.formatResponse(response, "Tootsearch",bodyString ?: "(no information)")) - } - - return try { - TootApiResult(response,bodyString,JSONObject(bodyString)) - } catch(ex : Throwable) { - log.trace(ex) - TootApiResult(Utils.formatError(ex, "API data error")) - } - } - - fun getHits(root : JSONObject) : JSONArray? { - val hits = root.optJSONObject("hits") - return hits?.optJSONArray("hits") - } - - // returns the number for "from" parameter of next page. - // returns "" if no more next page. - fun getMaxId(root : JSONObject, old : String) : String { - val old_from = Utils.parse_int(old, 0) - val hits2 = getHits(root) - if(hits2 != null) { - val size = hits2.length() - return if(size == 0) "" else Integer.toString(old_from + hits2.length()) - } - return "" - } - -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/CustomEmoji.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/CustomEmoji.kt index ada4c661..331ad1e9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/CustomEmoji.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/CustomEmoji.kt @@ -4,15 +4,9 @@ import org.json.JSONObject import jp.juggler.subwaytooter.util.Utils class CustomEmoji( - // shortcode (コロンを含まない) - val shortcode : String, - - // 画像URL - val url : String, - - // アニメーションなしの画像URL - val static_url : String? - + val shortcode : String, // shortcode (コロンを含まない) + val url : String, // 画像URL + val static_url : String? // アニメーションなしの画像URL ) : Mappable { constructor(src : JSONObject) : this( diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/Mappable.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/Mappable.kt index c8fee115..49f862bd 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/Mappable.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/Mappable.kt @@ -2,4 +2,6 @@ package jp.juggler.subwaytooter.api.entity interface Mappable { val mapKey : T -} \ No newline at end of file +} + +// EntityUtil の parseMap() でマップを構築する際、マップのキーを返すインタフェース diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootNotification.kt index 4dad33ba..d46e14e2 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootNotification.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootNotification.kt @@ -9,7 +9,7 @@ class TootNotification( val json : JSONObject, val id : Long, val type : String, // One of: "mention", "reblog", "favourite", "follow" - private val created_at : String, // The time the notification was created + private val created_at : String?, // The time the notification was created val account : TootAccount?, // The Account sending the notification to the user val status : TootStatus? // The Status associated with the notification, if applicable ) { @@ -24,7 +24,7 @@ class TootNotification( json = src, id = Utils.optLongX(src, "id"), type = src.notEmptyOrThrow("type"), - created_at = src.notEmptyOrThrow("created_at"), + created_at = Utils.optStringX(src,"created_at"), account = TootAccount.parse(parser.context, parser.accessInfo, src.optJSONObject("account"), ServiceType.MASTODON), status = TootStatus.parse(parser, src.optJSONObject("status"), ServiceType.MASTODON) ) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt index 07bb1a45..47a60949 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt @@ -6,6 +6,7 @@ import android.text.SpannableString import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.Pref import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.TootApiClient import org.json.JSONObject @@ -13,7 +14,6 @@ import java.lang.ref.WeakReference import java.util.regex.Pattern import jp.juggler.subwaytooter.api.TootParser -import jp.juggler.subwaytooter.api.TootsearchClient import jp.juggler.subwaytooter.table.HighlightWord import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* @@ -391,7 +391,7 @@ class TootStatus(parser : TootParser, src : JSONObject, serviceType : ServiceTyp serviceType : ServiceType = ServiceType.TOOTSEARCH ) : TootStatus.List { val result = TootStatus.List() - val array = TootsearchClient.getHits(root) + val array = TootApiClient.getTootsearchHits(root) if(array != null) { val array_size = array.length() result.ensureCapacity(array_size) diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt b/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt index f6e4fc58..9fcce811 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt @@ -64,4 +64,13 @@ object ClientInfo { } } + + // 単体テスト用。インスタンス名を指定して削除する + internal fun delete(instance : String) { + try { + App1.database.delete(table, "$COL_HOST=?", arrayOf(instance)) + } catch(ex : Throwable) { + log.e(ex, "delete failed.") + } + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/SimpleHttpClient.kt b/app/src/main/java/jp/juggler/subwaytooter/util/SimpleHttpClient.kt new file mode 100644 index 00000000..dea717a4 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/SimpleHttpClient.kt @@ -0,0 +1,31 @@ +package jp.juggler.subwaytooter.util + +import okhttp3.* + +// okhttpそのままだとモックしづらいので +// リクエストを投げてレスポンスを得る部分をインタフェースにまとめる + +interface CurrentCallCallback { + fun onCallCreated(call : Call) +} + +interface SimpleHttpClient{ + var currentCallCallback : CurrentCallCallback? + fun getResponse(request: Request) : Response + fun getWebSocket(request: Request, webSocketListener : WebSocketListener): WebSocket +} + +class SimpleHttpClientImpl(val okHttpClient:OkHttpClient): SimpleHttpClient{ + override var currentCallCallback : CurrentCallCallback? = null + + override fun getResponse(request : Request) : Response { + val call = okHttpClient.newCall(request) + currentCallCallback?.onCallCreated(call) + return call.execute() + } + + override fun getWebSocket(request : Request, webSocketListener : WebSocketListener) : WebSocket { + return okHttpClient.newWebSocket(request,webSocketListener) + } +} + diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt index e9501f48..67662755 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt @@ -38,6 +38,7 @@ import android.util.SparseBooleanArray import android.database.Cursor import android.net.Uri +import android.view.GestureDetector import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager @@ -849,68 +850,6 @@ object Utils { return null } - fun simplifyErrorHtml(response : Response, sv : String) : String { - try { - val data = JSONObject(sv) - val error = data.getString("error") - if(error != null && error.isNotBlank()) return error - } catch(ex : Throwable) { - log.e(ex, "response body is not JSON or missing 'error' attribute.") - } - - // JSONではなかった - - // HTMLならタグの除去を試みる - val ct = response.header("content-type") - if(ct != null && ct.contains("/html")) { - return DecodeOptions().decodeHTML(null, null, sv).toString() - } - - // XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない - - return sv - } - - fun formatResponse(response : Response, caption : String, bodyString : String? = null) : String { - val sb = StringBuilder() - try { - val empty_length = sb.length - - if(bodyString != null) { - sb.append(simplifyErrorHtml(response, bodyString)) - } else { - val body = response.body() - if(body != null) { - try { - val sv = body.string() - if(sv != null && sv.isNotBlank()) { - sb.append(simplifyErrorHtml(response, sv)) - } - } catch(ex : Throwable) { - log.e(ex, "response body is not String.") - } - } - } - - if(sb.length == empty_length) sb.append(' ') - sb.append("(HTTP ").append(Integer.toString(response.code())) - - val message = response.message() - if(message != null && message.isNotEmpty()) { - sb.append(' ').append(message) - } - sb.append(")") - - if(caption.isNotEmpty()) { - sb.append(' ').append(caption) - } - - } catch(ex : Throwable) { - log.trace(ex) - } - - return sb.toString().replace("\n+".toRegex(), "\n") - } fun scanView(view : View?, callback : (view : View) -> Unit) { view ?: return