1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-01-27 09:11:23 +01:00
This commit is contained in:
tateisu 2018-01-12 18:01:25 +09:00
parent f558a1a42d
commit 0ef6186f64
25 changed files with 2125 additions and 869 deletions

View File

@ -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'

View File

@ -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<Any>()
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
package jp.juggler.subwaytooter.mock
import android.content.SharedPreferences
class MockSharedPreferences(
val map : HashMap<String, Any> = 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<String>?)
= map.get(key) as? MutableSet<String> ?: defValues
override fun edit() : SharedPreferences.Editor {
return Editor(this)
}
override fun getAll() : MutableMap<String, *> {
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<String, Any>()
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<String>?) = putAny(key, value)
}
}

View File

@ -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."))

View File

@ -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) {

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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<Any>? = 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<TootNotification>()
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<TootNotification>()
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)
}
}

View File

@ -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

View File

@ -42,10 +42,6 @@ internal class StreamReader(
private val reader_list = LinkedList<Reader>()
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<WebSocket>(null)
internal val callback_list = LinkedList<Callback>()
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()

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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"))
// }
//
// }
}
}

View File

@ -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: <https://mastodon.juggler.jp/api/v1/timelines/home?limit=XX&max_id=405228>; rel="next",
// <https://mastodon.juggler.jp/api/v1/timelines/home?limit=XX&since_id=436946>; 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: <https://mastodon.juggler.jp/api/v1/timelines/home?limit=XX&max_id=405228>; rel="next",
// <https://mastodon.juggler.jp/api/v1/timelines/home?limit=XX&since_id=436946>; 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
}

View File

@ -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

View File

@ -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 ""
}
}

View File

@ -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<String> {
constructor(src : JSONObject) : this(

View File

@ -2,4 +2,6 @@ package jp.juggler.subwaytooter.api.entity
interface Mappable<out T> {
val mapKey : T
}
}
// EntityUtil の parseMap() でマップを構築する際、マップのキーを返すインタフェース

View File

@ -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)
)

View File

@ -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)

View File

@ -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.")
}
}
}

View File

@ -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)
}
}

View File

@ -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