package jp.juggler.subwaytooter import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.net.Uri import android.os.AsyncTask import android.os.Environment import android.os.SystemClock import android.support.v4.view.ViewCompat import android.support.v7.app.AppCompatActivity import android.view.Gravity import android.view.View import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.BucketList import jp.juggler.subwaytooter.util.InstanceTicker import jp.juggler.subwaytooter.util.ScrollPosition import jp.juggler.subwaytooter.util.WordTrieTree import jp.juggler.util.* import okhttp3.Handshake import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.io.File import java.lang.ref.WeakReference import java.nio.ByteBuffer import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.regex.Pattern import kotlin.collections.ArrayList enum class StreamingIndicatorState { NONE, REGISTERED, // registered, but not listening LISTENING, } enum class ColumnTaskType { LOADING, REFRESH_TOP, REFRESH_BOTTOM, GAP } abstract class ColumnTask( val ctType : ColumnTaskType ) : AsyncTask() { val ctStarted = AtomicBoolean(false) val ctClosed = AtomicBoolean(false) } class Column( val app_state : AppState, val context : Context, val access_info : SavedAccount, val column_type : Int, val column_id : String ) { companion object { private val log = LogCategory("Column") private const val DIR_BACKGROUND_IMAGE = "columnBackground" private const val READ_LIMIT = 80 // API側の上限が80です。ただし指定しても40しか返ってこないことが多い private const val LOOP_TIMEOUT = 10000L private const val LOOP_READ_ENOUGH = 30 // フィルタ後のデータ数がコレ以上ならループを諦めます private const val RELATIONSHIP_LOAD_STEP = 40 private const val ACCT_DB_STEP = 100 private const val MISSKEY_HASHTAG_LIMIT = 30 // ステータスのリストを返すAPI private const val PATH_HOME = "/api/v1/timelines/home?limit=$READ_LIMIT" private const val PATH_DIRECT_MESSAGES = "/api/v1/timelines/direct?limit=$READ_LIMIT" private const val PATH_DIRECT_MESSAGES2 = "/api/v1/conversations?limit=$READ_LIMIT" private const val PATH_LOCAL = "/api/v1/timelines/public?limit=$READ_LIMIT&local=true" private const val PATH_TL_FEDERATE = "/api/v1/timelines/public?limit=$READ_LIMIT" private const val PATH_FAVOURITES = "/api/v1/favourites?limit=$READ_LIMIT" private const val PATH_LIST_TL = "/api/v1/timelines/list/%s?limit=$READ_LIMIT" // アカウントのリストを返すAPI private const val PATH_ACCOUNT_FOLLOWING = "/api/v1/accounts/%s/following?limit=$READ_LIMIT" // 1:account_id private const val PATH_ACCOUNT_FOLLOWERS = "/api/v1/accounts/%s/followers?limit=$READ_LIMIT" // 1:account_id private const val PATH_MUTES = "/api/v1/mutes?limit=$READ_LIMIT" private const val PATH_BLOCKS = "/api/v1/blocks?limit=$READ_LIMIT" private const val PATH_FOLLOW_REQUESTS = "/api/v1/follow_requests?limit=$READ_LIMIT" private const val PATH_FOLLOW_SUGGESTION = "/api/v1/suggestions?limit=$READ_LIMIT" private const val PATH_ENDORSEMENT = "/api/v1/endorsements?limit=$READ_LIMIT" private const val PATH_BOOSTED_BY = "/api/v1/statuses/%s/reblogged_by?limit=$READ_LIMIT" // 1:status_id private const val PATH_FAVOURITED_BY = "/api/v1/statuses/%s/favourited_by?limit=$READ_LIMIT" // 1:status_id private const val PATH_LIST_MEMBER = "/api/v1/lists/%s/accounts?limit=$READ_LIMIT" // 他のリストを返すAPI private const val PATH_REPORTS = "/api/v1/reports?limit=$READ_LIMIT" private const val PATH_NOTIFICATIONS = "/api/v1/notifications?limit=$READ_LIMIT" private const val PATH_DOMAIN_BLOCK = "/api/v1/domain_blocks?limit=$READ_LIMIT" private const val PATH_LIST_LIST = "/api/v1/lists?limit=$READ_LIMIT" // リストではなくオブジェクトを返すAPI private const val PATH_ACCOUNT = "/api/v1/accounts/%s" // 1:account_id private const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id private const val PATH_STATUSES_CONTEXT = "/api/v1/statuses/%s/context" // 1:status_id const val PATH_SEARCH = "/api/v1/search?q=%s" const val PATH_SEARCH_V2 = "/api/v2/search?q=%s" // search args 1: query(urlencoded) , also, append "&resolve=1" if resolve non-local accounts private const val PATH_INSTANCE = "/api/v1/instance" private const val PATH_LIST_INFO = "/api/v1/lists/%s" const val PATH_FILTERS = "/api/v1/filters" const val PATH_MISSKEY_PROFILE_FOLLOWING = "/api/users/following" const val PATH_MISSKEY_PROFILE_FOLLOWERS = "/api/users/followers" const val PATH_MISSKEY_PROFILE_STATUSES = "/api/users/notes" const val PATH_MISSKEY_PROFILE = "/api/users/show" const val PATH_MISSKEY_MUTES = "/api/mute/list" const val PATH_MISSKEY_FOLLOW_REQUESTS = "/api/following/requests/list" const val PATH_MISSKEY_FOLLOW_SUGGESTION = "/api/users/recommendation" const val PATH_MISSKEY_FAVORITES = "/api/i/favorites" private enum class PagingType { Default, Cursor, Offset, None, } internal const val KEY_ACCOUNT_ROW_ID = "account_id" internal const val KEY_TYPE = "type" internal const val KEY_COLUMN_ID = "column_id" internal const val KEY_DONT_CLOSE = "dont_close" private const val KEY_WITH_ATTACHMENT = "with_attachment" private const val KEY_WITH_HIGHLIGHT = "with_highlight" private const val KEY_DONT_SHOW_BOOST = "dont_show_boost" private const val KEY_DONT_SHOW_FAVOURITE = "dont_show_favourite" private const val KEY_DONT_SHOW_FOLLOW = "dont_show_follow" private const val KEY_DONT_SHOW_REPLY = "dont_show_reply" private const val KEY_DONT_SHOW_REACTION = "dont_show_reaction" private const val KEY_DONT_SHOW_VOTE = "dont_show_vote" private const val KEY_DONT_SHOW_NORMAL_TOOT = "dont_show_normal_toot" private const val KEY_DONT_STREAMING = "dont_streaming" private const val KEY_DONT_AUTO_REFRESH = "dont_auto_refresh" private const val KEY_HIDE_MEDIA_DEFAULT = "hide_media_default" private const val KEY_SYSTEM_NOTIFICATION_NOT_RELATED = "system_notification_not_related" private const val KEY_INSTANCE_LOCAL = "instance_local" private const val KEY_ENABLE_SPEECH = "enable_speech" private const val KEY_USE_OLD_API = "use_old_api" private const val KEY_LAST_VIEWING_ITEM = "lastViewingItem" private const val KEY_QUICK_FILTER = "quickFilter" private const val KEY_REGEX_TEXT = "regex_text" private const val KEY_HEADER_BACKGROUND_COLOR = "header_background_color" private const val KEY_HEADER_TEXT_COLOR = "header_text_color" private const val KEY_COLUMN_BACKGROUND_COLOR = "column_background_color" private const val KEY_COLUMN_ACCT_TEXT_COLOR = "column_acct_text_color" private const val KEY_COLUMN_CONTENT_TEXT_COLOR = "column_content_text_color" private const val KEY_COLUMN_BACKGROUND_IMAGE = "column_background_image" private const val KEY_COLUMN_BACKGROUND_IMAGE_ALPHA = "column_background_image_alpha" private const val KEY_PROFILE_ID = "profile_id" private const val KEY_PROFILE_TAB = "tab" private const val KEY_STATUS_ID = "status_id" private const val KEY_HASHTAG = "hashtag" private const val KEY_SEARCH_QUERY = "search_query" private const val KEY_SEARCH_RESOLVE = "search_resolve" private const val KEY_INSTANCE_URI = "instance_uri" internal const val KEY_COLUMN_ACCESS = "column_access" internal const val KEY_COLUMN_ACCESS_COLOR = "column_access_color" internal const val KEY_COLUMN_ACCESS_COLOR_BG = "column_access_color_bg" internal const val KEY_COLUMN_NAME = "column_name" internal const val KEY_OLD_INDEX = "old_index" internal const val TYPE_HOME = 1 const val TYPE_LOCAL = 2 internal const val TYPE_FEDERATE = 3 const val TYPE_PROFILE = 4 internal const val TYPE_FAVOURITES = 5 internal const val TYPE_REPORTS = 6 const val TYPE_NOTIFICATIONS = 7 const val TYPE_CONVERSATION = 8 const val TYPE_HASHTAG = 9 internal const val TYPE_SEARCH = 10 internal const val TYPE_MUTES = 11 internal const val TYPE_BLOCKS = 12 internal const val TYPE_FOLLOW_REQUESTS = 13 internal const val TYPE_BOOSTED_BY = 14 internal const val TYPE_FAVOURITED_BY = 15 internal const val TYPE_DOMAIN_BLOCKS = 16 internal const val TYPE_SEARCH_MSP = 17 const val TYPE_INSTANCE_INFORMATION = 18 internal const val TYPE_LIST_LIST = 19 internal const val TYPE_LIST_TL = 20 internal const val TYPE_LIST_MEMBER = 21 internal const val TYPE_SEARCH_TS = 22 internal const val TYPE_DIRECT_MESSAGES = 23 internal const val TYPE_TREND_TAG = 24 internal const val TYPE_FOLLOW_SUGGESTION = 25 internal const val TYPE_KEYWORD_FILTER = 26 internal const val TYPE_MISSKEY_HYBRID = 27 internal const val TYPE_ENDORSEMENT = 28 internal const val TYPE_LOCAL_AROUND = 29 internal const val TYPE_FEDERATED_AROUND = 30 internal const val TYPE_ACCOUNT_AROUND = 31 internal const val TYPE_SCHEDULED_STATUS = 33 internal const val TAB_STATUS = 0 internal const val TAB_FOLLOWING = 1 internal const val TAB_FOLLOWERS = 2 internal var useInstanceTicker = false internal const val QUICK_FILTER_ALL = 0 internal const val QUICK_FILTER_MENTION = 1 internal const val QUICK_FILTER_FAVOURITE = 2 internal const val QUICK_FILTER_BOOST = 3 internal const val QUICK_FILTER_FOLLOW = 4 internal const val QUICK_FILTER_REACTION = 5 internal const val QUICK_FILTER_VOTE = 6 @Suppress("UNCHECKED_CAST") private inline fun getParamAt(params : Array, idx : Int) : T { return params[idx] as T } fun loadAccount(context : Context, src : JSONObject) : SavedAccount { val account_db_id = src.parseLong(KEY_ACCOUNT_ROW_ID) ?: - 1L return if(account_db_id >= 0) { SavedAccount.loadAccount(context, account_db_id) ?: throw RuntimeException("missing account") } else { SavedAccount.na } } fun getColumnTypeName(context : Context, type : Int) : String { return when(type) { TYPE_HOME -> context.getString(R.string.home) TYPE_LOCAL_AROUND -> context.getString(R.string.ltl_around) TYPE_FEDERATED_AROUND -> context.getString(R.string.ftl_around) TYPE_ACCOUNT_AROUND -> context.getString(R.string.account_tl_around) TYPE_LOCAL -> context.getString(R.string.local_timeline) TYPE_FEDERATE -> context.getString(R.string.federate_timeline) TYPE_MISSKEY_HYBRID -> context.getString(R.string.misskey_hybrid_timeline) TYPE_PROFILE -> context.getString(R.string.profile) TYPE_FAVOURITES -> context.getString(R.string.favourites) TYPE_REPORTS -> context.getString(R.string.reports) TYPE_NOTIFICATIONS -> context.getString(R.string.notifications) TYPE_CONVERSATION -> context.getString(R.string.conversation) TYPE_BOOSTED_BY -> context.getString(R.string.boosted_by) TYPE_FAVOURITED_BY -> context.getString(R.string.favourited_by) TYPE_HASHTAG -> context.getString(R.string.hashtag) TYPE_MUTES -> context.getString(R.string.muted_users) TYPE_KEYWORD_FILTER -> context.getString(R.string.keyword_filters) TYPE_BLOCKS -> context.getString(R.string.blocked_users) TYPE_DOMAIN_BLOCKS -> context.getString(R.string.blocked_domains) TYPE_SEARCH -> context.getString(R.string.search) TYPE_SEARCH_MSP -> context.getString(R.string.toot_search_msp) TYPE_SEARCH_TS -> context.getString(R.string.toot_search_ts) TYPE_INSTANCE_INFORMATION -> context.getString(R.string.instance_information) TYPE_FOLLOW_REQUESTS -> context.getString(R.string.follow_requests) TYPE_FOLLOW_SUGGESTION -> context.getString(R.string.follow_suggestion) TYPE_ENDORSEMENT -> context.getString(R.string.endorse_set) TYPE_LIST_LIST -> context.getString(R.string.lists) TYPE_LIST_MEMBER -> context.getString(R.string.list_member) TYPE_LIST_TL -> context.getString(R.string.list_timeline) TYPE_DIRECT_MESSAGES -> context.getString(R.string.direct_messages) TYPE_TREND_TAG -> context.getString(R.string.trend_tag) TYPE_SCHEDULED_STATUS -> context.getString(R.string.scheduled_status) else -> "?" } } internal fun getIconId(acct : String, type : Int) : Int { return when(type) { TYPE_REPORTS -> R.drawable.ic_info TYPE_HOME -> R.drawable.ic_home TYPE_LOCAL_AROUND -> R.drawable.ic_run TYPE_FEDERATED_AROUND -> R.drawable.ic_bike TYPE_ACCOUNT_AROUND -> R.drawable.ic_account_box TYPE_LOCAL -> R.drawable.ic_run TYPE_FEDERATE -> R.drawable.ic_bike TYPE_MISSKEY_HYBRID -> R.drawable.ic_share TYPE_PROFILE -> R.drawable.ic_account_box TYPE_FAVOURITES -> if(SavedAccount.isNicoru(acct)) R.drawable.ic_nicoru else R.drawable.ic_star TYPE_NOTIFICATIONS -> R.drawable.ic_announcement TYPE_CONVERSATION -> R.drawable.ic_forum TYPE_BOOSTED_BY -> R.drawable.ic_repeat TYPE_FAVOURITED_BY -> if(SavedAccount.isNicoru(acct)) R.drawable.ic_nicoru else R.drawable.ic_star TYPE_HASHTAG -> R.drawable.ic_hashtag TYPE_MUTES -> R.drawable.ic_volume_off TYPE_KEYWORD_FILTER -> R.drawable.ic_volume_off TYPE_BLOCKS -> R.drawable.ic_block TYPE_DOMAIN_BLOCKS -> R.drawable.ic_cloud_off TYPE_SEARCH, TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> R.drawable.ic_search TYPE_INSTANCE_INFORMATION -> R.drawable.ic_info TYPE_FOLLOW_REQUESTS -> R.drawable.ic_follow_wait TYPE_FOLLOW_SUGGESTION -> R.drawable.ic_follow_plus TYPE_ENDORSEMENT -> R.drawable.ic_follow_plus TYPE_LIST_LIST -> R.drawable.ic_list_list TYPE_LIST_MEMBER -> R.drawable.ic_list_member TYPE_LIST_TL -> R.drawable.ic_list_tl TYPE_DIRECT_MESSAGES -> R.drawable.ic_mail TYPE_TREND_TAG -> R.drawable.ic_hashtag TYPE_SCHEDULED_STATUS -> R.drawable.ic_timer else -> R.drawable.ic_info } } internal val reMaxId = Pattern.compile("[&?]max_id=(\\d+)") // より古いデータの取得に使う private val reMinId = Pattern.compile("[&?]min_id=(\\d+)") // より新しいデータの取得に使う (マストドン2.6.0以降) private val reSinceId = Pattern.compile("[&?]since_id=(\\d+)") // より新しいデータの取得に使う(マストドン2.6.0未満) val COLUMN_REGEX_FILTER_DEFAULT : (CharSequence?) -> Boolean = { false } private val time_format_hhmm = SimpleDateFormat("HH:mm", Locale.getDefault()) private fun getResetTimeString() : String { time_format_hhmm.timeZone = TimeZone.getDefault() return time_format_hhmm.format(Date(0L)) } fun onFiltersChanged(context : Context, access_info : SavedAccount) { TootTaskRunner(context, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info, object : TootTask { var filter_list : ArrayList? = null override fun background(client : TootApiClient) : TootApiResult? { val result = client.request(Column.PATH_FILTERS) val jsonArray = result?.jsonArray if(jsonArray != null) { filter_list = TootFilter.parseList(jsonArray) } return result } override fun handleResult(result : TootApiResult?) { val filter_list = this.filter_list if(filter_list != null) { val stream_acct = access_info.acct log.d("update filters for $stream_acct") for(column in App1.getAppState(context).column_list) { if(column.access_info.acct == stream_acct) { column.onFiltersChanged2(filter_list) } } } } }) } private val misskeyArrayFinderUsers = { it : JSONObject -> it.optJSONArray("users") } private val misskeyCustomParserFollowRequest = { parser : TootParser, jsonArray : JSONArray -> val dst = ArrayList() for(i in 0 until jsonArray.length()) { val src = jsonArray.optJSONObject(i) ?: continue val accountRef = TootAccountRef.mayNull( parser, parser.account(src.optJSONObject("follower")) ) ?: continue val requestId = EntityId.mayNull(src.parseString("id")) ?: continue accountRef.get()._orderId = requestId dst.add(accountRef) } dst } private val misskeyCustomParserBlocks = { parser : TootParser, jsonArray : JSONArray -> val dst = ArrayList() for(i in 0 until jsonArray.length()) { val src = jsonArray.optJSONObject(i) ?: continue val accountRef = TootAccountRef.mayNull( parser, parser.account(src.optJSONObject("blockee")) ) ?: continue val requestId = EntityId.mayNull(src.parseString("id")) ?: continue accountRef.get()._orderId = requestId dst.add(accountRef) } dst } private val misskeyCustomParserFavorites = { parser : TootParser, jsonArray : JSONArray -> val dst = ArrayList() for(i in 0 until jsonArray.length()) { val src = jsonArray.optJSONObject(i) ?: continue val note = parser.status(src.optJSONObject("note")) ?: continue val favId = EntityId.mayNull(src.parseString("id")) ?: continue note.favourited = true note._orderId = favId dst.add(note) } dst } private val columnIdMap = HashMap?>() private fun registerColumnId(id : String, column : Column) { synchronized(columnIdMap) { columnIdMap[id] = WeakReference(column) } } private fun generateColumnId() : String { synchronized(columnIdMap) { val buffer = ByteBuffer.allocate(8) var id = "" while(id.isEmpty() || columnIdMap.containsKey(id)) { if(id.isNotEmpty()) Thread.sleep(1L) buffer.clear() buffer.putLong(System.currentTimeMillis()) id = buffer.array().encodeBase64Url() } columnIdMap[id] = null return id } } private fun decodeColumnId(src : JSONObject) : String { return src.parseString(KEY_COLUMN_ID) ?: generateColumnId() } fun findColumnById(id : String) : Column? { synchronized(columnIdMap) { return columnIdMap[id]?.get() } } fun getBackgroundImageDir(context : Context) : File { val externalDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) if(externalDir == null) { log.e("getExternalFilesDir is null.") } else { val state = Environment.getExternalStorageState() if(state != Environment.MEDIA_MOUNTED) { log.e("getExternalStorageState: ${state}") } else { log.i("externalDir: ${externalDir}") externalDir.mkdir() val backgroundDir = File(externalDir, DIR_BACKGROUND_IMAGE) backgroundDir.mkdir() log.i("backgroundDir: ${backgroundDir} exists=${backgroundDir.exists()}") return backgroundDir } } val backgroundDir = context.getDir(Column.DIR_BACKGROUND_IMAGE, Context.MODE_PRIVATE) log.i("backgroundDir: ${backgroundDir} exists=${backgroundDir.exists()}") return backgroundDir } private var defaultColorHeaderBg = 0 private var defaultColorHeaderName = 0 private var defaultColorHeaderPageNumber = 0 var defaultColorContentBg = 0 private var defaultColorContentAcct = 0 private var defaultColorContentText = 0 fun reloadDefaultColor(activity : AppCompatActivity, pref : SharedPreferences) { var c : Int // c = Pref.ipCcdHeaderBg(pref) if(c == 0) c = getAttributeColor(activity, R.attr.color_column_header) defaultColorHeaderBg = c // c = Pref.ipCcdHeaderFg(pref) if(c == 0) c = getAttributeColor(activity, R.attr.colorColumnHeaderName) defaultColorHeaderName = c // c = Pref.ipCcdHeaderFg(pref) if(c == 0) c = getAttributeColor(activity, R.attr.colorColumnHeaderPageNumber) defaultColorHeaderPageNumber = c // c = Pref.ipCcdContentBg(pref) defaultColorContentBg = c // c = Pref.ipCcdContentAcct(pref) if(c == 0) c = getAttributeColor(activity, R.attr.colorTimeSmall) defaultColorContentAcct = c // c = Pref.ipCcdContentText(pref) if(c == 0) c = getAttributeColor(activity, R.attr.colorContentText) defaultColorContentText = c } } private var callback_ref : WeakReference? = null private val isActivityStart : Boolean get() { return callback_ref?.get()?.isActivityStart ?: false } private val streamPath : String? get() = if(isMisskey) { val misskeyApiToken = access_info.misskeyApiToken if(misskeyApiToken == null) { // Misskey 8.25 からLTLだけ認証なしでも見れるようになった when(column_type) { TYPE_LOCAL -> "/local-timeline" else -> null } } else { when(column_type) { TYPE_HOME, TYPE_NOTIFICATIONS -> "/?i=$misskeyApiToken" TYPE_LOCAL -> "/local-timeline?i=$misskeyApiToken" TYPE_MISSKEY_HYBRID -> "/hybrid-timeline?i=$misskeyApiToken" TYPE_FEDERATE -> "/global-timeline?i=$misskeyApiToken" TYPE_LIST_TL -> "/user-list?i=$misskeyApiToken&listId=$profile_id" else -> null } } } else { when(column_type) { TYPE_HOME, TYPE_NOTIFICATIONS -> "/api/v1/streaming/?stream=user" TYPE_LOCAL -> "/api/v1/streaming/?stream=public:local" TYPE_FEDERATE -> "/api/v1/streaming/?stream=public" TYPE_LIST_TL -> "/api/v1/streaming/?stream=list&list=$profile_id" TYPE_DIRECT_MESSAGES -> "/api/v1/streaming/?stream=direct" TYPE_HASHTAG -> when(instance_local) { true -> "/api/v1/streaming/?stream=" + Uri.encode("hashtag:local") + "&tag=" + hashtag.encodePercent() else -> "/api/v1/streaming/?stream=hashtag&tag=" + hashtag.encodePercent() // タグ先頭の#を含まない } else -> null } } private val isPublicStream : Boolean get() { return when(column_type) { TYPE_LOCAL, TYPE_FEDERATE, TYPE_HASHTAG, TYPE_LOCAL_AROUND, TYPE_FEDERATED_AROUND -> true else -> false } } internal var dont_close : Boolean = false internal var with_attachment : Boolean = false internal var with_highlight : Boolean = false internal var dont_show_boost : Boolean = false internal var dont_show_reply : Boolean = false internal var dont_show_normal_toot : Boolean = false internal var dont_show_favourite : Boolean = false // 通知カラムのみ internal var dont_show_follow : Boolean = false // 通知カラムのみ internal var dont_show_reaction : Boolean = false // 通知カラムのみ internal var dont_show_vote : Boolean = false // 通知カラムのみ internal var quick_filter = QUICK_FILTER_ALL internal var dont_streaming : Boolean = false internal var dont_auto_refresh : Boolean = false internal var hide_media_default : Boolean = false internal var system_notification_not_related : Boolean = false internal var instance_local : Boolean = false internal var enable_speech : Boolean = false internal var use_old_api = false internal var regex_text : String = "" internal var header_bg_color : Int = 0 internal var header_fg_color : Int = 0 internal var column_bg_color : Int = 0 internal var acct_color : Int = 0 internal var content_color : Int = 0 internal var column_bg_image : String = "" internal var column_bg_image_alpha = 1f internal var profile_tab = TAB_STATUS private var status_id : EntityId? = null // プロフカラムではアカウントのID。リストカラムではリストのID internal var profile_id : EntityId? = null internal var search_query : String = "" internal var search_resolve : Boolean = false private var hashtag : String = "" internal var instance_uri : String = "" // プロフカラムでのアカウント情報 @Volatile internal var who_account : TootAccountRef? = null // リストカラムでのリスト情報 @Volatile private var list_info : TootList? = null // 「インスタンス情報」カラムに表示するインスタンス情報 // (SavedAccount中のインスタンス情報とは異なるので注意) internal var instance_information : TootInstance? = null internal var handshake : Handshake? = null internal var scroll_save : ScrollPosition? = null private var last_viewing_item_id : EntityId? = null internal val is_dispose = AtomicBoolean() internal var bFirstInitialized = false var filter_reload_required : Boolean = false ////////////////////////////////////////////////////////////////////////////////////// // カラムを閉じた後のnotifyDataSetChangedのタイミングで、add/removeされる順序が期待通りにならないので // 参照を1つだけ持つのではなく、リストを保持して先頭の要素を使うことにする private val _holder_list = LinkedList() internal // 複数のリスナがある場合、最も新しいものを返す val viewHolder : ColumnViewHolder? get() { if(is_dispose.get()) return null return if(_holder_list.isEmpty()) null else _holder_list.first } ////////////////////////////////////////////////////////////////////////////////////// internal var lastTask : ColumnTask? = null internal var bInitialLoading : Boolean = false internal var bRefreshLoading : Boolean = false internal var mInitialLoadingError : String = "" internal var mRefreshLoadingError : String = "" internal var mRefreshLoadingErrorTime : Long = 0L internal var mRefreshLoadingErrorPopupState : Int = 0 internal var task_progress : String? = null internal val list_data = BucketList() private val duplicate_map = DuplicateMap() private val isFilterEnabled : Boolean get() = (with_attachment || with_highlight || regex_text.isNotEmpty() || dont_show_normal_toot || quick_filter != QUICK_FILTER_ALL || dont_show_boost || dont_show_favourite || dont_show_follow || dont_show_reply || dont_show_reaction || dont_show_vote ) @Volatile private var column_regex_filter = COLUMN_REGEX_FILTER_DEFAULT @Volatile private var muted_word2 : WordTrieTree? = null @Volatile private var favMuteSet : HashSet? = null @Volatile private var highlight_trie : WordTrieTree? = null // タイムライン中のデータの始端と終端 // misskeyは private var idRecent : EntityId? = null private var idOld : EntityId? = null private var offsetNext : Int = 0 private var pagingType : PagingType = PagingType.Default var bRefreshingTop : Boolean = false // ListViewの表示更新が追いつかないとスクロール位置が崩れるので // 一定時間より短期間にはデータ更新しないようにする private val last_show_stream_data = AtomicLong(0L) private val stream_data_queue = ConcurrentLinkedQueue() private var bPutGap : Boolean = false @Suppress("unused") val listTitle : String get() { return when(column_type) { TYPE_LIST_MEMBER, TYPE_LIST_TL -> { val sv = list_info?.title if(sv != null && sv.isNotEmpty()) sv else profile_id.toString() } else -> "?" } } @Suppress("unused") val listId : EntityId? get() { return when(column_type) { TYPE_LIST_MEMBER, TYPE_LIST_TL -> profile_id else -> null } } val isSearchColumn : Boolean get() { return when(column_type) { TYPE_SEARCH, TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> true else -> false } } internal interface Callback { val isActivityStart : Boolean } internal constructor( app_state : AppState, access_info : SavedAccount, callback : Callback, type : Int, vararg params : Any ) : this(app_state, app_state.context, access_info, type, generateColumnId()) { this.callback_ref = WeakReference(callback) when(type) { TYPE_CONVERSATION, TYPE_BOOSTED_BY, TYPE_FAVOURITED_BY, TYPE_LOCAL_AROUND, TYPE_FEDERATED_AROUND, TYPE_ACCOUNT_AROUND -> status_id = getParamAt(params, 0) TYPE_PROFILE, TYPE_LIST_TL, TYPE_LIST_MEMBER -> profile_id = getParamAt(params, 0) TYPE_HASHTAG -> hashtag = getParamAt(params, 0) TYPE_SEARCH -> { search_query = getParamAt(params, 0) search_resolve = getParamAt(params, 1) } TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> search_query = getParamAt(params, 0) TYPE_INSTANCE_INFORMATION -> instance_uri = getParamAt(params, 0) } } internal constructor(app_state : AppState, src : JSONObject) : this( app_state, app_state.context, loadAccount(app_state.context, src), src.optInt(KEY_TYPE), decodeColumnId(src) ) { dont_close = src.optBoolean(KEY_DONT_CLOSE) with_attachment = src.optBoolean(KEY_WITH_ATTACHMENT) with_highlight = src.optBoolean(KEY_WITH_HIGHLIGHT) dont_show_boost = src.optBoolean(KEY_DONT_SHOW_BOOST) dont_show_follow = src.optBoolean(KEY_DONT_SHOW_FOLLOW) dont_show_favourite = src.optBoolean(KEY_DONT_SHOW_FAVOURITE) dont_show_reply = src.optBoolean(KEY_DONT_SHOW_REPLY) dont_show_reaction = src.optBoolean(KEY_DONT_SHOW_REACTION) dont_show_vote = src.optBoolean(KEY_DONT_SHOW_VOTE) dont_show_normal_toot = src.optBoolean(KEY_DONT_SHOW_NORMAL_TOOT) dont_streaming = src.optBoolean(KEY_DONT_STREAMING) dont_auto_refresh = src.optBoolean(KEY_DONT_AUTO_REFRESH) hide_media_default = src.optBoolean(KEY_HIDE_MEDIA_DEFAULT) system_notification_not_related = src.optBoolean(KEY_SYSTEM_NOTIFICATION_NOT_RELATED) instance_local = src.optBoolean(KEY_INSTANCE_LOCAL) quick_filter = src.optInt(KEY_QUICK_FILTER, 0) enable_speech = src.optBoolean(KEY_ENABLE_SPEECH) use_old_api = src.optBoolean(KEY_USE_OLD_API) last_viewing_item_id = EntityId.from(src, KEY_LAST_VIEWING_ITEM) regex_text = src.parseString(KEY_REGEX_TEXT) ?: "" header_bg_color = src.optInt(KEY_HEADER_BACKGROUND_COLOR) header_fg_color = src.optInt(KEY_HEADER_TEXT_COLOR) column_bg_color = src.optInt(KEY_COLUMN_BACKGROUND_COLOR) acct_color = src.optInt(KEY_COLUMN_ACCT_TEXT_COLOR) content_color = src.optInt(KEY_COLUMN_CONTENT_TEXT_COLOR) column_bg_image = src.parseString(KEY_COLUMN_BACKGROUND_IMAGE) ?: "" column_bg_image_alpha = src.optDouble(KEY_COLUMN_BACKGROUND_IMAGE_ALPHA, 1.0).toFloat() when(column_type) { TYPE_CONVERSATION, TYPE_BOOSTED_BY, TYPE_FAVOURITED_BY, TYPE_LOCAL_AROUND, TYPE_FEDERATED_AROUND, TYPE_ACCOUNT_AROUND -> status_id = when(isMisskey) { true -> EntityId.mayNull(src.parseString(KEY_STATUS_ID)) else -> EntityId.mayNull(src.parseLong(KEY_STATUS_ID)) } TYPE_PROFILE -> { profile_id = when(isMisskey) { true -> EntityId.mayNull(src.parseString(KEY_PROFILE_ID)) else -> EntityId.mayNull(src.parseLong(KEY_PROFILE_ID)) } profile_tab = src.optInt(KEY_PROFILE_TAB) } TYPE_LIST_MEMBER, TYPE_LIST_TL -> { profile_id = when(isMisskey) { true -> EntityId.mayNull(src.parseString(KEY_PROFILE_ID)) else -> EntityId.mayNull(src.parseLong(KEY_PROFILE_ID)) } } TYPE_HASHTAG -> hashtag = src.optString(KEY_HASHTAG) TYPE_SEARCH -> { search_query = src.optString(KEY_SEARCH_QUERY) search_resolve = src.optBoolean(KEY_SEARCH_RESOLVE, false) } TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> search_query = src.optString(KEY_SEARCH_QUERY) TYPE_INSTANCE_INFORMATION -> instance_uri = src.optString(KEY_INSTANCE_URI) } } private fun JSONObject.putIfTrue(key : String, value : Boolean) { if(value) put(key, true) } @Throws(JSONException::class) fun encodeJSON(dst : JSONObject, old_index : Int) { dst.put(KEY_ACCOUNT_ROW_ID, access_info.db_id) dst.put(KEY_TYPE, column_type) dst.put(KEY_COLUMN_ID, column_id) dst.putIfTrue(KEY_DONT_CLOSE, dont_close) dst.putIfTrue(KEY_WITH_ATTACHMENT, with_attachment) dst.putIfTrue(KEY_WITH_HIGHLIGHT, with_highlight) dst.putIfTrue(KEY_DONT_SHOW_BOOST, dont_show_boost) dst.putIfTrue(KEY_DONT_SHOW_FOLLOW, dont_show_follow) dst.putIfTrue(KEY_DONT_SHOW_FAVOURITE, dont_show_favourite) dst.putIfTrue(KEY_DONT_SHOW_REPLY, dont_show_reply) dst.putIfTrue(KEY_DONT_SHOW_REACTION, dont_show_reaction) dst.putIfTrue(KEY_DONT_SHOW_VOTE, dont_show_vote) dst.putIfTrue(KEY_DONT_SHOW_NORMAL_TOOT, dont_show_normal_toot) dst.putIfTrue(KEY_DONT_STREAMING, dont_streaming) dst.putIfTrue(KEY_DONT_AUTO_REFRESH, dont_auto_refresh) dst.putIfTrue(KEY_HIDE_MEDIA_DEFAULT, hide_media_default) dst.putIfTrue(KEY_SYSTEM_NOTIFICATION_NOT_RELATED, system_notification_not_related) dst.putIfTrue(KEY_INSTANCE_LOCAL, instance_local) dst.putIfTrue(KEY_ENABLE_SPEECH, enable_speech) dst.putIfTrue(KEY_USE_OLD_API, use_old_api) dst.put(KEY_QUICK_FILTER, quick_filter) last_viewing_item_id?.putTo(dst, KEY_LAST_VIEWING_ITEM) dst.put(KEY_REGEX_TEXT, regex_text) dst.put(KEY_HEADER_BACKGROUND_COLOR, header_bg_color) dst.put(KEY_HEADER_TEXT_COLOR, header_fg_color) dst.put(KEY_COLUMN_BACKGROUND_COLOR, column_bg_color) dst.put(KEY_COLUMN_ACCT_TEXT_COLOR, acct_color) dst.put(KEY_COLUMN_CONTENT_TEXT_COLOR, content_color) dst.put(KEY_COLUMN_BACKGROUND_IMAGE, column_bg_image) dst.put(KEY_COLUMN_BACKGROUND_IMAGE_ALPHA, column_bg_image_alpha.toDouble()) when(column_type) { TYPE_CONVERSATION, TYPE_BOOSTED_BY, TYPE_FAVOURITED_BY, TYPE_LOCAL_AROUND, TYPE_FEDERATED_AROUND, TYPE_ACCOUNT_AROUND -> dst.put(KEY_STATUS_ID, status_id.toString()) TYPE_PROFILE -> dst.put(KEY_PROFILE_ID, profile_id.toString()).put(KEY_PROFILE_TAB, profile_tab) TYPE_LIST_MEMBER, TYPE_LIST_TL -> dst.put(KEY_PROFILE_ID, profile_id.toString()) TYPE_HASHTAG -> dst.put(KEY_HASHTAG, hashtag) TYPE_SEARCH -> dst.put(KEY_SEARCH_QUERY, search_query).put( KEY_SEARCH_RESOLVE, search_resolve ) TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> dst.put(KEY_SEARCH_QUERY, search_query) TYPE_INSTANCE_INFORMATION -> dst.put(KEY_INSTANCE_URI, instance_uri) } // 以下は保存には必要ないが、カラムリスト画面で使う val ac = AcctColor.load(access_info.acct) dst.put(KEY_COLUMN_ACCESS, if(AcctColor.hasNickname(ac)) ac.nickname else access_info.acct) dst.put(KEY_COLUMN_ACCESS_COLOR, if(AcctColor.hasColorForeground(ac)) ac.color_fg else 0) dst.put(KEY_COLUMN_ACCESS_COLOR_BG, if(AcctColor.hasColorBackground(ac)) ac.color_bg else 0) dst.put(KEY_COLUMN_NAME, getColumnName(true)) dst.put(KEY_OLD_INDEX, old_index) } internal fun isSameSpec(ai : SavedAccount, type : Int, params : Array) : Boolean { if(type != column_type || ai.acct != access_info.acct) return false return try { when(type) { TYPE_PROFILE, TYPE_LIST_TL, TYPE_LIST_MEMBER -> profile_id == when(isMisskey) { true -> EntityIdString(getParamAt(params, 0)) else -> EntityIdLong(getParamAt(params, 0)) } TYPE_CONVERSATION, TYPE_BOOSTED_BY, TYPE_FAVOURITED_BY, TYPE_LOCAL_AROUND, TYPE_FEDERATED_AROUND, TYPE_ACCOUNT_AROUND -> status_id == when(isMisskey) { true -> EntityIdString(getParamAt(params, 0)) else -> EntityIdLong(getParamAt(params, 0)) } TYPE_HASHTAG -> getParamAt(params, 0) == hashtag TYPE_SEARCH -> getParamAt(params, 0) == search_query && getParamAt( params, 1 ) == search_resolve TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> getParamAt(params, 0) == search_query TYPE_INSTANCE_INFORMATION -> getParamAt(params, 0) == instance_uri else -> true } } catch(ex : Throwable) { log.trace(ex) false } } internal fun getColumnName(bLong : Boolean) : String { return when(column_type) { TYPE_PROFILE -> { val who = who_account?.get() context.getString( R.string.profile_of, if(who != null) AcctColor.getNickname(access_info.getFullAcct(who)) else profile_id.toString() ) } TYPE_LIST_MEMBER -> context.getString( R.string.list_member_of, list_info?.title ?: profile_id.toString() ) TYPE_LIST_TL -> context.getString( R.string.list_tl_of, list_info?.title ?: profile_id.toString() ) TYPE_CONVERSATION -> context.getString( R.string.conversation_around, (status_id?.toString() ?: "null") ) TYPE_LOCAL_AROUND -> context.getString( R.string.ltl_around_of, (status_id?.toString() ?: "null") ) TYPE_FEDERATED_AROUND -> context.getString( R.string.ftl_around_of, (status_id?.toString() ?: "null") ) TYPE_ACCOUNT_AROUND -> context.getString( R.string.account_tl_around_of, (status_id?.toString() ?: "null") ) TYPE_HASHTAG -> context.getString(R.string.hashtag_of, hashtag) TYPE_SEARCH -> if(bLong) context.getString(R.string.search_of, search_query) else getColumnTypeName(context, column_type) TYPE_SEARCH_MSP -> if(bLong) context.getString(R.string.toot_search_msp_of, search_query) else getColumnTypeName(context, column_type) TYPE_SEARCH_TS -> if(bLong) context.getString(R.string.toot_search_ts_of, search_query) else getColumnTypeName(context, column_type) TYPE_INSTANCE_INFORMATION -> if(bLong) context.getString(R.string.instance_information_of, instance_uri) else getColumnTypeName(context, column_type) TYPE_NOTIFICATIONS -> context.getString(R.string.notifications) + getNotificationTypeString() else -> getColumnTypeName(context, column_type) } } private fun getNotificationTypeString() : String { val sb = StringBuilder() sb.append("(") when(quick_filter) { QUICK_FILTER_ALL -> { var n = 0 if(! dont_show_reply) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_mention)) } if(! dont_show_follow) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_follow)) } if(! dont_show_boost) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_boost)) } if(! dont_show_favourite) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_favourite)) } if(isMisskey && ! dont_show_reaction) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_reaction)) } if(isMisskey && ! dont_show_vote) { if(n ++ > 0) sb.append(", ") sb.append(context.getString(R.string.notification_type_vote)) } val n_max = if(isMisskey) { 6 } else { 4 } if(n == 0 || n == n_max) return "" // 全部か皆無なら部分表記は要らない } QUICK_FILTER_MENTION -> sb.append(context.getString(R.string.notification_type_mention)) QUICK_FILTER_FAVOURITE -> sb.append(context.getString(R.string.notification_type_favourite)) QUICK_FILTER_BOOST -> sb.append(context.getString(R.string.notification_type_boost)) QUICK_FILTER_FOLLOW -> sb.append(context.getString(R.string.notification_type_follow)) QUICK_FILTER_REACTION -> sb.append(context.getString(R.string.notification_type_reaction)) QUICK_FILTER_VOTE -> sb.append(context.getString(R.string.notification_type_vote)) } sb.append(")") return sb.toString() } internal fun dispose() { is_dispose.set(true) stopStreaming() for(vh in _holder_list) { try { vh.listView.adapter = null } catch(ignored : Throwable) { } } } internal fun getIconId(type : Int) : Int { return getIconId(access_info.acct, type) } // ブーストやお気に入りの更新に使う。ステータスを列挙する。 fun findStatus( target_instance : String, target_status_id : EntityId, callback : (account : SavedAccount, status : TootStatus) -> Boolean // callback return true if rebind view required ) { if(! access_info.host.equals(target_instance, ignoreCase = true)) return var bChanged = false fun procStatus(status : TootStatus?) { if(status != null) { if(target_status_id == status.id) { if(callback(access_info, status)) bChanged = true } procStatus(status.reblog) } } for(data in list_data) { when(data) { is TootNotification -> procStatus(data.status) is TootStatus -> procStatus(data) } } if(bChanged) fireRebindAdapterItems() } // ミュート、ブロックが成功した時に呼ばれる // リストメンバーカラムでメンバーをリストから除去した時に呼ばれる fun removeAccountInTimeline( target_account : SavedAccount, who_id : EntityId, removeFromUserList : Boolean = false ) { if(target_account.acct != access_info.acct) return val INVALID_ACCOUNT = - 1L val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootStatus) { if(who_id == (o.account.id)) continue if(who_id == (o.reblog?.account?.id ?: INVALID_ACCOUNT)) continue } else if(o is TootNotification) { if(who_id == (o.account?.id ?: INVALID_ACCOUNT)) continue if(who_id == (o.status?.account?.id ?: INVALID_ACCOUNT)) continue if(who_id == (o.status?.reblog?.account?.id ?: INVALID_ACCOUNT)) continue } else if(o is TootAccountRef && removeFromUserList) { if(who_id == o.get().id) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "removeAccountInTimeline") } } // misskeyカラムやプロフカラムでブロック成功した時に呼ばれる fun updateFollowIcons(target_account : SavedAccount) { if(target_account.acct != access_info.acct) return fireShowContent(reason = "updateFollowIcons", reset = true) } fun removeUser(targetAccount : SavedAccount, columnType : Int, who_id : EntityId) { if(column_type == columnType && targetAccount.acct == access_info.acct) { val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootAccountRef) { if(o.get().id == who_id) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "removeUser") } } } // ステータスが削除された時に呼ばれる fun onStatusRemoved(tl_host : String, status_id : EntityId) { if(is_dispose.get() || bInitialLoading || bRefreshLoading) return if(tl_host.equals(access_info.host, ignoreCase = true)) { val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootStatus) { if(status_id == o.id) continue if(status_id == (o.reblog?.id ?: - 1L)) continue } else if(o is TootNotification) { val s = o.status if(s != null) { if(status_id == s.id) continue if(status_id == (s.reblog?.id ?: - 1L)) continue } } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "removeStatus") } } } fun removeNotifications() { cancelLastTask() mRefreshLoadingErrorPopupState = 0 mRefreshLoadingError = "" bRefreshLoading = false mInitialLoadingError = "" bInitialLoading = false idOld = null idRecent = null offsetNext = 0 pagingType = PagingType.Default list_data.clear() duplicate_map.clear() fireShowContent(reason = "removeNotifications", reset = true) PollingWorker.queueNotificationCleared(context, access_info.db_id) } fun removeNotificationOne(target_account : SavedAccount, notification : TootNotification) { if(column_type != TYPE_NOTIFICATIONS) return if(access_info.acct != target_account.acct) return val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootNotification) { if(o.id == notification.id) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "removeNotificationOne") } } fun onMuteUpdated() { val checker = { status : TootStatus? -> status?.checkMuted() ?: false } val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootStatus) { if(checker(o)) continue } if(o is TootNotification) { if(checker(o.status)) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "onMuteUpdated") } } fun onHideFavouriteNotification(acct : String) { if(column_type != TYPE_NOTIFICATIONS) return val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootNotification && o.type != TootNotification.TYPE_MENTION) { val a = o.account if(a != null) { val a_acct = access_info.getFullAcct(a) if(a_acct == acct) continue } } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "onHideFavouriteNotification") } } fun onDomainBlockChanged(target_account : SavedAccount, domain : String, bBlocked : Boolean) { if(target_account.host != access_info.host) return if(access_info.isPseudo) return if(column_type == TYPE_DOMAIN_BLOCKS) { // ドメインブロック一覧を読み直す startLoading() return } if(bBlocked) { // ブロックしたのとドメイン部分が一致するアカウントからのステータスと通知をすべて除去する val reDomain = Pattern.compile("[^@]+@\\Q$domain\\E\\z", Pattern.CASE_INSENSITIVE) val checker = { acct : String? -> if(acct == null) false else reDomain.matcher(acct).find() } val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootStatus) { if(checker(o.account.acct)) continue if(checker(o.reblog?.account?.acct)) continue } else if(o is TootNotification) { if(checker(o.account?.acct)) continue if(checker(o.status?.account?.acct)) continue if(checker(o.status?.reblog?.account?.acct)) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "onDomainBlockChanged") } } } fun onListListUpdated(account : SavedAccount) { if(column_type == TYPE_LIST_LIST && access_info.acct == account.acct) { startLoading() val vh = viewHolder vh?.onListListUpdated() } } fun onListNameUpdated(account : SavedAccount, item : TootList) { if(access_info.acct != account.acct) return when(column_type) { TYPE_LIST_LIST -> { startLoading() } TYPE_LIST_TL, TYPE_LIST_MEMBER -> { if(item.id == profile_id) { this.list_info = item fireShowColumnHeader() } } } } fun onListMemberUpdated( account : SavedAccount, list_id : EntityId, who : TootAccount, bAdd : Boolean ) { if(column_type == TYPE_LIST_TL && access_info.acct == account.acct && list_id == profile_id) { if(! bAdd) { removeAccountInTimeline(account, who.id) } } else if(column_type == TYPE_LIST_MEMBER && access_info.acct == account.acct && list_id == profile_id) { if(! bAdd) { removeAccountInTimeline(account, who.id) } } } internal fun addColumnViewHolder(cvh : ColumnViewHolder) { // 現在のリストにあるなら削除する removeColumnViewHolder(cvh) // 最後に追加されたものが先頭にくるようにする // 呼び出しの後に必ず追加されているようにする _holder_list.addFirst(cvh) } internal fun removeColumnViewHolder(cvh : ColumnViewHolder) { val it = _holder_list.iterator() while(it.hasNext()) { if(cvh == it.next()) it.remove() } } internal fun removeColumnViewHolderByActivity(activity : ActMain) { val it = _holder_list.iterator() while(it.hasNext()) { val cvh = it.next() if(cvh.activity == activity) { it.remove() } } } internal fun hasMultipleViewHolder() : Boolean { return _holder_list.size > 1 } internal fun fireShowContent( reason : String, changeList : List? = null, reset : Boolean = false ) { if(! isMainThread) { throw RuntimeException("fireShowContent: not on main thread.") } viewHolder?.showContent(reason, changeList, reset) } internal fun fireShowColumnHeader() { if(! isMainThread) { throw RuntimeException("fireShowColumnHeader: not on main thread.") } viewHolder?.showColumnHeader() } internal fun fireShowColumnStatus() { if(! isMainThread) { throw RuntimeException("fireShowColumnStatus: not on main thread.") } viewHolder?.showColumnStatus() } internal fun fireColumnColor() { if(! isMainThread) { throw RuntimeException("fireColumnColor: not on main thread.") } viewHolder?.showColumnColor() } fun fireRelativeTime() { if(! isMainThread) { throw RuntimeException("fireRelativeTime: not on main thread.") } viewHolder?.updateRelativeTime() } fun fireRebindAdapterItems() { if(! isMainThread) { throw RuntimeException("fireRelativeTime: not on main thread.") } viewHolder?.rebindAdapterItems() } private fun cancelLastTask() { if(lastTask != null) { lastTask?.cancel(true) lastTask = null // bInitialLoading = false bRefreshLoading = false mInitialLoadingError = context.getString(R.string.cancelled) } } private fun initFilter() { column_regex_filter = COLUMN_REGEX_FILTER_DEFAULT val regex_text = this.regex_text if(regex_text.isNotEmpty()) { try { val re = Pattern.compile(regex_text) column_regex_filter = { text : CharSequence? -> if(text?.isEmpty() != false) false else re.matcher( text ).find() } } catch(ex : Throwable) { log.trace(ex) } } favMuteSet = FavMute.acctSet highlight_trie = HighlightWord.nameSet } private fun isFilteredByAttachment(status : TootStatus) : Boolean { // オプションがどれも設定されていないならフィルタしない(false) if(! (with_attachment || with_highlight)) return false val matchMedia = with_attachment && status.reblog?.hasMedia() ?: status.hasMedia() val matchHighlight = with_highlight && status.reblog?.hasHighlight ?: status.hasHighlight // どれかの条件を満たすならフィルタしない(false)、どれも満たさないならフィルタする(true) return ! (matchMedia || matchHighlight) } private fun isFiltered(status : TootStatus) : Boolean { // word mute2 status.updateFiltered(muted_word2) if(isFilteredByAttachment(status)) return true if(dont_show_boost) { if(status.reblog != null) return true } if(dont_show_reply) { if(status.in_reply_to_id != null) return true if(status.reblog?.in_reply_to_id != null) return true } if(dont_show_normal_toot) { if(status.in_reply_to_id == null && status.reblog == null) return true } if(column_regex_filter(status.decoded_content)) return true if(column_regex_filter(status.reblog?.decoded_content)) return true if(column_regex_filter(status.decoded_spoiler_text)) return true if(column_regex_filter(status.reblog?.decoded_spoiler_text)) return true return status.checkMuted() } private inline fun addAll( dstArg : ArrayList?, src : List ) : ArrayList { val dst = dstArg ?: ArrayList(src.size) dst.addAll(src) return dst } private fun addOne( dstArg : ArrayList?, item : TimelineItem? ) : ArrayList { val dst = dstArg ?: ArrayList() if(item != null) dst.add(item) return dst } private fun addOneFirst( dstArg : ArrayList?, item : TimelineItem? ) : ArrayList { val dst = dstArg ?: ArrayList() if(item != null) dst.add(0, item) return dst } private fun addWithFilterStatus( dstArg : ArrayList?, src : List ) : ArrayList { val dst = dstArg ?: ArrayList(src.size) for(status in src) { if(! isFiltered(status)) { dst.add(status) } } return dst } private fun addWithFilterConversationSummary( dstArg : ArrayList?, src : List ) : ArrayList { val dst = dstArg ?: ArrayList(src.size) for(cs in src) { if(! isFiltered(cs.last_status)) { dst.add(cs) } } return dst } private fun addWithFilterNotification( dstArg : ArrayList?, src : List ) : ArrayList { val dst = dstArg ?: ArrayList(src.size) for(item in src) { if(! isFiltered(item)) dst.add(item) } return dst } private fun isFiltered(item : TootNotification) : Boolean { when(quick_filter) { QUICK_FILTER_ALL -> { when(item.type) { TootNotification.TYPE_FAVOURITE -> if(dont_show_favourite) { log.d("isFiltered: favourite notification filtered.") return true } TootNotification.TYPE_REBLOG, TootNotification.TYPE_RENOTE, TootNotification.TYPE_QUOTE -> if(dont_show_boost) { log.d("isFiltered: reblog notification filtered.") return true } TootNotification.TYPE_FOLLOW_REQUEST, TootNotification.TYPE_FOLLOW -> if(dont_show_follow) { log.d("isFiltered: follow notification filtered.") return true } TootNotification.TYPE_MENTION, TootNotification.TYPE_REPLY -> if(dont_show_reply) { log.d("isFiltered: mention notification filtered.") return true } TootNotification.TYPE_REACTION -> if(dont_show_reaction) { log.d("isFiltered: reaction notification filtered.") return true } TootNotification.TYPE_VOTE -> if(dont_show_vote) { log.d("isFiltered: vote notification filtered.") return true } } } else -> { when(item.type) { TootNotification.TYPE_FAVOURITE -> if(quick_filter != QUICK_FILTER_FAVOURITE) { log.d("isFiltered: ${item.type} notification filtered.") return true } TootNotification.TYPE_REBLOG, TootNotification.TYPE_RENOTE, TootNotification.TYPE_QUOTE -> if(quick_filter != QUICK_FILTER_BOOST) { log.d("isFiltered: ${item.type} notification filtered.") return true } TootNotification.TYPE_FOLLOW_REQUEST, TootNotification.TYPE_FOLLOW -> if(quick_filter != QUICK_FILTER_FOLLOW) { log.d("isFiltered: ${item.type} notification filtered.") return true } TootNotification.TYPE_MENTION, TootNotification.TYPE_REPLY -> if(quick_filter != QUICK_FILTER_MENTION) { log.d("isFiltered: ${item.type} notification filtered.") return true } TootNotification.TYPE_REACTION -> if(quick_filter != QUICK_FILTER_REACTION) { log.d("isFiltered: ${item.type} notification filtered.") return true } TootNotification.TYPE_VOTE -> if(quick_filter != QUICK_FILTER_VOTE) { log.d("isFiltered: ${item.type} notification filtered.") return true } else -> { log.d("isFiltered: ${item.type} notification filtered.") return true } } } } val status = item.status status?.updateFiltered(muted_word2) if(status?.checkMuted() == true) { log.d("isFiltered: status muted.") return true } // ふぁぼ魔ミュート when(item.type) { TootNotification.TYPE_REBLOG, TootNotification.TYPE_RENOTE, TootNotification.TYPE_QUOTE, TootNotification.TYPE_FAVOURITE, TootNotification.TYPE_REACTION, TootNotification.TYPE_VOTE, TootNotification.TYPE_FOLLOW -> { val who = item.account if(who != null && favMuteSet?.contains(access_info.getFullAcct(who)) == true) { PollingWorker.log.d("%s is in favMuteSet.", access_info.getFullAcct(who)) return true } } } return false } // @Nullable String parseMaxId( TootApiResult result ){ // if( result != null && result.link_older != null ){ // Matcher m = reMaxId.matcher( result.link_older ); // if( m.get() ) return m.group( 1 ); // } // return null; // } internal fun loadProfileAccount( client : TootApiClient, parser : TootParser, bForceReload : Boolean ) : TootApiResult? { return if(this.who_account != null && ! bForceReload) { // リロード不要なら何もしない null } else if(isMisskey) { val params = access_info.putMisskeyApiToken(JSONObject()) .put("userId", profile_id) val result = client.request(PATH_MISSKEY_PROFILE, params.toPostRequestBuilder()) // ユーザリレーションの取り扱いのため、別のparserを作ってはいけない parser.misskeyDecodeProfilePin = true try { val a = TootAccountRef.mayNull(parser, parser.account(result?.jsonObject)) if(a != null) { this.who_account = a client.publishApiProgress("") // カラムヘッダの再表示 } } finally { parser.misskeyDecodeProfilePin = false } result } else { val result = client.request(String.format(Locale.JAPAN, PATH_ACCOUNT, profile_id)) val a = TootAccountRef.mayNull(parser, parser.account(result?.jsonObject)) if(a != null) { this.who_account = a client.publishApiProgress("") // カラムヘッダの再表示 } result } } internal fun loadListInfo(client : TootApiClient, bForceReload : Boolean) { val parser = TootParser(context, access_info) if(bForceReload || this.list_info == null) { val result = if(isMisskey) { val params = makeMisskeyBaseParameter(parser) .put("listId", profile_id) client.request("/api/users/lists/show", params.toPostRequestBuilder()) } else { client.request(String.format(Locale.JAPAN, PATH_LIST_INFO, profile_id)) } val jsonObject = result?.jsonObject if(jsonObject != null) { val data = parseItem(::TootList, parser, jsonObject) if(data != null) { this.list_info = data client.publishApiProgress("") // カラムヘッダの再表示 } } } } private inner class UpdateRelationEnv { internal val who_set = HashSet() internal val acct_set = HashSet() internal val tag_set = HashSet() internal fun add(whoRef : TootAccountRef?) { add(whoRef?.get()) } internal fun add(who : TootAccount?) { who ?: return who_set.add(who.id) acct_set.add("@" + access_info.getFullAcct(who)) // add(who.movedRef) } internal fun add(s : TootStatus?) { if(s == null) return add(s.accountRef) add(s.reblog) s.tags?.forEach { tag_set.add(it.name) } } internal fun add(n : TootNotification?) { if(n == null) return add(n.accountRef) add(n.status) } internal fun update(client : TootApiClient, parser : TootParser) { var n : Int var size : Int if(isMisskey) { // parser内部にアカウントIDとRelationのマップが生成されるので、それをデータベースに記録する run { val now = System.currentTimeMillis() val who_list = parser.misskeyUserRelationMap.entries.toMutableList() var start = 0 val end = who_list.size while(start < end) { var step = end - start if(step > RELATIONSHIP_LOAD_STEP) step = RELATIONSHIP_LOAD_STEP UserRelationMisskey.saveList(now, access_info.db_id, who_list, start, step) start += step } log.d("updateRelation: update %d relations.", end) } // 2018/11/1 Misskeyにもリレーション取得APIができた // アカウントIDの集合からRelationshipを取得してデータベースに記録する size = who_set.size if(size > 0) { val who_list = ArrayList(size) who_list.addAll(who_set) val now = System.currentTimeMillis() n = 0 while(n < who_list.size) { val userIdList = ArrayList(RELATIONSHIP_LOAD_STEP) for(i in 0 until RELATIONSHIP_LOAD_STEP) { if(n >= size) break if(! parser.misskeyUserRelationMap.containsKey(who_list[n])) { userIdList.add(who_list[n]) } ++ n } if(userIdList.isEmpty()) continue val params = access_info.putMisskeyApiToken() .put("userId", JSONArray().apply { for(id in userIdList) put(id.toString()) }) val result = client.request("/api/users/relation", params.toPostRequestBuilder()) if(result == null || result.response?.code() in 400 until 500) break val list = parseList(::TootRelationShip, parser, result.jsonArray) if(list.size == userIdList.size) { for(i in 0 until list.size) { list[i].id = userIdList[i] } UserRelationMisskey.saveList2(now, access_info.db_id, list) } } log.d("updateRelation: update %d relations.", n) } } else { // アカウントIDの集合からRelationshipを取得してデータベースに記録する size = who_set.size if(size > 0) { val who_list = ArrayList(size) who_list.addAll(who_set) val now = System.currentTimeMillis() n = 0 while(n < who_list.size) { val sb = StringBuilder() sb.append("/api/v1/accounts/relationships") for(i in 0 until RELATIONSHIP_LOAD_STEP) { if(n >= size) break sb.append(if(i == 0) '?' else '&') sb.append("id[]=") sb.append(who_list[n ++].toString()) } val result = client.request(sb.toString()) ?: break // cancelled. val list = parseList(::TootRelationShip, parser, result.jsonArray) if(list.size > 0) UserRelation.saveList(now, access_info.db_id, list) } log.d("updateRelation: update %d relations.", n) } } // 出現したacctをデータベースに記録する size = acct_set.size if(size > 0) { val acct_list = ArrayList(size) acct_list.addAll(acct_set) val now = System.currentTimeMillis() n = 0 while(n < acct_list.size) { var length = size - n if(length > ACCT_DB_STEP) length = ACCT_DB_STEP AcctSet.saveList(now, acct_list, n, length) n += length } log.d("updateRelation: update %d acct.", n) } // 出現したタグをデータベースに記録する size = tag_set.size if(size > 0) { val tag_list = ArrayList(size) tag_list.addAll(tag_set) val now = System.currentTimeMillis() n = 0 while(n < tag_list.size) { var length = size - n if(length > ACCT_DB_STEP) length = ACCT_DB_STEP TagSet.saveList(now, tag_list, n, length) n += length } log.d("updateRelation: update %d tag.", n) } } } // private fun updateRelation( client : TootApiClient, list : ArrayList?, whoRef : TootAccountRef?, parser : TootParser ) { if(access_info.isPseudo) return val env = UpdateRelationEnv() env.add(whoRef) list?.forEach { when(it) { is TootAccountRef -> env.add(it) is TootStatus -> env.add(it) is TootNotification -> env.add(it) is TootConversationSummary -> env.add(it.last_status) } } env.update(client, parser) } // DMカラム更新時に新APIの利用に成功したなら真 internal var useConversationSummarys = false // DMカラムのストリーミングイベントで新形式のイベントを利用できたなら真 internal var useConversationSummaryStreaming = false internal fun startLoading() { cancelLastTask() stopStreaming() initFilter() useInstanceTicker = Pref.bpInstanceTicker(app_state.pref) mRefreshLoadingErrorPopupState = 0 mRefreshLoadingError = "" mInitialLoadingError = "" bFirstInitialized = true bInitialLoading = true bRefreshLoading = false idOld = null idRecent = null offsetNext = 0 pagingType = PagingType.Default duplicate_map.clear() list_data.clear() fireShowContent(reason = "loading start", reset = true) val task = @SuppressLint("StaticFieldLeak") object : ColumnTask(ColumnTaskType.LOADING) { var parser = TootParser(context, access_info, highlightTrie = highlight_trie) var instance_tmp : TootInstance? = null var list_pinned : ArrayList? = null var list_tmp : ArrayList? = null fun getInstanceInformation( client : TootApiClient, instance_name : String? ) : TootApiResult? { if(instance_name != null) { // 「インスタンス情報」カラムをNAアカウントで開く場合 client.instance = instance_name } else { // カラムに紐付けられたアカウントのタンスのインスタンス情報 } val result = client.parseInstanceInformation(client.getInstanceInformation()) instance_tmp = result?.data as? TootInstance return result } fun getStatusesPinned(client : TootApiClient, path_base : String) { val result = client.request(path_base) val jsonArray = result?.jsonArray if(jsonArray != null) { // val src = TootParser( context, access_info, pinned = true, highlightTrie = highlight_trie ).statusList(jsonArray) this.list_pinned = addWithFilterStatus(null, src) // pinned tootにはページングの概念はない } log.d("getStatusesPinned: list size=%s", list_pinned?.size ?: - 1) } fun getStatuses( client : TootApiClient, path_base : String, aroundMin : Boolean = false, aroundMax : Boolean = false, misskeyParams : JSONObject? = null, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.statusList(jsonArray) }, initialUntilDate : Boolean = false ) : TootApiResult? { val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val time_start = SystemClock.elapsedRealtime() // 初回の取得 val result = when { isMisskey -> { if(initialUntilDate) { params.put("untilDate", System.currentTimeMillis() + (86400000L * 365)) } client.request(path_base, params.toPostRequestBuilder()) } aroundMin -> client.request("$path_base&min_id=$status_id") aroundMax -> client.request("$path_base&max_id=$status_id") else -> client.request(path_base) } var jsonArray = result?.jsonArray if(jsonArray != null) { var src = misskeyCustomParser(parser, jsonArray) if(list_tmp == null) { list_tmp = ArrayList(src.size) } this.list_tmp = addWithFilterStatus(list_tmp, src) saveRange(true, true, result, src) if(aroundMin) { while(true) { if(client.isApiCancelled) { log.d("loading-statuses: cancelled.") break } if(! isFilterEnabled) { log.d("loading-statuses: isFiltered is false.") break } if(idRecent == null) { log.d("loading-statuses: idRecent is empty.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("loading-statuses: read enough data.") break } if(src.isEmpty()) { log.d("loading-statuses: previous response is empty.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("loading-statuses: timeout.") break } // フィルタなどが有効な場合は2回目以降の取得 val result2 = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}min_id=${idRecent}" client.request(path) } jsonArray = result2?.jsonArray if(jsonArray == null) { log.d("loading-statuses: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray) addWithFilterStatus(list_tmp, src) if(! saveRangeStart(result = result2, list = src)) { log.d("loading-statuses: missing range info.") break } } } else { while(true) { if(client.isApiCancelled) { log.d("loading-statuses: cancelled.") break } if(! isFilterEnabled) { log.d("loading-statuses: isFiltered is false.") break } if(idOld == null) { log.d("loading-statuses: idOld is empty.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("loading-statuses: read enough data.") break } if(src.isEmpty()) { log.d("loading-statuses: previous response is empty.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("loading-statuses: timeout.") break } // フィルタなどが有効な場合は2回目以降の取得 val result2 = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}max_id=${idOld}" client.request(path) } jsonArray = result2?.jsonArray if(jsonArray == null) { log.d("loading-statuses: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray) addWithFilterStatus(list_tmp, src) if(! saveRangeEnd(result = result2, list = src)) { log.d("loading-statuses: missing range info.") break } } } } return result } fun getConversationSummary( client : TootApiClient, path_base : String, aroundMin : Boolean = false, aroundMax : Boolean = false, misskeyParams : JSONObject? = null, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parseList(::TootConversationSummary, parser, jsonArray) } ) : TootApiResult? { val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val time_start = SystemClock.elapsedRealtime() // 初回の取得 val result = when { isMisskey -> client.request(path_base, params.toPostRequestBuilder()) aroundMin -> client.request("$path_base&min_id=$status_id") aroundMax -> client.request("$path_base&max_id=$status_id") else -> client.request(path_base) } var jsonArray = result?.jsonArray if(jsonArray != null) { var src = misskeyCustomParser(parser, jsonArray !!) if(list_tmp == null) { list_tmp = ArrayList(src.size) } this.list_tmp = addWithFilterConversationSummary(list_tmp, src) saveRange(true, true, result, src) if(aroundMin) { while(true) { if(client.isApiCancelled) { log.d("loading-ConversationSummary: cancelled.") break } if(! isFilterEnabled) { log.d("loading-ConversationSummary: isFiltered is false.") break } if(idRecent == null) { log.d("loading-ConversationSummary: idRecent is empty.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("loading-ConversationSummary: read enough data.") break } if(src.isEmpty()) { log.d("loading-ConversationSummary: previous response is empty.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("loading-ConversationSummary: timeout.") break } // フィルタなどが有効な場合は2回目以降の取得 val result2 = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}min_id=${idRecent}" client.request(path) } jsonArray = result2?.jsonArray if(jsonArray == null) { log.d("loading-ConversationSummary: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray !!) addWithFilterConversationSummary(list_tmp, src) if(! saveRangeStart(result = result2, list = src)) { log.d("loading-ConversationSummary: missing range info.") break } } } else { while(true) { if(client.isApiCancelled) { log.d("loading-ConversationSummary: cancelled.") break } if(! isFilterEnabled) { log.d("loading-ConversationSummary: isFiltered is false.") break } if(idOld == null) { log.d("loading-ConversationSummary: idOld is empty.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("loading-ConversationSummary: read enough data.") break } if(src.isEmpty()) { log.d("loading-ConversationSummary: previous response is empty.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("loading-ConversationSummary: timeout.") break } // フィルタなどが有効な場合は2回目以降の取得 val result2 = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}max_id=${idOld}" client.request(path) } jsonArray = result2?.jsonArray if(jsonArray == null) { log.d("loading-ConversationSummary: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray !!) addWithFilterConversationSummary(list_tmp, src) if(! saveRangeEnd(result = result2, list = src)) { log.d("loading-ConversationSummary: missing range info.") break } } } } return result } fun parseAccountList( client : TootApiClient, path_base : String, emptyMessage : String? = null, misskeyParams : JSONObject? = null, misskeyArrayFinder : (JSONObject) -> JSONArray? = { null }, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.accountList(jsonArray) } ) : TootApiResult? { val result = if(misskeyParams != null) { client.request(path_base, misskeyParams.toPostRequestBuilder()) } else { client.request(path_base) } if(result != null && result.error == null) { val jsonObject = result.jsonObject if(jsonObject != null) { if(pagingType == PagingType.Cursor) { idOld = EntityId.mayNull(jsonObject.parseString("next")) } result.data = misskeyArrayFinder(jsonObject) } val jsonArray = result.jsonArray ?: return result.setError("missing JSON data.") val src = misskeyCustomParser(parser, jsonArray) when(pagingType) { PagingType.Default -> { saveRange(true, true, result, src) } PagingType.Offset -> { offsetNext += src.size } else -> { } } val tmp = ArrayList() if(emptyMessage != null) { // フォロー/フォロワー一覧には警告の表示が必要だった val who = who_account?.get() if(! access_info.isMe(who)) { if(who != null && access_info.isRemoteUser(who)) tmp.add( TootMessageHolder( context.getString(R.string.follow_follower_list_may_restrict) ) ) if(src.isEmpty()) { tmp.add(TootMessageHolder(emptyMessage)) } } } tmp.addAll(src) list_tmp = addAll(null, tmp) } return result } fun parseFilterList( client : TootApiClient, path_base : String ) : TootApiResult? { val result = client.request(path_base) if(result != null) { val src = TootFilter.parseList(result.jsonArray) this.list_tmp = addAll(null, src) } return result } fun parseDomainList( client : TootApiClient, path_base : String ) : TootApiResult? { val result = client.request(path_base) if(result != null) { val src = TootDomainBlock.parseList(result.jsonArray) saveRange(true, true, result, src) this.list_tmp = addAll(null, src) } return result } fun parseReports(client : TootApiClient, path_base : String) : TootApiResult? { val result = client.request(path_base) if(result != null) { val src = parseList(::TootReport, result.jsonArray) saveRange(true, true, result, src) list_tmp = addAll(null, src) } return result } fun parseListList( client : TootApiClient, path_base : String, misskeyParams : JSONObject? = null ) : TootApiResult? { val result = if(misskeyParams != null) { client.request(path_base, misskeyParams.toPostRequestBuilder()) } else { client.request(path_base) } if(result != null) { val src = parseList(::TootList, parser, result.jsonArray) src.sort() saveRange(true, true, result, src) this.list_tmp = addAll(null, src) } return result } fun parseNotifications(client : TootApiClient) : TootApiResult? { val params = makeMisskeyBaseParameter(parser).addMisskeyNotificationFilter() val path_base = makeNotificationUrl() val time_start = SystemClock.elapsedRealtime() val result = if(isMisskey) { client.request(path_base, params.toPostRequestBuilder()) } else { client.request(path_base) } var jsonArray = result?.jsonArray if(jsonArray != null) { var src = parser.notificationList(jsonArray) saveRange(true, true, result, src) this.list_tmp = addWithFilterNotification(null, src) // if(! src.isEmpty()) { PollingWorker.injectData(context, access_info, src) } // val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' while(true) { if(client.isApiCancelled) { log.d("loading-notifications: cancelled.") break } if(! isFilterEnabled) { log.d("loading-notifications: isFiltered is false.") break } if(idOld == null) { log.d("loading-notifications: max_id is empty.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("loading-notifications: read enough data.") break } if(src.isEmpty()) { log.d("loading-notifications: previous response is empty.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("loading-notifications: timeout.") break } val result2 = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}max_id=$idOld" client.request(path) } jsonArray = result2?.jsonArray if(jsonArray == null) { log.d("loading-notifications: error or cancelled.") break } src = parser.notificationList(jsonArray) addWithFilterNotification(list_tmp, src) if(! saveRangeEnd(result2, src)) { log.d("loading-notifications: missing range info.") break } } } return result } fun getPublicAroundStatuses(client : TootApiClient, url : String) : TootApiResult? { // (Mastodonのみ対応) var instance = access_info.instance if(instance == null) { getInstanceInformation(client, null) if(instance_tmp != null) { instance = instance_tmp access_info.instance = instance } } // ステータスIDに該当するトゥート // タンスをまたいだりすると存在しないかもしれないが、エラーは出さない var result : TootApiResult? = client.request(String.format(Locale.JAPAN, PATH_STATUSES, status_id)) val target_status = parser.status(result?.jsonObject) if(target_status != null) { list_tmp = addOne(list_tmp, target_status) } idOld = null idRecent = null var bInstanceTooOld = false if(instance?.versionGE(TootInstance.VERSION_2_6_0) == true) { // 指定より新しいトゥート result = getStatuses(client, url, aroundMin = true) if(result == null || result.error != null) return result } else { bInstanceTooOld = true } // 指定位置より古いトゥート result = getStatuses(client, url, aroundMax = true) if(result == null || result.error != null) return result list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() if(bInstanceTooOld) { list_tmp?.add( 0, TootMessageHolder(context.getString(R.string.around_toot_limitation_warning)) ) } return result } fun getAccountAroundStatuses(client : TootApiClient) : TootApiResult? { // (Mastodonのみ対応) var instance = access_info.instance if(instance == null) { getInstanceInformation(client, null) if(instance_tmp != null) { instance = instance_tmp access_info.instance = instance } } // ステータスIDに該当するトゥート // タンスをまたいだりすると存在しないかもしれない var result : TootApiResult? = client.request(String.format(Locale.JAPAN, PATH_STATUSES, status_id)) val target_status = parser.status(result?.jsonObject) ?: return result list_tmp = addOne(list_tmp, target_status) // ↑のトゥートのアカウントのID profile_id = target_status.account.id val path = makeProfileStatusesUrl(profile_id) idOld = null idRecent = null var bInstanceTooOld = false if(instance?.versionGE(TootInstance.VERSION_2_6_0) == true) { // 指定より新しいトゥート result = getStatuses(client, path, aroundMin = true) if(result == null || result?.error != null) return result } else { bInstanceTooOld = true } // 指定位置より古いトゥート result = getStatuses(client, path, aroundMax = true) if(result == null || result?.error != null) return result list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() if(bInstanceTooOld) { list_tmp?.add( 0, TootMessageHolder(context.getString(R.string.around_toot_limitation_warning)) ) } return result } private fun getScheduledStatuses(client : TootApiClient) : TootApiResult? { val result = client.request("/api/v1/scheduled_statuses") val src = parseList(::TootScheduled, parser, result?.jsonArray) list_tmp = addAll(list_tmp, src) // ページングはないっぽい idOld = null idRecent = null return result } override fun doInBackground(vararg unused : Void) : TootApiResult? { ctStarted.set(true) if(Pref.bpInstanceTicker(app_state.pref)) { InstanceTicker.load() } val client = TootApiClient(context, callback = object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() override fun publishApiProgress(s : String) { runOnMainLooper { if(isCancelled) return@runOnMainLooper task_progress = s fireShowContent(reason = "loading progress", changeList = ArrayList()) } } }) client.account = access_info try { var result : TootApiResult? = access_info.checkConfirmed(context, client) if(result == null || result?.error != null) return result val q : String muted_word2 = encodeFilterTree(loadFilter2(client)) when(column_type) { TYPE_DIRECT_MESSAGES -> { useConversationSummarys = false if(! use_old_api) { // try 2.6.0 new API https://github.com/tootsuite/mastodon/pull/8832 val resultCS = getConversationSummary(client, PATH_DIRECT_MESSAGES2) when { // cancelled resultCS == null -> return null // not error resultCS.error.isNullOrBlank() -> { useConversationSummarys = true return resultCS } } } // fallback to old api return getStatuses(client, PATH_DIRECT_MESSAGES) } TYPE_LOCAL -> return getStatuses(client, makePublicLocalUrl()) TYPE_LOCAL_AROUND -> return getPublicAroundStatuses( client, makePublicLocalUrl() ) TYPE_FEDERATED_AROUND -> return getPublicAroundStatuses( client, makePublicFederateUrl() ) TYPE_ACCOUNT_AROUND -> return getAccountAroundStatuses( client ) TYPE_MISSKEY_HYBRID -> return getStatuses(client, makeMisskeyHybridTlUrl()) TYPE_FEDERATE -> return getStatuses(client, makePublicFederateUrl()) TYPE_PROFILE -> { val who_result = loadProfileAccount(client, parser, true) if(client.isApiCancelled || who_account == null) return who_result when(profile_tab) { TAB_FOLLOWING -> return when { isMisskey -> { pagingType = PagingType.Cursor parseAccountList( client, PATH_MISSKEY_PROFILE_FOLLOWING, emptyMessage = context.getString(R.string.none_or_hidden_following), misskeyParams = makeMisskeyParamsUserId(parser), misskeyArrayFinder = misskeyArrayFinderUsers ) } access_info.isPseudo -> { idRecent = null idOld = null list_tmp = addOne( list_tmp, TootMessageHolder(context.getString(R.string.pseudo_account_cant_get_follow_list)) ) TootApiResult() } else -> { parseAccountList( client, String.format( Locale.JAPAN, PATH_ACCOUNT_FOLLOWING, profile_id ), emptyMessage = context.getString(R.string.none_or_hidden_following) ) } } TAB_FOLLOWERS -> return when { isMisskey -> { pagingType = PagingType.Cursor parseAccountList( client, PATH_MISSKEY_PROFILE_FOLLOWERS, emptyMessage = context.getString(R.string.none_or_hidden_followers), misskeyParams = makeMisskeyParamsUserId(parser), misskeyArrayFinder = misskeyArrayFinderUsers ) } access_info.isPseudo -> { idRecent = null idOld = null list_tmp = addOne( list_tmp, TootMessageHolder(context.getString(R.string.pseudo_account_cant_get_follow_list)) ) TootApiResult() } else -> { parseAccountList( client, String.format( Locale.JAPAN, PATH_ACCOUNT_FOLLOWERS, profile_id ), emptyMessage = context.getString(R.string.none_or_hidden_followers) ) } } else -> { var instance = access_info.instance return if(isMisskey) { // 固定トゥートの取得 val pinnedNotes = who_account?.get()?.pinnedNotes if(pinnedNotes != null) { this.list_pinned = addWithFilterStatus(null, pinnedNotes) } // 通常トゥートの取得 getStatuses( client, PATH_MISSKEY_PROFILE_STATUSES, misskeyParams = makeMisskeyParamsProfileStatuses(parser), initialUntilDate = true ) } else { // まだ取得してない // 疑似アカウントの場合は過去のデータが別タンスかもしれない? if(instance == null || access_info.isPseudo) { getInstanceInformation(client, null) if(instance_tmp != null) { instance = instance_tmp access_info.instance = instance } } val path = makeProfileStatusesUrl(profile_id) if(instance?.versionGE(TootInstance.VERSION_1_6) == true // 将来的に正しく判定できる見込みがないので、Pleroma条件でのフィルタは行わない // && instance.instanceType != TootInstance.InstanceType.Pleroma ) { getStatusesPinned(client, "$path&pinned=true") } getStatuses(client, path) } } } } TYPE_MUTES -> return if(isMisskey) { pagingType = PagingType.Cursor parseAccountList( client , PATH_MISSKEY_MUTES , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyArrayFinder = misskeyArrayFinderUsers ) } else { parseAccountList(client, PATH_MUTES) } TYPE_KEYWORD_FILTER -> return parseFilterList(client, PATH_FILTERS) TYPE_BLOCKS -> return if(isMisskey) { pagingType = PagingType.Default val params = access_info.putMisskeyApiToken(JSONObject()) parseAccountList( client, "/api/blocking/list", misskeyParams = params, misskeyCustomParser = misskeyCustomParserBlocks ) } else { parseAccountList(client, PATH_BLOCKS) } TYPE_DOMAIN_BLOCKS -> return parseDomainList(client, PATH_DOMAIN_BLOCK) TYPE_LIST_LIST -> return if(isMisskey) { val params = makeMisskeyBaseParameter(parser) parseListList( client, "/api/users/lists/list", misskeyParams = params ) } else { parseListList(client, PATH_LIST_LIST) } TYPE_LIST_TL -> { loadListInfo(client, true) return if(isMisskey) { val params = makeMisskeyTimelineParameter(parser) .put("listId", profile_id) getStatuses(client, makeListTlUrl(), misskeyParams = params) } else { getStatuses(client, makeListTlUrl()) } } TYPE_LIST_MEMBER -> { loadListInfo(client, true) return if(isMisskey) { pagingType = PagingType.None val params = access_info.putMisskeyApiToken(JSONObject()) .put("userIds", JSONArray().apply { list_info?.userIds?.forEach { this.put(it.toString()) } }) parseAccountList( client, "/api/users/show", misskeyParams = params ) } else { parseAccountList( client, String.format(Locale.JAPAN, PATH_LIST_MEMBER, profile_id) ) } } TYPE_FOLLOW_REQUESTS -> return if(isMisskey) { pagingType = PagingType.None parseAccountList( client , PATH_MISSKEY_FOLLOW_REQUESTS , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyCustomParser = misskeyCustomParserFollowRequest ) } else { parseAccountList( client, PATH_FOLLOW_REQUESTS ) } TYPE_FOLLOW_SUGGESTION -> return if(isMisskey) { pagingType = PagingType.Offset parseAccountList( client , PATH_MISSKEY_FOLLOW_SUGGESTION , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) ) } else { parseAccountList( client, PATH_FOLLOW_SUGGESTION ) } TYPE_ENDORSEMENT -> return parseAccountList( client, PATH_ENDORSEMENT ) TYPE_FAVOURITES -> return if(isMisskey) { getStatuses( client , PATH_MISSKEY_FAVORITES , misskeyParams = makeMisskeyTimelineParameter(parser) , misskeyCustomParser = misskeyCustomParserFavorites ) } else { getStatuses(client, PATH_FAVOURITES) } TYPE_HASHTAG -> return if(isMisskey) { getStatuses( client , makeHashtagUrl(hashtag) , misskeyParams = makeMisskeyTimelineParameter(parser) .put("tag", hashtag) .put("limit", MISSKEY_HASHTAG_LIMIT) ) } else { getStatuses(client, makeHashtagUrl(hashtag)) } TYPE_REPORTS -> return parseReports(client, PATH_REPORTS) TYPE_NOTIFICATIONS -> return parseNotifications(client) TYPE_BOOSTED_BY -> return parseAccountList( client, String.format(Locale.JAPAN, PATH_BOOSTED_BY, status_id) ) TYPE_FAVOURITED_BY -> return parseAccountList( client, String.format(Locale.JAPAN, PATH_FAVOURITED_BY, status_id) ) TYPE_CONVERSATION -> { if(isMisskey) { // 指定された発言そのもの val queryParams = makeMisskeyBaseParameter(parser) .put("noteId", status_id) result = client.request( "/api/notes/show" , queryParams.toPostRequestBuilder() ) val jsonObject = result?.jsonObject ?: return result val target_status = parser.status(jsonObject) ?: return TootApiResult("TootStatus parse failed.") target_status.conversation_main = true // 祖先 val list_asc = ArrayList() while(true) { if(client.isApiCancelled) return null queryParams.put("offset", list_asc.size) result = client.request( "/api/notes/conversation" , queryParams.toPostRequestBuilder() ) val jsonArray = result?.jsonArray ?: return result val src = parser.statusList(jsonArray) if(src.isEmpty()) break list_asc.addAll(src) } // 直接の子リプライ。(子孫をたどることまではしない) val list_desc = ArrayList() while(true) { if(client.isApiCancelled) return null queryParams.put("offset", list_desc.size) result = client.request( "/api/notes/replies" , queryParams.toPostRequestBuilder() ) val jsonArray = result?.jsonArray ?: return result val src = parser.statusList(jsonArray) if(src.isEmpty()) break list_desc.addAll(src) } // 一つのリストにまとめる this.list_tmp = ArrayList( list_asc.size + list_desc.size + 2 ).apply { addAll(list_asc.sortedBy { it.time_created_at }) add(target_status) addAll(list_desc.sortedBy { it.time_created_at }) add(TootMessageHolder(context.getString(R.string.misskey_cant_show_all_descendants))) } // return result } else { // 指定された発言そのもの result = client.request( String.format(Locale.JAPAN, PATH_STATUSES, status_id) ) var jsonObject = result?.jsonObject ?: return result val target_status = parser.status(jsonObject) ?: return TootApiResult("TootStatus parse failed.") // 前後の会話 result = client.request( String.format(Locale.JAPAN, PATH_STATUSES_CONTEXT, status_id) ) jsonObject = result?.jsonObject ?: return result val conversation_context = parseItem(::TootContext, parser, jsonObject) // 一つのリストにまとめる target_status.conversation_main = true if(conversation_context != null) { this.list_tmp = ArrayList( 1 + (conversation_context.ancestors?.size ?: 0) + (conversation_context.descendants?.size ?: 0) ) // if(conversation_context.ancestors != null) addWithFilterStatus( this.list_tmp, conversation_context.ancestors ) // addOne(list_tmp, target_status) // if(conversation_context.descendants != null) addWithFilterStatus( this.list_tmp, conversation_context.descendants ) // } else { this.list_tmp = addOne(this.list_tmp, target_status) this.list_tmp = addOne( this.list_tmp, TootMessageHolder(context.getString(R.string.toot_context_parse_failed)) ) } // マストドン2.6でTLにカード情報がレンダリングされたことにより、 // 個別にカードを取得する機能は ST3.0.7で廃止される // この機能はRate limitの問題を引き起こすことが多かった // if(! Pref.bpDontRetrievePreviewCard(context)) { // this.list_tmp?.forEach { o -> // // カードを取得する // if(o is TootStatus && o.card == null) // o.card = parseItem( // ::TootCard, // client.request("/api/v1/statuses/" + o.id + "/card")?.jsonObject // ) // } // } return result } } TYPE_TREND_TAG -> { result = client.request("/api/v1/trends") val src = parser.trendTagList(result?.jsonArray) this.list_tmp = addAll(this.list_tmp, src) this.list_tmp = addOne( this.list_tmp, TootMessageHolder( context.getString( R.string.trend_tag_desc, getResetTimeString() ), gravity = Gravity.END ) ) return result } TYPE_SEARCH -> if(isMisskey) { result = TootApiResult() val parser = TootParser(context, access_info) var params : JSONObject list_tmp = ArrayList() val queryAccount = search_query.trim().replace("^@".toRegex(), "") if(queryAccount.isNotEmpty()) { params = access_info.putMisskeyApiToken(JSONObject()) .put("query", queryAccount) .put("localOnly", ! search_resolve) result = client.request( "/api/users/search", params.toPostRequestBuilder() ) val jsonArray = result?.jsonArray if(jsonArray != null) { val src = TootParser(context, access_info).accountList(jsonArray) list_tmp = addAll(list_tmp, src) } } val queryTag = search_query.trim().replace("^#".toRegex(), "") if(queryTag.isNotEmpty()) { params = access_info.putMisskeyApiToken(JSONObject()) .put("query", queryTag) result = client.request( "/api/hashtags/search", params.toPostRequestBuilder() ) val jsonArray = result?.jsonArray if(jsonArray != null) { val src = TootTag.parseTootTagList(parser, jsonArray) list_tmp = addAll(list_tmp, src) } } if(search_query.isNotEmpty()) { params = access_info.putMisskeyApiToken(JSONObject()) .put("query", search_query) result = client.request( "/api/notes/search", params.toPostRequestBuilder() ) val jsonArray = result?.jsonArray if(jsonArray != null) { val src = parser.statusList(jsonArray) list_tmp = addWithFilterStatus(list_tmp, src) } } // 検索機能が無効だとsearch_query が 400を返すが、他のAPIがデータを返したら成功したことにする return if(list_tmp?.isNotEmpty() == true) { TootApiResult() } else { result } } else { if(access_info.isPseudo) { // 1.5.0rc からマストドンの検索APIは認証を要求するようになった return TootApiResult(context.getString(R.string.search_is_not_available_on_pseudo_account)) } var instance = access_info.instance if(instance == null) { getInstanceInformation(client, null) if(instance_tmp != null) { instance = instance_tmp access_info.instance = instance } } if(instance?.versionGE(TootInstance.VERSION_2_4_0) == true) { // v2 api を試す var path = String.format( Locale.JAPAN, PATH_SEARCH_V2, search_query.encodePercent() ) if(search_resolve) path += "&resolve=1" result = client.request(path) val jsonObject = result?.jsonObject if(jsonObject != null) { val tmp = parser.resultsV2(jsonObject) if(tmp != null) { list_tmp = ArrayList() addAll(list_tmp, tmp.hashtags) addAll(list_tmp, tmp.accounts) addAll(list_tmp, tmp.statuses) return result } } if(instance?.versionGE(TootInstance.VERSION_2_4_1_rc1) == true) { // 2.4.1rc1以降はv2が確実に存在するはずなので、v1へのフォールバックを行わない return result } } var path = String.format( Locale.JAPAN, PATH_SEARCH, search_query.encodePercent() ) if(search_resolve) path += "&resolve=1" result = client.request(path) val jsonObject = result?.jsonObject if(result == null || jsonObject == null) return result val tmp = parser.results(jsonObject) if(tmp != null) { list_tmp = ArrayList() addAll(list_tmp, tmp.hashtags) addAll(list_tmp, tmp.accounts) addAll(list_tmp, tmp.statuses) } return result } TYPE_SEARCH_MSP -> { idOld = null q = search_query.trim { it <= ' ' } if(q.isEmpty()) { list_tmp = ArrayList() result = TootApiResult() } else { result = client.searchMsp(search_query, idOld?.toString()) val jsonArray = result?.jsonArray if(jsonArray != null) { // max_id の更新 idOld = EntityId.mayNull( TootApiClient.getMspMaxId( jsonArray, idOld?.toString() ) ) // リストデータの用意 parser.serviceType = ServiceType.MSP list_tmp = addWithFilterStatus(null, parser.statusList(jsonArray)) } } return result } TYPE_SEARCH_TS -> { idOld = null q = search_query.trim { it <= ' ' } if(q.isEmpty()) { list_tmp = ArrayList() result = TootApiResult() } else { result = client.searchTootsearch(search_query, idOld?.toLong()) val jsonObject = result?.jsonObject if(jsonObject != null) { // max_id の更新 idOld = EntityId.mayNull( TootApiClient.getTootsearchMaxId( jsonObject, idOld?.toLong() ) ) // リストデータの用意 val search_result = TootStatus.parseListTootsearch(parser, jsonObject) this.list_tmp = addWithFilterStatus(null, search_result) if(search_result.isEmpty()) { log.d("search result is empty. %s", result?.bodyString) } } } return result } TYPE_INSTANCE_INFORMATION -> { result = getInstanceInformation(client, instance_uri) if(instance_tmp != null) { instance_information = instance_tmp handshake = result?.response?.handshake() } return result } TYPE_SCHEDULED_STATUS -> return getScheduledStatuses(client) else -> return getStatuses(client, makeHomeTlUrl()) } } finally { try { updateRelation(client, list_tmp, who_account, parser) } catch(ex : Throwable) { log.trace(ex) } ctClosed.set(true) runOnMainLooperDelayed(333L) { if(! isCancelled) fireShowColumnStatus() } } } override fun onCancelled(result : TootApiResult?) { onPostExecute(null) } override fun onPostExecute(result : TootApiResult?) { if(is_dispose.get()) return if(isCancelled || result == null) { return } bInitialLoading = false lastTask = null if(result.error != null) { this@Column.mInitialLoadingError = result.error ?: "" } else { duplicate_map.clear() list_data.clear() val list_tmp = this.list_tmp if(list_tmp != null) { val list_pinned = this.list_pinned if(list_pinned?.isNotEmpty() == true) { val list_new = duplicate_map.filterDuplicate(list_pinned) list_data.addAll(list_new) } val list_new = duplicate_map.filterDuplicate(list_tmp) list_data.addAll(list_new) } resumeStreaming(false) } fireShowContent(reason = "loading updated", reset = true) // 初期ロードの直後は先頭に移動する viewHolder?.scrollToTop() updateMisskeyCapture() } } this.lastTask = task task.executeOnExecutor(App1.task_executor) } // mastodon用 private fun makeProfileStatusesUrl(profile_id : EntityId?) : String { var path ="/api/v1/accounts/$profile_id/statuses?limit=$READ_LIMIT" if(with_attachment && ! with_highlight) path += "&only_media=1" if(dont_show_boost) path += "&exclude_reblogs=1" if(dont_show_reply) path += "&exclude_replies=1" return path } private var bMinIdMatched : Boolean = false private fun parseRange( result : TootApiResult?, list : List? ) : Pair { var idMin : EntityId? = null var idMax : EntityId? = null if(isMisskey && list != null) { // MisskeyはLinkヘッダがないので、常にデータからIDを読む for(item in list) { val id = item.getOrderId() if(idMin == null || id < idMin) idMin = id if(idMax == null || id > idMax) idMax = id } } else if(result != null) { // Linkヘッダを読む idMin = if(result.link_older == null) { null } else { val m = reMaxId.matcher(result.link_older) if(m.find()) { EntityIdLong(m.group(1).toLong()) } else { null } } idMax = if(result.link_newer == null) { null } else { var m = reMinId.matcher(result.link_newer) if(m.find()) { bMinIdMatched = true EntityIdLong(m.group(1).toLong()) } else { m = reSinceId.matcher(result.link_newer) if(m.find()) { bMinIdMatched = false EntityIdLong(m.group(1).toLong()) } else { null } } } } return Pair(idMin, idMax) } // int scroll_hack; // return true if list bottom may have unread remain private fun saveRange( bBottom : Boolean, bTop : Boolean, result : TootApiResult?, list : List? ) : Boolean { val (idMin, idMax) = parseRange(result, list) var hasBottomRemain = false if(bBottom) { when { idMin == null -> idOld = null // リストの終端 idMin is EntityIdString && ! isMisskey -> { log.e("EntityId should be Long for non-misskey column! columnType=$column_type") } else -> { val i = idOld?.compareTo(idMin) if(i == null || i > 0) { idOld = idMin hasBottomRemain = true } } } } if(bTop) { when { // リロードを許容するため、取得内容がカラでもidRecentを変更しない idMax == null -> { } idMax is EntityIdString && ! isMisskey -> { log.e("EntityId should be Long for non-misskey column! columnType=$column_type") } else -> { val i = idRecent?.compareTo(idMax) if(i == null || i < 0) { idRecent = idMax } } } } return hasBottomRemain } // return true if list bottom may have unread remain private fun saveRangeEnd(result : TootApiResult?, list : List?) = saveRange(true, false, result = result, list = list) // return true if list bottom may have unread remain private fun saveRangeStart(result : TootApiResult?, list : List?) = saveRange(false, true, result = result, list = list) private fun addRange(bBottom : Boolean, path : String) : String { val delimiter = if(- 1 != path.indexOf('?')) '&' else '?' if(bBottom) { if(idOld != null) return "$path${delimiter}max_id=${idOld}" } else { if(idRecent != null) return "$path${delimiter}since_id=${idRecent}" } return path } private fun addRangeMin(path : String) : String { val delimiter = if(- 1 != path.indexOf('?')) '&' else '?' if(idRecent != null) return "$path${delimiter}min_id=${idRecent}" return path } private fun addRangeMisskey(bBottom : Boolean, params : JSONObject) : JSONObject { if(bBottom) { idOld?.putMisskeyUntil(params) } else { idRecent?.putMisskeySince(params) } return params } internal fun startRefreshForPost( refresh_after_post : Int, posted_status_id : EntityId, posted_reply_id : EntityId? ) { when(column_type) { TYPE_HOME, TYPE_LOCAL, TYPE_FEDERATE, TYPE_DIRECT_MESSAGES, TYPE_MISSKEY_HYBRID -> { startRefresh( true, false, posted_status_id, refresh_after_post ) } TYPE_PROFILE -> { if(profile_tab == TAB_STATUS && profile_id == access_info.loginAccount?.id ) { startRefresh( true, false, posted_status_id, refresh_after_post ) } } TYPE_CONVERSATION -> { // 会話への返信が行われたなら会話を更新する try { if(posted_reply_id != null) { for(item in list_data) { if(item is TootStatus && item.id == posted_reply_id) { startLoading() break } } } } catch(_ : Throwable) { } } } } internal fun startRefresh( bSilent : Boolean, bBottom : Boolean, posted_status_id : EntityId? = null, refresh_after_toot : Int = - 1 ) { if(lastTask != null) { if(! bSilent) { showToast(context, true, R.string.column_is_busy) val holder = viewHolder if(holder != null) holder.refreshLayout.isRefreshing = false } return } else if(bBottom && ! canRefreshBottom()) { if(! bSilent) { showToast(context, true, R.string.end_of_list) val holder = viewHolder if(holder != null) holder.refreshLayout.isRefreshing = false } return } else if(! bBottom && ! canRefreshTop()) { val holder = viewHolder if(holder != null) holder.refreshLayout.isRefreshing = false startLoading() return } if(bSilent) { val holder = viewHolder if(holder != null) { holder.refreshLayout.isRefreshing = true } } if(! bBottom) { bRefreshingTop = true stopStreaming() } bRefreshLoading = true mRefreshLoadingError = "" val task = @SuppressLint("StaticFieldLeak") object : ColumnTask( when { bBottom -> ColumnTaskType.REFRESH_BOTTOM else -> ColumnTaskType.REFRESH_TOP } ) { var parser = TootParser(context, access_info, highlightTrie = highlight_trie) var list_tmp : ArrayList? = null fun getAccountList( client : TootApiClient, path_base : String, misskeyParams : JSONObject? = null, misskeyArrayFinder : (JSONObject) -> JSONArray? = { null }, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.accountList(jsonArray) } ) : TootApiResult? { @Suppress("NON_EXHAUSTIVE_WHEN") when(bBottom) { false -> when(pagingType) { PagingType.Cursor, PagingType.None, PagingType.Offset -> { return TootApiResult("can't refresh top.") } } true -> when(pagingType) { PagingType.Cursor -> if(idOld == null) { return TootApiResult(context.getString(R.string.end_of_list)) } PagingType.None -> { return TootApiResult(context.getString(R.string.end_of_list)) } } } val params = misskeyParams ?: makeMisskeyBaseParameter(parser) val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent val time_start = SystemClock.elapsedRealtime() var result = if(isMisskey) { @Suppress("NON_EXHAUSTIVE_WHEN") when(pagingType) { PagingType.Default -> addRangeMisskey(bBottom, params) PagingType.Offset -> params.put("offset", offsetNext) PagingType.Cursor -> params.put("cursor", idOld) } client.request(path_base, params.toPostRequestBuilder()) } else { client.request(addRange(bBottom, path_base)) } val firstResult = result var jsonObject = result?.jsonObject if(jsonObject != null) { if(pagingType == PagingType.Cursor) { idOld = EntityId.mayNull(jsonObject.parseString("next")) } result !!.data = misskeyArrayFinder(jsonObject) } var array = result?.jsonArray if(array != null) { var src = misskeyCustomParser(parser, array) @Suppress("NON_EXHAUSTIVE_WHEN") when(pagingType) { PagingType.Default -> { saveRange(bBottom, ! bBottom, firstResult, src) } PagingType.Offset -> { offsetNext += src.size } } list_tmp = addAll(null, src) if(! bBottom) { if(isMisskey) { var bHeadGap = false // misskeyの場合、sinceIdを指定したら未読範囲の古い方から読んでしまう // 最新まで読めるとは限らない // 先頭にギャップを置くかもしれない while(true) { if(isCancelled) { log.d("refresh-account-top: cancelled.") break } if(src.isEmpty()) { // 直前のデータが0個なら終了とみなす log.d("refresh-account-top: previous size == 0.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-account-top: timeout.") bHeadGap = true break } idRecent?.putMisskeySince(params) result = client.request(path_base, params.toPostRequestBuilder()) jsonObject = result?.jsonObject if(jsonObject != null) { // pagingType is always default. result !!.data = misskeyArrayFinder(jsonObject) } array = result?.jsonArray if(array == null) { log.d("refresh-account-top: error or cancelled.") bHeadGap = true break } src = misskeyCustomParser(parser, array) addAll(list_tmp, src) // pagingType is always default. saveRange(false, true, result, src) } // pagingType is always default. if(isMisskey && ! bBottom) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(! isCancelled && list_tmp?.isNotEmpty() == true && (bHeadGap || Pref.bpForceGap(context)) ) { addOneFirst(list_tmp, TootGap.mayNull(null, idRecent)) } } else { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-account-top: cancelled.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-account-top: previous size == 0.") break } max_id = parseRange(result, src).first if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-account-top: timeout. make gap.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) jsonObject = result?.jsonObject if(jsonObject != null) { result?.data = misskeyArrayFinder(jsonObject) } val jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-account-top: error or cancelled. make gap.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = misskeyCustomParser(parser, jsonArray) addAll(list_tmp, src) } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } } // フィルタがないので下端更新の繰り返しは発生しない } return firstResult } fun getDomainList( client : TootApiClient, path_base : String ) : TootApiResult? { if(isMisskey) return TootApiResult("misskey support is not yet implemented.") val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent var result = client.request(addRange(bBottom, path_base)) val firstResult = result var jsonArray = result?.jsonArray if(jsonArray != null) { var src = TootDomainBlock.parseList(jsonArray) // ページネーションはサーバ側の内部パラメータで行われる saveRange(bBottom, ! bBottom, result, src) list_tmp = addAll(null, src) if(! bBottom) { if(isMisskey) { // Misskey非対応 } else { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-domain-top: cancelled.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-domain-top: previous size == 0.") break } // 直前に読んだ範囲のmaxIdを調べる max_id = parseRange(result, src).first if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-domain-top: timeout.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-domain-top: error or cancelled.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = TootDomainBlock.parseList(jsonArray) addAll(list_tmp, src) } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } } // フィルタがないので下端更新の繰り返しはない } return firstResult } // fun getListList(client : TootApiClient, path_base : String) : TootApiResult? { // // if(isMisskey) return TootApiResult("misskey support is not yet implemented.") // // return TootApiResult("Mastodon's /api/v1/lists has no pagination.") // } fun getReportList( client : TootApiClient, path_base : String ) : TootApiResult? { if(isMisskey) return TootApiResult("misskey support is not yet implemented.") val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent var result = client.request(addRange(bBottom, path_base)) val firstResult = result var jsonArray = result?.jsonArray if(jsonArray != null) { var src = parseList(::TootReport, jsonArray) list_tmp = addAll(null, src) saveRange(bBottom, ! bBottom, result, src) if(! bBottom) { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-report-top: cancelled.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-report-top: previous size == 0.") break } // 直前に読んだ範囲のmaxIdを調べる max_id = parseRange(result, src).first if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-report-top: timeout. make gap.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-report-top: timeout. error or retry. make gap.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = parseList(::TootReport, jsonArray) addAll(list_tmp, src) } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } // レポートにはフィルタがないので下端更新は繰り返さない } return firstResult } fun getNotificationList(client : TootApiClient) : TootApiResult? { val path_base = makeNotificationUrl() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent val params = makeMisskeyBaseParameter(parser).addMisskeyNotificationFilter() val time_start = SystemClock.elapsedRealtime() var result = if(isMisskey) { addRangeMisskey(bBottom, params) client.request(path_base, params.toPostRequestBuilder()) } else { client.request(addRange(bBottom, path_base)) } val firstResult = result var jsonArray = result?.jsonArray if(jsonArray != null) { var src = parser.notificationList(jsonArray) list_tmp = addWithFilterNotification(null, src) saveRange(bBottom, ! bBottom, result, src) if(! src.isEmpty()) { PollingWorker.injectData(context, access_info, src) } if(! bBottom) { // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う if(isMisskey) { // misskey ではsinceIdを指定すると古い方から読める // 先頭にギャップを追加するかもしれない var bHeadGap = false while(true) { if(isCancelled) { log.d("refresh-notification-top: cancelled.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-notification-top: previous size == 0.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-notification-top: timeout. make gap.") // タイムアウト bHeadGap = true break } idRecent?.putMisskeySince(params) result = client.request(path_base, params.toPostRequestBuilder()) jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-notification-top: error or cancelled. make gap.") // エラー bHeadGap = true break } src = parser.notificationList(jsonArray) saveRange(false, true, result, src) if(! src.isEmpty()) { addWithFilterNotification(list_tmp, src) PollingWorker.injectData(context, access_info, src) } } if(isMisskey && ! bBottom) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(! isCancelled && list_tmp?.isNotEmpty() == true && (bHeadGap || Pref.bpForceGap(context)) ) { addOneFirst(list_tmp, TootGap.mayNull(null, idRecent)) } } else { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-notification-offset: cancelled.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-notification-offset: previous size == 0.") break } max_id = parseRange(result, src).first if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-notification-offset: timeout. make gap.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-notification-offset: error or cancelled. make gap.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = parser.notificationList(jsonArray) if(! src.isEmpty()) { addWithFilterNotification(list_tmp, src) PollingWorker.injectData(context, access_info, src) } } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } } else { while(true) { if(isCancelled) { log.d("refresh-notification-bottom: cancelled.") break } // bottomの場合、フィルタなしなら繰り返さない if(! isFilterEnabled) { log.d("refresh-notification-bottom: isFiltered is false.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-notification-bottom: previous size == 0.") break } // 十分読んだらそれで終了 if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-notification-bottom: read enough data.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { // タイムアウト log.d("refresh-notification-bottom: loop timeout.") break } result = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base) } else { val path = path_base + delimiter + "max_id=" + idOld client.request(path) } jsonArray = result?.jsonArray if(jsonArray == null) { log.d("refresh-notification-bottom: error or cancelled.") break } src = parser.notificationList(jsonArray) addWithFilterNotification(list_tmp, src) if(! saveRangeEnd(result, src)) { log.d("refresh-notification-bottom: saveRangeEnd failed.") break } } } } return firstResult } fun getConversationSummaryList( client : TootApiClient, path_base : String, aroundMin : Boolean = false, misskeyParams : JSONObject? = null, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parseList(::TootConversationSummary, parser, jsonArray) } ) : TootApiResult? { val isMisskey = access_info.isMisskey val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent var result = when { isMisskey -> { addRangeMisskey(bBottom, params) client.request(path_base, params.toPostRequestBuilder()) } aroundMin -> client.request(addRangeMin(path_base)) else -> client.request(addRange(bBottom, path_base)) } val firstResult = result val jsonArray = result?.jsonArray if(jsonArray != null) { var src = misskeyCustomParser(parser, jsonArray) saveRange(bBottom, ! bBottom, result, src) list_tmp = addWithFilterConversationSummary(null, src) if(! bBottom) { if(isMisskey) { // Misskeyの場合はsinceIdを指定しても取得できるのは未読のうち古い範囲に偏る var bHeadGap = false while(true) { if(isCancelled) { log.d("refresh-ConversationSummary-top: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // 直前のデータが0個なら終了とみなす if(src.isEmpty()) { log.d("refresh-ConversationSummary-top: previous size == 0.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-ConversationSummary-top: read enough. make gap.") bHeadGap = true break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-ConversationSummary-top: timeout. make gap.") bHeadGap = true break } idRecent?.putMisskeySince(params) result = client.request(path_base, params.toPostRequestBuilder()) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-ConversationSummary-top: error or cancelled. make gap.") bHeadGap = true break } src = misskeyCustomParser(parser, jsonArray2) saveRange(false, true, result, src) addWithFilterConversationSummary(list_tmp, src) } if(isMisskey && ! bBottom) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(! isCancelled && list_tmp?.isNotEmpty() == true && (bHeadGap || Pref.bpForceGap(context)) ) { addOneFirst(list_tmp, TootGap.mayNull(null, idRecent)) } } else if(aroundMin) { while(true) { saveRangeStart(result, src) if(isCancelled) { log.d("refresh-ConversationSummary-aroundMin: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-ConversationSummary-aroundMin: previous size == 0.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-ConversationSummary-aroundMin: read enough.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-ConversationSummary-aroundMin: timeout.") break } val path = "$path_base${delimiter}min_id=$idRecent" result = client.request(path) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-ConversationSummary-aroundMin: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterConversationSummary(list_tmp, src) } } else { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-ConversationSummary-top: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-ConversationSummary-top: previous size == 0.") break } max_id = parseRange(result, src).first if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-ConversationSummary-top: read enough. make gap.") // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-ConversationSummary-top: timeout. make gap.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-ConversationSummary-top: error or cancelled. make gap.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterConversationSummary(list_tmp, src) } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } } else { while(true) { if(isCancelled) { log.d("refresh-ConversationSummary-bottom: cancelled.") break } // bottomの場合、フィルタなしなら繰り返さない if(! isFilterEnabled) { log.d("refresh-ConversationSummary-bottom: isFiltered is false.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-ConversationSummary-bottom: previous size == 0.") break } // 十分読んだらそれで終了 if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-ConversationSummary-bottom: read enough data.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { // タイムアウト log.d("refresh-ConversationSummary-bottom: loop timeout.") break } result = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}max_id=$idOld" client.request(path) } val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-ConversationSummary-bottom: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterConversationSummary(list_tmp, src) if(! saveRangeEnd(result, src)) { log.d("refresh-ConversationSummary-bottom: saveRangeEnd failed.") break } } } } return firstResult } fun getStatusList( client : TootApiClient, path_base : String, aroundMin : Boolean = false, misskeyParams : JSONObject? = null, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.statusList(jsonArray) } ) : TootApiResult? { val isMisskey = access_info.isMisskey val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' val last_since_id = idRecent var result = when { isMisskey -> { addRangeMisskey(bBottom, params) client.request(path_base, params.toPostRequestBuilder()) } aroundMin -> client.request(addRangeMin(path_base)) else -> client.request(addRange(bBottom, path_base)) } val firstResult = result val jsonArray = result?.jsonArray if(jsonArray != null) { var src = misskeyCustomParser(parser, jsonArray) saveRange(bBottom, ! bBottom, result, src) list_tmp = addWithFilterStatus(null, src) if(! bBottom) { if(isMisskey) { // Misskeyの場合はsinceIdを指定しても取得できるのは未読のうち古い範囲に偏る var bHeadGap = false while(true) { if(isCancelled) { log.d("refresh-status-top: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // 直前のデータが0個なら終了とみなす if(src.isEmpty()) { log.d("refresh-status-top: previous size == 0.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-status-top: read enough. make gap.") bHeadGap = true break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-status-top: timeout. make gap.") bHeadGap = true break } idRecent?.putMisskeySince(params) result = client.request(path_base, params.toPostRequestBuilder()) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-status-top: error or cancelled. make gap.") bHeadGap = true break } src = misskeyCustomParser(parser, jsonArray2) saveRange(false, true, result, src) addWithFilterStatus(list_tmp, src) } if(isMisskey && ! bBottom) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(! isCancelled && list_tmp?.isNotEmpty() == true && (bHeadGap || Pref.bpForceGap(context)) ) { addOneFirst(list_tmp, TootGap.mayNull(null, idRecent)) } } else if(aroundMin) { while(true) { saveRangeStart(result, src) if(isCancelled) { log.d("refresh-status-aroundMin: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-status-aroundMin: previous size == 0.") break } if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-status-aroundMin: read enough.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-status-aroundMin: timeout.") break } val path = "$path_base${delimiter}min_id=$idRecent" result = client.request(path) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-status-aroundMin: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterStatus(list_tmp, src) } } else { var bGapAdded = false var max_id : EntityId? = null while(true) { if(isCancelled) { log.d("refresh-status-top: cancelled.") break } // 頭の方を読む時は隙間を減らすため、フィルタの有無に関係なく繰り返しを行う // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-status-top: previous size == 0.") break } max_id = parseRange(result, src).first if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-status-top: read enough. make gap.") // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("refresh-status-top: timeout. make gap.") // タイムアウト // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$last_since_id" result = client.request(path) val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-status-top: error or cancelled. make gap.") // エラー // 隙間ができるかもしれない。後ほど手動で試してもらうしかない addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) bGapAdded = true break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterStatus(list_tmp, src) } if(Pref.bpForceGap(context) && ! isCancelled && ! bGapAdded && list_tmp?.isNotEmpty() == true) { addOne(list_tmp, TootGap.mayNull(max_id, last_since_id)) } } } else { while(true) { if(isCancelled) { log.d("refresh-status-bottom: cancelled.") break } // bottomの場合、フィルタなしなら繰り返さない if(! isFilterEnabled) { log.d("refresh-status-bottom: isFiltered is false.") break } // max_id だけを指定した場合、必ずlimit個のデータが帰ってくるとは限らない // 直前のデータが0個なら終了とみなすしかなさそう if(src.isEmpty()) { log.d("refresh-status-bottom: previous size == 0.") break } // 十分読んだらそれで終了 if((list_tmp?.size ?: 0) >= LOOP_READ_ENOUGH) { log.d("refresh-status-bottom: read enough data.") break } if(SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { // タイムアウト log.d("refresh-status-bottom: loop timeout.") break } result = if(isMisskey) { idOld?.putMisskeyUntil(params) client.request(path_base, params.toPostRequestBuilder()) } else { val path = "$path_base${delimiter}max_id=$idOld" client.request(path) } val jsonArray2 = result?.jsonArray if(jsonArray2 == null) { log.d("refresh-status-bottom: error or cancelled.") break } src = misskeyCustomParser(parser, jsonArray2) addWithFilterStatus(list_tmp, src) if(! saveRangeEnd(result, src)) { log.d("refresh-status-bottom: saveRangeEnd failed.") break } } } } return firstResult } var filterUpdated = false override fun doInBackground(vararg unused : Void) : TootApiResult? { ctStarted.set(true) val client = TootApiClient(context, callback = object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() override fun publishApiProgress(s : String) { runOnMainLooper { if(isCancelled) return@runOnMainLooper task_progress = s fireShowContent(reason = "refresh progress", changeList = ArrayList()) } } }) client.account = access_info try { if(! bBottom) { val filterList = loadFilter2(client) if(filterList != null) { muted_word2 = encodeFilterTree(filterList) filterUpdated = true } } return when(column_type) { TYPE_DIRECT_MESSAGES -> if(useConversationSummarys) { // try 2.6.0 new API https://github.com/tootsuite/mastodon/pull/8832 getConversationSummaryList(client, PATH_DIRECT_MESSAGES2) } else { // fallback to old api getStatusList(client, PATH_DIRECT_MESSAGES) } TYPE_LOCAL -> getStatusList(client, makePublicLocalUrl()) TYPE_LOCAL_AROUND -> { if(bBottom) { // 通常と同じ getStatusList(client, makePublicLocalUrl()) } else { val rv = getStatusList(client, makePublicLocalUrl(), aroundMin = true) list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() rv } } TYPE_FEDERATED_AROUND -> { if(bBottom) { // 通常と同じ getStatusList(client, makePublicFederateUrl()) } else { val rv = getStatusList(client, makePublicFederateUrl(), aroundMin = true) list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() rv } } TYPE_ACCOUNT_AROUND -> { val path = makeProfileStatusesUrl(profile_id) if(bBottom) { getStatusList(client, path) } else { val rv = getStatusList(client, path, aroundMin = true) list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() rv } } TYPE_MISSKEY_HYBRID -> getStatusList(client, makeMisskeyHybridTlUrl()) TYPE_FEDERATE -> getStatusList(client, makePublicFederateUrl()) TYPE_FAVOURITES -> if(isMisskey) { getStatusList( client , PATH_MISSKEY_FAVORITES , misskeyParams = makeMisskeyTimelineParameter(parser) , misskeyCustomParser = misskeyCustomParserFavorites ) } else { getStatusList(client, PATH_FAVOURITES) } TYPE_REPORTS -> getReportList(client, PATH_REPORTS) TYPE_NOTIFICATIONS -> getNotificationList(client) TYPE_BOOSTED_BY -> getAccountList( client, String.format( Locale.JAPAN, PATH_BOOSTED_BY, posted_status_id ) ) TYPE_FAVOURITED_BY -> getAccountList( client, String.format( Locale.JAPAN, PATH_FAVOURITED_BY, posted_status_id ) ) TYPE_PROFILE -> { loadProfileAccount(client, parser, false) when(profile_tab) { TAB_FOLLOWING -> if(isMisskey) { getAccountList( client, PATH_MISSKEY_PROFILE_FOLLOWING, misskeyParams = makeMisskeyParamsUserId(parser), misskeyArrayFinder = misskeyArrayFinderUsers ) } else { getAccountList( client, String.format( Locale.JAPAN, PATH_ACCOUNT_FOLLOWING, profile_id ) ) } TAB_FOLLOWERS -> if(isMisskey) { getAccountList( client, PATH_MISSKEY_PROFILE_FOLLOWERS, misskeyParams = makeMisskeyParamsUserId(parser), misskeyArrayFinder = misskeyArrayFinderUsers ) } else { getAccountList( client, String.format( Locale.JAPAN, PATH_ACCOUNT_FOLLOWERS, profile_id ) ) } else -> if(isMisskey) { getStatusList( client, PATH_MISSKEY_PROFILE_STATUSES, misskeyParams = makeMisskeyParamsProfileStatuses(parser) ) } else { getStatusList(client, makeProfileStatusesUrl(profile_id)) } } } TYPE_LIST_LIST -> { TootApiResult("list API does not support refresh loading.") } TYPE_LIST_TL -> { loadListInfo(client, false) if(isMisskey) { val params = makeMisskeyTimelineParameter(parser) .put("listId", profile_id) getStatusList(client, makeListTlUrl(), misskeyParams = params) } else { getStatusList(client, makeListTlUrl()) } } TYPE_LIST_MEMBER -> { loadListInfo(client, false) getAccountList( client, String.format(Locale.JAPAN, PATH_LIST_MEMBER, profile_id) ) } TYPE_MUTES -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_MUTES , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyArrayFinder = misskeyArrayFinderUsers ) } else { getAccountList(client, PATH_MUTES) } TYPE_BLOCKS -> if(isMisskey) { pagingType = PagingType.Default val params = access_info.putMisskeyApiToken(JSONObject()) getAccountList( client, "/api/blocking/list", misskeyParams = params, misskeyCustomParser = misskeyCustomParserBlocks ) } else { getAccountList(client, PATH_BLOCKS) } TYPE_DOMAIN_BLOCKS -> getDomainList(client, PATH_DOMAIN_BLOCK) TYPE_FOLLOW_REQUESTS -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_FOLLOW_REQUESTS , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyCustomParser = misskeyCustomParserFollowRequest ) } else { getAccountList(client, PATH_FOLLOW_REQUESTS) } TYPE_FOLLOW_SUGGESTION -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_FOLLOW_SUGGESTION , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) ) } else { getAccountList(client, PATH_FOLLOW_SUGGESTION) } TYPE_ENDORSEMENT -> getAccountList(client, PATH_ENDORSEMENT) TYPE_HASHTAG -> if(isMisskey) { getStatusList( client , makeHashtagUrl(hashtag) , misskeyParams = makeMisskeyTimelineParameter(parser) .put("tag", hashtag) .put("limit", MISSKEY_HASHTAG_LIMIT) ) } else { getStatusList(client, makeHashtagUrl(hashtag)) } TYPE_SEARCH_MSP -> if(! bBottom) { TootApiResult("head of list.") } else { val result : TootApiResult? val q = search_query.trim { it <= ' ' } if(q.isEmpty()) { list_tmp = ArrayList() result = TootApiResult(context.getString(R.string.end_of_list)) } else { result = client.searchMsp(search_query, idOld?.toString()) val jsonArray = result?.jsonArray if(jsonArray != null) { // max_id の更新 idOld = EntityId.mayNull( TootApiClient.getMspMaxId( jsonArray, idOld?.toString() ) ) // リストデータの用意 parser.serviceType = ServiceType.MSP list_tmp = addWithFilterStatus( list_tmp, parser.statusList(jsonArray) ) } } result } TYPE_SEARCH_TS -> if(! bBottom) { TootApiResult("head of list.") } else { val result : TootApiResult? val q = search_query.trim { it <= ' ' } if(q.isEmpty() || idOld == null) { list_tmp = ArrayList() result = TootApiResult(context.getString(R.string.end_of_list)) } else { result = client.searchTootsearch(search_query, idOld?.toLong()) val jsonObject = result?.jsonObject if(jsonObject != null) { // max_id の更新 idOld = EntityId.mayNull( TootApiClient.getTootsearchMaxId( jsonObject, idOld?.toLong() ) ) // リストデータの用意 val search_result = TootStatus.parseListTootsearch(parser, jsonObject) list_tmp = addWithFilterStatus(list_tmp, search_result) } } result } else -> getStatusList(client, makeHomeTlUrl()) } } finally { try { updateRelation(client, list_tmp, who_account, parser) } catch(ex : Throwable) { log.trace(ex) } ctClosed.set(true) runOnMainLooperDelayed(333L) { if(! isCancelled) fireShowColumnStatus() } } } override fun onCancelled(result : TootApiResult?) { onPostExecute(null) } override fun onPostExecute(result : TootApiResult?) { if(is_dispose.get()) return if(isCancelled || result == null) { return } try { lastTask = null bRefreshLoading = false if(filterUpdated) { checkFiltersForListData(muted_word2) } val error = result.error if(error != null) { mRefreshLoadingError = error mRefreshLoadingErrorTime = SystemClock.elapsedRealtime() fireShowContent(reason = "refresh error", changeList = ArrayList()) return } val list_new = duplicate_map.filterDuplicate(list_tmp) if(list_new.isEmpty()) { fireShowContent( reason = "refresh list_new is empty", changeList = ArrayList() ) return } // 事前にスクロール位置を覚えておく var sp : ScrollPosition? = null val holder = viewHolder if(holder != null) { sp = holder.scrollPosition } if(bBottom) { val changeList = listOf( AdapterChange( AdapterChangeType.RangeInsert, list_data.size, list_new.size ) ) list_data.addAll(list_new) fireShowContent(reason = "refresh updated bottom", changeList = changeList) // 新着が少しだけ見えるようにスクロール位置を移動する if(sp != null) { holder?.setScrollPosition(sp, 20f) } } else { val changeList = ArrayList() if(list_data.isNotEmpty() && list_data[0] is TootGap) { changeList.add(AdapterChange(AdapterChangeType.RangeRemove, 0, 1)) list_data.removeAt(0) } for(o in list_new) { if(o is TootStatus) { val highlight_sound = o.highlight_sound if(highlight_sound != null) { App1.sound(highlight_sound) break } } } replaceConversationSummary(changeList, list_new, list_data) val added = list_new.size // may 0 // 投稿後のリフレッシュなら当該投稿の位置を探す var status_index = - 1 for(i in 0 until added) { val o = list_new[i] if(o is TootStatus && o.id == posted_status_id) { status_index = i break } } changeList.add(AdapterChange(AdapterChangeType.RangeInsert, 0, added)) list_data.addAll(0, list_new) fireShowContent(reason = "refresh updated head", changeList = changeList) if(status_index >= 0 && refresh_after_toot == Pref.RAT_REFRESH_SCROLL) { // 投稿後にその投稿にスクロールする if(holder != null) { holder.setScrollPosition( ScrollPosition(toAdapterIndex(status_index)), 0f ) } else { scroll_save = ScrollPosition(toAdapterIndex(status_index)) } } else { // val scroll_save = this@Column.scroll_save when { // ViewHolderがある場合は増加件数分+deltaの位置にスクロールする sp != null -> { sp.adapterIndex += added val delta = if(bSilent) 0f else - 20f holder?.setScrollPosition(sp, delta) } // ViewHolderがなくて保存中の位置がある場合、増加件数分ずらす。deltaは難しいので反映しない scroll_save != null -> scroll_save.adapterIndex += added // 保存中の位置がない場合、保存中の位置を新しく作る else -> this@Column.scroll_save = ScrollPosition(toAdapterIndex(added)) } } } updateMisskeyCapture() } finally { fireShowColumnStatus() if(! bBottom) { bRefreshingTop = false resumeStreaming(false) } } } } this.lastTask = task task.executeOnExecutor(App1.task_executor) fireShowColumnStatus() } internal fun startGap(gap : TootGap?) { if(gap == null) { showToast(context, true, "gap is null") return } if(lastTask != null) { showToast(context, true, R.string.column_is_busy) return } @Suppress("UNNECESSARY_SAFE_CALL") viewHolder?.refreshLayout?.isRefreshing = true bRefreshLoading = true mRefreshLoadingError = "" val task = @SuppressLint("StaticFieldLeak") object : ColumnTask(ColumnTaskType.GAP) { var max_id : EntityId? = gap.max_id var since_id : EntityId? = gap.since_id var list_tmp : ArrayList? = null var parser = TootParser(context, access_info, highlightTrie = highlight_trie) fun getAccountList( client : TootApiClient, path_base : String, misskeyParams : JSONObject? = null, misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.accountList(jsonArray) }, misskeyArrayFinder : (jsonObject : JSONObject) -> JSONArray? = { null } ) : TootApiResult? { @Suppress("NON_EXHAUSTIVE_WHEN") when(pagingType) { PagingType.Offset, PagingType.Cursor, PagingType.None -> { return TootApiResult("can't support gap") } } val params = misskeyParams ?: makeMisskeyBaseParameter(parser) val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' list_tmp = ArrayList() var result : TootApiResult? = null if(isMisskey) { // missKeyではgapを下から読む var bHeadGap = false while(true) { if(isCancelled) { log.d("gap-account: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-account: timeout. make gap.") bHeadGap = true break } since_id?.putMisskeySince(params) val r2 = client.request(path_base, params.toPostRequestBuilder()) val jsonObject = r2?.jsonObject if(jsonObject != null) { r2.data = misskeyArrayFinder(jsonObject) } val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-account: error. make gap.") if(result == null) result = r2 bHeadGap = true break } result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { log.d("gap-account: empty.") break } addAll(list_tmp, src) since_id = parseRange(result, src).second } if(isMisskey) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(bHeadGap) { addOneFirst(list_tmp, TootGap.mayNull(max_id, since_id)) } } else { while(true) { if(isCancelled) { log.d("gap-account: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-account: timeout. make gap.") // タイムアウト // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } val path = "$path_base${delimiter}max_id=$max_id&since_id=$since_id" val r2 = client.request(path) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-account: error timeout. make gap.") if(result == null) result = r2 // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { log.d("gap-account: empty.") break } addAll(list_tmp, src) max_id = parseRange(result, src).first } } return result } fun getReportList( client : TootApiClient, path_base : String ) : TootApiResult? { val time_start = SystemClock.elapsedRealtime() val params = makeMisskeyBaseParameter(parser) val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' list_tmp = ArrayList() var result : TootApiResult? = null if(isMisskey) { var bHeadGap = false while(true) { if(isCancelled) { log.d("gap-report: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-report: timeout. make gap.") bHeadGap = true break } since_id?.putMisskeySince(params) val r2 = client.request(path_base, params.toPostRequestBuilder()) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-report: error or cancelled. make gap.") if(result == null) result = r2 bHeadGap = true break } result = r2 val src = parseList(::TootReport, jsonArray) if(src.isEmpty()) { log.d("gap-report: empty.") break } addAll(list_tmp, src) // 隙間の最新のステータスIDは取得データ末尾のステータスIDである since_id = parseRange(result, src).second } // レポート一覧ってそもそもMisskey対応してないので、ここをどうするかは不明 // 多分 sinceIDによるページングではないと思う if(isMisskey) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(bHeadGap) { addOneFirst(list_tmp, TootGap.mayNull(max_id, since_id)) } } else { while(true) { if(isCancelled) { log.d("gap-report: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-report: timeout. make gap.") // タイムアウト // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } val path = path_base + delimiter + "max_id=" + max_id + "&since_id=" + since_id val r2 = client.request(path) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-report: error or cancelled. make gap.") if(result == null) result = r2 // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } result = r2 val src = parseList(::TootReport, jsonArray) if(src.isEmpty()) { log.d("gap-report: empty.") // コレ以上取得する必要はない break } addAll(list_tmp, src) // 隙間の最新のステータスIDは取得データ末尾のステータスIDである max_id = parseRange(result, src).first } } return result } fun getNotificationList(client : TootApiClient) : TootApiResult? { val path_base = makeNotificationUrl() val params = makeMisskeyBaseParameter(parser).addMisskeyNotificationFilter() val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' list_tmp = ArrayList() var result : TootApiResult? = null if(isMisskey) { var bHeadGap = false while(true) { if(isCancelled) { log.d("gap-notification: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-notification: timeout. make gap.") bHeadGap = true break } since_id?.putMisskeySince(params) val r2 = client.request(path_base, params.toPostRequestBuilder()) val jsonArray = r2?.jsonArray if(jsonArray == null) { // エラー log.d("gap-notification: error or response. make gap.") if(result == null) result = r2 // 隙間が残る bHeadGap = true break } result = r2 val src = parser.notificationList(jsonArray) if(src.isEmpty()) { log.d("gap-notification: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである since_id = parseRange(result, src).second addWithFilterNotification(list_tmp, src) PollingWorker.injectData(context, access_info, src) } if(isMisskey) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(bHeadGap) { addOneFirst(list_tmp, TootGap.mayNull(max_id, since_id)) } } else { while(true) { if(isCancelled) { log.d("gap-notification: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-notification: timeout. make gap.") // タイムアウト // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } val path = path_base + delimiter + "max_id=" + max_id + "&since_id=" + since_id val r2 = client.request(path) val jsonArray = r2?.jsonArray if(jsonArray == null) { // エラー log.d("gap-notification: error or response. make gap.") if(result == null) result = r2 // 隙間が残る addOne(list_tmp, TootGap.mayNull(max_id, since_id)) break } result = r2 val src = parser.notificationList(jsonArray) if(src.isEmpty()) { log.d("gap-notification: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである max_id = parseRange(result, src).first addWithFilterNotification(list_tmp, src) PollingWorker.injectData(context, access_info, src) } } return result } fun getStatusList( client : TootApiClient, path_base : String, misskeyParams : JSONObject? = null , misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parser.statusList(jsonArray) } ) : TootApiResult? { val isMisskey = access_info.isMisskey val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' list_tmp = ArrayList() var result : TootApiResult? = null if(isMisskey) { var bHeadGap = false while(true) { if(isCancelled) { log.d("gap-statuses: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-statuses: timeout.") bHeadGap = true break } since_id?.putMisskeySince(params) val r2 = client.request(path_base, params.toPostRequestBuilder()) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-statuses: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if(result == null) result = r2 bHeadGap = true break } // 成功した場合はそれを返したい result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("gap-statuses: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである since_id = parseRange(result, src).second addWithFilterStatus(list_tmp, src) } if(isMisskey) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(bHeadGap) { addOneFirst(list_tmp, TootGap.mayNull(max_id, since_id)) } } else { var bLastGap = false while(true) { if(isCancelled) { log.d("gap-statuses: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-statuses: timeout.") // タイムアウト bLastGap = true break } val path = "${path_base}${delimiter}max_id=${max_id}&since_id=${since_id}" val r2 = client.request(path) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-statuses: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if(result == null) result = r2 bLastGap = true break } // 成功した場合はそれを返したい result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("gap-statuses: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである max_id = parseRange(result, src).first addWithFilterStatus(list_tmp, src) } if(bLastGap) { addOne(list_tmp, TootGap.mayNull(max_id, since_id)) } } return result } fun getConversationSummaryList( client : TootApiClient, path_base : String, misskeyParams : JSONObject? = null , misskeyCustomParser : (parser : TootParser, jsonArray : JSONArray) -> ArrayList = { parser, jsonArray -> parseList(::TootConversationSummary, parser, jsonArray) } ) : TootApiResult? { val isMisskey = access_info.isMisskey val params = misskeyParams ?: makeMisskeyTimelineParameter(parser) val time_start = SystemClock.elapsedRealtime() val delimiter = if(- 1 != path_base.indexOf('?')) '&' else '?' list_tmp = ArrayList() var result : TootApiResult? = null if(isMisskey) { var bHeadGap = false while(true) { if(isCancelled) { log.d("gap-statuses: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-statuses: timeout.") bHeadGap = true break } since_id?.putMisskeySince(params) val r2 = client.request(path_base, params.toPostRequestBuilder()) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-statuses: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if(result == null) result = r2 bHeadGap = true break } // 成功した場合はそれを返したい result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("gap-statuses: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである since_id = parseRange(result, src).second addWithFilterConversationSummary(list_tmp, src) } if(isMisskey) { list_tmp?.sortBy { it.getOrderId() } list_tmp?.reverse() } if(bHeadGap) { addOneFirst(list_tmp, TootGap.mayNull(max_id, since_id)) } } else { var bLastGap = false while(true) { if(isCancelled) { log.d("gap-statuses: cancelled.") break } if(result != null && SystemClock.elapsedRealtime() - time_start > LOOP_TIMEOUT) { log.d("gap-statuses: timeout.") // タイムアウト bLastGap = true break } val path = "${path_base}${delimiter}max_id=${max_id}&since_id=${since_id}" val r2 = client.request(path) val jsonArray = r2?.jsonArray if(jsonArray == null) { log.d("gap-statuses: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if(result == null) result = r2 bLastGap = true break } // 成功した場合はそれを返したい result = r2 val src = misskeyCustomParser(parser, jsonArray) if(src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("gap-statuses: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである max_id = parseRange(result, src).first addWithFilterConversationSummary(list_tmp, src) } if(bLastGap) { addOne(list_tmp, TootGap.mayNull(max_id, since_id)) } } return result } override fun doInBackground(vararg unused : Void) : TootApiResult? { ctStarted.set(true) val client = TootApiClient(context, callback = object : TootApiCallback { override val isApiCancelled : Boolean get() = isCancelled || is_dispose.get() override fun publishApiProgress(s : String) { runOnMainLooper { if(isCancelled) return@runOnMainLooper task_progress = s fireShowContent(reason = "gap progress", changeList = ArrayList()) } } }) client.account = access_info try { return when(column_type) { TYPE_LOCAL -> getStatusList(client, makePublicLocalUrl()) TYPE_MISSKEY_HYBRID -> getStatusList(client, makeMisskeyHybridTlUrl()) TYPE_FEDERATE -> getStatusList(client, makePublicFederateUrl()) TYPE_LIST_TL -> if(isMisskey) { val params = makeMisskeyTimelineParameter(parser) .put("listId", profile_id) getStatusList(client, makeListTlUrl(), misskeyParams = params) } else { getStatusList(client, makeListTlUrl()) } TYPE_FAVOURITES -> if(isMisskey) { getStatusList( client, PATH_MISSKEY_FAVORITES , misskeyParams = makeMisskeyTimelineParameter(parser) , misskeyCustomParser = misskeyCustomParserFavorites ) } else { getStatusList(client, PATH_FAVOURITES) } TYPE_REPORTS -> getReportList(client, PATH_REPORTS) TYPE_NOTIFICATIONS -> getNotificationList(client) TYPE_HASHTAG -> if(isMisskey) { getStatusList( client , makeHashtagUrl(hashtag) , misskeyParams = makeMisskeyTimelineParameter(parser) .put("tag", hashtag) .put("limit", MISSKEY_HASHTAG_LIMIT) ) } else { getStatusList(client, makeHashtagUrl(hashtag)) } TYPE_BOOSTED_BY -> getAccountList( client, String.format(Locale.JAPAN, PATH_BOOSTED_BY, status_id) ) TYPE_FAVOURITED_BY -> getAccountList( client, String.format(Locale.JAPAN, PATH_FAVOURITED_BY, status_id) ) TYPE_MUTES -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_MUTES , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyArrayFinder = misskeyArrayFinderUsers ) } else { getAccountList(client, PATH_MUTES) } TYPE_BLOCKS -> if(isMisskey) { pagingType = PagingType.Default val params = access_info.putMisskeyApiToken(JSONObject()) getAccountList( client, "/api/blocking/list", misskeyParams = params, misskeyCustomParser = misskeyCustomParserBlocks ) } else { getAccountList(client, PATH_BLOCKS) } TYPE_FOLLOW_REQUESTS -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_FOLLOW_REQUESTS , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) , misskeyCustomParser = misskeyCustomParserFollowRequest ) } else { getAccountList( client , PATH_FOLLOW_REQUESTS ) } TYPE_FOLLOW_SUGGESTION -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_FOLLOW_SUGGESTION , misskeyParams = access_info.putMisskeyApiToken(JSONObject()) ) } else { getAccountList( client , PATH_FOLLOW_SUGGESTION ) } TYPE_ENDORSEMENT -> getAccountList(client, PATH_ENDORSEMENT) TYPE_PROFILE -> when(profile_tab) { TAB_FOLLOWING -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_PROFILE_FOLLOWING , misskeyParams = makeMisskeyParamsUserId(parser) ) } else { getAccountList( client, String.format(Locale.JAPAN, PATH_ACCOUNT_FOLLOWING, profile_id) ) } TAB_FOLLOWERS -> if(isMisskey) { getAccountList( client , PATH_MISSKEY_PROFILE_FOLLOWERS , misskeyParams = makeMisskeyParamsUserId(parser) ) } else { getAccountList( client, String.format(Locale.JAPAN, PATH_ACCOUNT_FOLLOWERS, profile_id) ) } else -> if(isMisskey) { getStatusList( client , PATH_MISSKEY_PROFILE_STATUSES , misskeyParams = makeMisskeyParamsProfileStatuses(parser) ) } else { if(access_info.isPseudo) { client.request(PATH_INSTANCE) } else { getStatusList(client, makeProfileStatusesUrl(profile_id)) } } } TYPE_DIRECT_MESSAGES -> if(useConversationSummarys) { // try 2.6.0 new API https://github.com/tootsuite/mastodon/pull/8832 getConversationSummaryList(client, PATH_DIRECT_MESSAGES2) } else { // fallback to old api getStatusList(client, PATH_DIRECT_MESSAGES) } else -> getStatusList(client, makeHomeTlUrl()) } } finally { try { updateRelation(client, list_tmp, who_account, parser) } catch(ex : Throwable) { log.trace(ex) } ctClosed.set(true) runOnMainLooperDelayed(333L) { if(! isCancelled) fireShowColumnStatus() } } } override fun onCancelled(result : TootApiResult?) { onPostExecute(null) } override fun onPostExecute(result : TootApiResult?) { if(is_dispose.get()) return if(isCancelled || result == null) { return } try { lastTask = null bRefreshLoading = false val error = result.error if(error != null) { mRefreshLoadingError = error fireShowContent(reason = "gap error", changeList = ArrayList()) return } val list_tmp = this.list_tmp if(list_tmp == null) { fireShowContent(reason = "gap list_tmp is null", changeList = ArrayList()) return } val list_new = duplicate_map.filterDuplicate(list_tmp) // 0個でもギャップを消すために以下の処理を続ける val changeList = ArrayList() replaceConversationSummary(changeList, list_new, list_data) val added = list_new.size // may 0 val position = list_data.indexOf(gap) if(position == - 1) { log.d("gap not found..") fireShowContent(reason = "gap not found", changeList = ArrayList()) return } // idx番目の要素がListViewのtopから何ピクセル下にあるか var restore_idx = position + 1 var restore_y = 0 val holder = viewHolder if(holder != null) { try { restore_y = holder.getListItemOffset(restore_idx) } catch(ex : IndexOutOfBoundsException) { restore_idx = position try { restore_y = holder.getListItemOffset(restore_idx) } catch(ex2 : IndexOutOfBoundsException) { restore_idx = - 1 } } } list_data.removeAt(position) list_data.addAll(position, list_new) changeList.add(AdapterChange(AdapterChangeType.RangeRemove, position)) if(added > 0) { changeList.add( AdapterChange( AdapterChangeType.RangeInsert, position, added ) ) } fireShowContent(reason = "gap updated", changeList = changeList) if(holder != null) { if(restore_idx >= 0) { // ギャップが画面内にあるなら holder.setListItemTop(restore_idx + added - 1, restore_y) } else { // ギャップが画面内にない場合、何もしない } } else { val scroll_save = this@Column.scroll_save if(scroll_save != null) { scroll_save.adapterIndex += added - 1 } } updateMisskeyCapture() } finally { fireShowColumnStatus() } } } this.lastTask = task task.executeOnExecutor(App1.task_executor) fireShowColumnStatus() } enum class HeaderType(val viewType : Int) { Profile(1), Search(2), Instance(3), Filter(4), } val headerType : HeaderType? get() = when(column_type) { Column.TYPE_PROFILE -> HeaderType.Profile Column.TYPE_SEARCH -> HeaderType.Search Column.TYPE_SEARCH_MSP -> HeaderType.Search Column.TYPE_SEARCH_TS -> HeaderType.Search Column.TYPE_INSTANCE_INFORMATION -> HeaderType.Instance Column.TYPE_KEYWORD_FILTER -> HeaderType.Filter else -> null } fun toAdapterIndex(listIndex : Int) : Int { return if(headerType != null) listIndex + 1 else listIndex } fun toListIndex(adapterIndex : Int) : Int { return if(headerType != null) adapterIndex - 1 else adapterIndex } private fun loadSearchDesc(raw_en : Int, raw_ja : Int) : String { val res_id = if("ja" == context.getString(R.string.language_code)) raw_ja else raw_en return context.loadRawResource(res_id).decodeUTF8() } private var cacheHeaderDesc : String? = null fun getHeaderDesc() : String? { var cache = cacheHeaderDesc if(cache != null) return cache cache = when(column_type) { Column.TYPE_SEARCH -> context.getString(R.string.search_desc_mastodon_api) Column.TYPE_SEARCH_MSP -> loadSearchDesc( R.raw.search_desc_msp_en, R.raw.search_desc_msp_ja ) Column.TYPE_SEARCH_TS -> loadSearchDesc( R.raw.search_desc_ts_en, R.raw.search_desc_ts_ja ) else -> "" } cacheHeaderDesc = cache return cache } //////////////////////////////////////////////////////////////////////// // Streaming internal fun onStart(callback : Callback) { this.callback_ref = WeakReference(callback) // 破棄されたカラムなら何もしない if(is_dispose.get()) { log.d("onStart: column was disposed.") return } // 未初期化なら何もしない if(! bFirstInitialized) { log.d("onStart: column is not initialized.") return } // 初期ロード中なら何もしない if(bInitialLoading) { log.d("onStart: column is in initial loading.") return } // フィルタ一覧のリロードが必要 if(filter_reload_required) { filter_reload_required = false startLoading() return } // 始端リフレッシュの最中だった // リフレッシュ終了時に自動でストリーミング開始するはず if(bRefreshingTop) { log.d("onStart: bRefreshingTop is true.") return } if(! bRefreshLoading && canAutoRefresh() && ! Pref.bpDontRefreshOnResume(App1.getAppState(context).pref) && ! dont_auto_refresh ) { // リフレッシュしてからストリーミング開始 log.d("onStart: start auto refresh.") startRefresh(true, false) } else if(isSearchColumn) { // 検索カラムはリフレッシュもストリーミングもないが、表示開始のタイミングでリストの再描画を行いたい fireShowContent(reason = "Column onStart isSearchColumn", reset = true) } else { // ギャップつきでストリーミング開始 log.d("onStart: start streaming with gap.") resumeStreaming(true) } } // カラム設定に正規表現フィルタを含めるなら真 fun canStatusFilter() : Boolean { if(getFilterContext() != TootFilter.CONTEXT_NONE) return true return when(column_type) { TYPE_SEARCH_MSP, TYPE_SEARCH_TS -> true else -> false } } // マストドン2.4.3rcのキーワードフィルタのコンテキスト private fun getFilterContext() = when(column_type) { TYPE_HOME, TYPE_LIST_TL, TYPE_MISSKEY_HYBRID -> TootFilter.CONTEXT_HOME TYPE_NOTIFICATIONS -> TootFilter.CONTEXT_NOTIFICATIONS TYPE_CONVERSATION -> TootFilter.CONTEXT_THREAD TYPE_LOCAL, TYPE_FEDERATE, TYPE_HASHTAG, TYPE_PROFILE, TYPE_SEARCH -> TootFilter.CONTEXT_PUBLIC TYPE_DIRECT_MESSAGES -> TootFilter.CONTEXT_PUBLIC else -> TootFilter.CONTEXT_NONE // TYPE_MISSKEY_HYBRID はHOMEでもPUBLICでもある… Misskeyだし関係ないが、NONEにするとアプリ内で完結するフィルタも働かなくなる } // カラム設定に「すべての画像を隠す」ボタンを含めるなら真 internal fun canNSFWDefault() : Boolean { return canStatusFilter() } // カラム設定に「ブーストを表示しない」ボタンを含めるなら真 fun canFilterBoost() : Boolean { return when(column_type) { TYPE_HOME, TYPE_MISSKEY_HYBRID, TYPE_PROFILE, TYPE_NOTIFICATIONS, TYPE_LIST_TL -> true TYPE_LOCAL, TYPE_FEDERATE, TYPE_HASHTAG, TYPE_SEARCH -> isMisskey TYPE_CONVERSATION, TYPE_DIRECT_MESSAGES -> isMisskey else -> false } } // カラム設定に「返信を表示しない」ボタンを含めるなら真 fun canFilterReply() : Boolean { return when(column_type) { TYPE_HOME, TYPE_MISSKEY_HYBRID, TYPE_PROFILE, TYPE_NOTIFICATIONS, TYPE_LIST_TL, TYPE_DIRECT_MESSAGES -> true TYPE_LOCAL, TYPE_FEDERATE, TYPE_HASHTAG, TYPE_SEARCH -> isMisskey else -> false } } fun canFilterNormalToot() : Boolean { return when(column_type) { TYPE_HOME, TYPE_MISSKEY_HYBRID, TYPE_LIST_TL -> true TYPE_LOCAL, TYPE_FEDERATE, TYPE_HASHTAG, TYPE_SEARCH -> isMisskey else -> false } } internal fun canAutoRefresh() : Boolean { return streamPath != null } fun canReloadWhenRefreshTop() : Boolean { return when(column_type) { TYPE_KEYWORD_FILTER, TYPE_SEARCH, TYPE_SEARCH_MSP, TYPE_SEARCH_TS, TYPE_CONVERSATION, TYPE_LIST_LIST, TYPE_TREND_TAG, TYPE_SCHEDULED_STATUS, TYPE_FOLLOW_SUGGESTION -> true TYPE_LIST_MEMBER, TYPE_MUTES, TYPE_FOLLOW_REQUESTS -> isMisskey else -> false } } // カラム操作的にリフレッシュを許容するかどうか fun canRefreshTopBySwipe() : Boolean { return canReloadWhenRefreshTop() || when(column_type) { TYPE_CONVERSATION, TYPE_INSTANCE_INFORMATION -> false else -> true } } // カラム操作的にリフレッシュを許容するかどうか fun canRefreshBottomBySwipe() : Boolean { return when(column_type) { TYPE_LIST_LIST, TYPE_CONVERSATION, TYPE_INSTANCE_INFORMATION, TYPE_KEYWORD_FILTER, TYPE_SEARCH, TYPE_TREND_TAG, TYPE_FOLLOW_SUGGESTION -> false TYPE_FOLLOW_REQUESTS -> isMisskey TYPE_LIST_MEMBER -> ! isMisskey else -> true } } // データ的にリフレッシュを許容するかどうか private fun canRefreshTop() : Boolean { return when(pagingType) { PagingType.Default -> idRecent != null else -> false } } // データ的にリフレッシュを許容するかどうか private fun canRefreshBottom() : Boolean { return when(pagingType) { PagingType.Default, PagingType.Cursor -> idOld != null PagingType.None -> false PagingType.Offset -> true } } internal fun canSpeech() : Boolean { return canStreaming() && column_type != TYPE_NOTIFICATIONS } internal fun canStreaming() = when { access_info.isNA -> false access_info.isMisskey -> streamPath != null access_info.isPseudo -> isPublicStream else -> streamPath != null } private val streamCallback = object : StreamReader.StreamCallback { override fun onListeningStateChanged() { if(is_dispose.get()) return runOnMainLooper { when { is_dispose.get() -> { } else -> { fireShowColumnStatus() updateMisskeyCapture() } } } } override fun onTimelineItem(item : TimelineItem) { if(is_dispose.get()) return if(item is TootConversationSummary) { if(column_type != TYPE_DIRECT_MESSAGES) return if(isFiltered(item.last_status)) return if(use_old_api) { useConversationSummaryStreaming = false return } else { useConversationSummaryStreaming = true } } 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 // マストドン2.6.0形式のDMカラム用イベントを利用したならば、その直後に発生する普通の投稿イベントを無視する if(useConversationSummaryStreaming) return if(column_type == TYPE_LOCAL && ! isMisskey && item.account.acct.indexOf('@') != - 1) return if(isFiltered(item)) return } stream_data_queue.add(item) val handler = App1.getAppState(context).handler handler.post(mergeStreamingMessage) } override fun onNoteUpdated(ev : MisskeyNoteUpdate) { // userId が自分かどうか調べる // アクセストークンの更新をして自分のuserIdが分かる状態でないとキャプチャ結果を反映させない // (でないとリアクションの2重カウントなどが発生してしまう) val myId = EntityId.from(access_info.token_info, TootApiClient.KEY_USER_ID) if(myId == null) { log.w("onNoteUpdated: missing my userId. updating access token is recommenced!!") return } val byMe = myId == ev.userId runOnMainLooper { if(is_dispose.get()) return@runOnMainLooper val changeList = ArrayList() fun scanStatus1(s : TootStatus?, idx : Int, block : (s : TootStatus) -> Boolean) { s ?: return if(s.id == ev.noteId) { if(block(s)) { changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx, 1)) } } scanStatus1(s.reblog, idx, block) scanStatus1(s.reply, idx, block) } fun scanStatusAll(block : (s : TootStatus) -> Boolean) { for(i in 0 until list_data.size) { val o = list_data[i] if(o is TootStatus) { scanStatus1(o, i, block) } else if(o is TootNotification) { scanStatus1(o.status, i, block) } } } when(ev.type) { MisskeyNoteUpdate.Type.REACTION -> { scanStatusAll { s -> s.increaseReaction(ev.reaction, byMe, "onNoteUpdated ${ev.userId}") } } MisskeyNoteUpdate.Type.UNREACTION -> { scanStatusAll { s -> s.decreaseReaction(ev.reaction, byMe, "onNoteUpdated ${ev.userId}") } } MisskeyNoteUpdate.Type.VOTED -> { scanStatusAll { s -> s.enquete?.increaseVote(context, ev.choice, byMe) ?: false } } MisskeyNoteUpdate.Type.DELETED -> { scanStatusAll { s -> s.markDeleted(context, ev.deletedAt) ?: false } } } if(changeList.isNotEmpty()) { fireShowContent(reason = "onNoteUpdated", changeList = changeList) } } } } private fun resumeStreaming(bPutGap : Boolean) { // カラム種別によってはストリーミングAPIを利用できない val stream_path = streamPath ?: return // 疑似アカウントではストリーミングAPIを利用できない // 2.1 では公開ストリームのみ利用できるらしい if(access_info.isNA || access_info.isPseudo && ! isPublicStream) { return } if(! isActivityStart) { log.d("resumeStreaming: isActivityStart is false.") return } // 破棄されたカラムなら何もしない if(is_dispose.get()) { log.d("resumeStreaming: column was disposed.") return } // 未初期化なら何もしない if(! bFirstInitialized) { log.d("resumeStreaming: column is not initialized.") return } // 初期ロード中なら何もしない if(bInitialLoading) { log.d("resumeStreaming: is in initial loading.") return } if(Pref.bpDontUseStreaming(context)) { log.d("resumeStreaming: disabled in app setting.") return } if(dont_streaming) { log.d("resumeStreaming: disabled in column setting.") return } this.bPutGap = bPutGap stream_data_queue.clear() streamReader = app_state.stream_reader.register( access_info, stream_path, highlight_trie, streamCallback ) fireShowColumnStatus() } private var streamReader : StreamReader.Reader? = null // onPauseの時はまとめて止められるが // カラム破棄やリロード開始時は個別にストリーミングを止める必要がある internal fun stopStreaming() { streamReader = null val stream_path = streamPath if(stream_path != null) { app_state.stream_reader.unregister(access_info, stream_path, streamCallback) fireShowColumnStatus() } } fun getStreamingStatus() : StreamingIndicatorState { if(is_dispose.get() || ! bFirstInitialized) return StreamingIndicatorState.NONE val stream_path = streamPath ?: return StreamingIndicatorState.NONE return app_state.stream_reader.getStreamingStatus(access_info, stream_path, streamCallback) } private val mergeStreamingMessage : Runnable = object : Runnable { override fun run() { // 前回マージしてから暫くは待機する val handler = App1.getAppState(context).handler val now = SystemClock.elapsedRealtime() val remain = last_show_stream_data.get() + 333L - now if(remain > 0) { handler.removeCallbacks(this) handler.postDelayed(this, remain) return } last_show_stream_data.set(now) val tmpList = ArrayList() while(stream_data_queue.isNotEmpty()) { tmpList.add(stream_data_queue.poll()) } if(tmpList.isEmpty()) return // キューから読めた件数が0の場合を除き、少し後に再処理させることでマージ漏れを防ぐ handler.postDelayed(this, 333L) tmpList.sortBy { it.getOrderId() } tmpList.reverse() val list_new = duplicate_map.filterDuplicate(tmpList) if(list_new.isEmpty()) return for(item in list_new) { if(enable_speech && item is TootStatus) { App1.getAppState(context).addSpeech(item.reblog ?: item) } } // 通知カラムならストリーミング経由で届いたデータを通知ワーカーに伝達する 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, list) } } // 最新のIDをsince_idとして覚える(ソートはしない) var new_id_max : EntityId? = null var new_id_min : EntityId? = null for(o in list_new) { try { val id = o.getOrderId() if(id.toString().isEmpty()) continue if(new_id_max == null || id > new_id_max) new_id_max = id if(new_id_min == null || id < new_id_min) new_id_min = id } catch(ex : Throwable) { // IDを取得できないタイプのオブジェクトだった // ストリームに来るのは通知かステータスだから、多分ここは通らない log.trace(ex) } } val tmpRecent = idRecent val tmpNewMax = new_id_max if(tmpNewMax is EntityIdString && ! isMisskey) { log.e("EntityId should be Long for non-misskey column! columnType=$column_type") } else if(tmpRecent is EntityIdString && tmpNewMax is EntityIdLong) { log.e("EntityId type mismatch! recent=String,newMax=Long,columnType=$column_type") } else if(tmpRecent is EntityIdLong && tmpNewMax is EntityIdString) { log.e("EntityId type mismatch! recent=Long,newMax=String,columnType=$column_type") } else if(tmpNewMax != null && (tmpRecent?.compareTo(tmpNewMax) ?: - 1) == - 1) { idRecent = tmpNewMax // XXX: コレはリフレッシュ時に取得漏れを引き起こすのでは…? // しかしコレなしだとリフレッシュ時に大量に読むことになる… } val holder = viewHolder // 事前にスクロール位置を覚えておく val holder_sp : ScrollPosition? = holder?.scrollPosition // idx番目の要素がListViewの上端から何ピクセル下にあるか var restore_idx = - 2 var restore_y = 0 if(holder != null) { if(list_data.size > 0) { try { restore_idx = holder.findFirstVisibleListItem() restore_y = holder.getListItemOffset(restore_idx) } catch(ex : IndexOutOfBoundsException) { restore_idx = - 2 restore_y = 0 } } } // 画面復帰時の自動リフレッシュではギャップが残る可能性がある if(bPutGap) { bPutGap = false try { if(list_data.size > 0 && new_id_min != null) { val since = list_data[0].getOrderId() if(new_id_min > since) { val gap = TootGap(new_id_min, since) list_new.add(gap) } } } catch(ex : Throwable) { log.e(ex, "can't put gap.") } } val changeList = ArrayList() replaceConversationSummary(changeList, list_new, list_data) val added = list_new.size // may 0 loop@ for(o in list_new) { when(o) { is TootStatus -> { val highlight_sound = o.highlight_sound if(highlight_sound != null) { App1.sound(highlight_sound) break@loop } } } } changeList.add(AdapterChange(AdapterChangeType.RangeInsert, 0, added)) list_data.addAll(0, list_new) fireShowContent(reason = "mergeStreamingMessage", changeList = changeList) if(holder != null) { when { holder_sp == null -> { // スクロール位置が先頭なら先頭にする log.d("mergeStreamingMessage: has VH. missing scroll position.") viewHolder?.scrollToTop() } holder_sp.isHead -> { // スクロール位置が先頭なら先頭にする log.d("mergeStreamingMessage: has VH. keep head. $holder_sp") holder.setScrollPosition(ScrollPosition()) } restore_idx < - 1 -> { // 可視範囲の検出に失敗 log.d("mergeStreamingMessage: has VH. can't get visible range.") } else -> { // 現在の要素が表示され続けるようにしたい log.d("mergeStreamingMessage: has VH. added=$added") holder.setListItemTop(restore_idx + added, restore_y) } } } else { val scroll_save = this@Column.scroll_save if(scroll_save == null || scroll_save.isHead) { // スクロール位置が先頭なら先頭のまま } else { // 現在の要素が表示され続けるようにしたい scroll_save.adapterIndex += added } } updateMisskeyCapture() } } private fun min(a : Int, b : Int) : Int = if(a < b) a else b private fun updateMisskeyCapture() { if(! isMisskey) return streamReader ?: return val max = 40 val list = ArrayList(max * 2) // リブログなどで膨れる場合がある fun add(s : TootStatus?) { s ?: return list.add(s.id) add(s.reblog) add(s.reply) } for(i in 0 until min(max, list_data.size)) { val o = list_data[i] if(o is TootStatus) { add(o) } else if(o is TootNotification) { add(o.status) } } if(list.isNotEmpty()) streamReader?.capture(list) } private fun replaceConversationSummary( changeList : ArrayList, list_new : ArrayList, list_data : BucketList ) { val newMap = HashMap() for(o in list_new) { if(o is TootConversationSummary) newMap[o.id] = o } if(list_data.isEmpty() || newMap.isEmpty()) return val removeSet = HashSet() for(i in list_data.size - 1 downTo 0) { val o = list_data[i] as? TootConversationSummary ?: continue val newItem = newMap[o.id] ?: continue if(o.last_status.uri == newItem.last_status.uri) { // 投稿が同じなので順序を入れ替えず、その場所で更新する changeList.add(AdapterChange(AdapterChangeType.RangeChange, i, 1)) list_data[i] = newItem removeSet.add(newItem.id) log.d("replaceConversationSummary: in-place update") } else { // 投稿が異なるので古い方を削除して、リストの順序を変える changeList.add(AdapterChange(AdapterChangeType.RangeRemove, i, 1)) list_data.removeAt(i) log.d("replaceConversationSummary: order change") } } val it = list_new.iterator() while(it.hasNext()) { val o = it.next() as? TootConversationSummary ?: continue if(removeSet.contains(o.id)) it.remove() } } private fun makeMisskeyBaseParameter(parser : TootParser?) : JSONObject = access_info .putMisskeyApiToken(JSONObject()) .apply { if(access_info.isMisskey) { if(parser != null) parser.serviceType = ServiceType.MISSKEY put("limit", 40) } } private fun JSONObject.putMisskeyParamsTimeline() : JSONObject { if(with_attachment && ! with_highlight) { put("mediaOnly", true) put("withMedia", true) put("withFiles", true) put("media", true) } return this } private fun makeMisskeyParamsUserId(parser : TootParser) : JSONObject = makeMisskeyBaseParameter(parser).put("userId", profile_id.toString()) private fun makeMisskeyTimelineParameter(parser : TootParser) = makeMisskeyBaseParameter(parser).putMisskeyParamsTimeline() private fun makeMisskeyParamsProfileStatuses(parser : TootParser) = makeMisskeyParamsUserId(parser).apply { putMisskeyParamsTimeline() if(! dont_show_reply) put("includeReplies", true) if(! dont_show_boost) put("includeMyRenotes", true) } private fun makePublicLocalUrl() : String { return when { access_info.isMisskey -> "/api/notes/local-timeline" with_attachment -> "$PATH_LOCAL&only_media=true" // mastodon 2.3 or later else -> PATH_LOCAL } } private fun makeMisskeyHybridTlUrl() : String { return when { access_info.isMisskey -> "/api/notes/hybrid-timeline" with_attachment -> "$PATH_LOCAL&only_media=true" // mastodon 2.3 or later else -> PATH_LOCAL } } private fun makePublicFederateUrl() : String { return when { access_info.isMisskey -> "/api/notes/global-timeline" with_attachment -> "$PATH_TL_FEDERATE&only_media=true" else -> PATH_TL_FEDERATE } } private fun makeHomeTlUrl() : String { return when { access_info.isMisskey -> "/api/notes/timeline" with_attachment -> "$PATH_HOME&only_media=true" else -> PATH_HOME } } private fun makeNotificationUrl() : String { return when { access_info.isMisskey -> "/api/i/notifications" else -> { val sb = StringBuilder(PATH_NOTIFICATIONS) // always contain "?limit=XX" when(quick_filter) { QUICK_FILTER_ALL -> { if(dont_show_favourite) sb.append("&exclude_types[]=favourite") if(dont_show_boost) sb.append("&exclude_types[]=reblog") if(dont_show_follow) sb.append("&exclude_types[]=follow") if(dont_show_reply) sb.append("&exclude_types[]=mention") } else -> { if(quick_filter != QUICK_FILTER_FAVOURITE) sb.append("&exclude_types[]=favourite") if(quick_filter != QUICK_FILTER_BOOST) sb.append("&exclude_types[]=reblog") if(quick_filter != QUICK_FILTER_FOLLOW) sb.append("&exclude_types[]=follow") if(quick_filter != QUICK_FILTER_MENTION) sb.append("&exclude_types[]=mention") } } // reaction,voteはmastodonにはない sb.toString() } } } private fun JSONObject.addMisskeyNotificationFilter() : JSONObject { when(quick_filter) { QUICK_FILTER_ALL -> { val excludeList = JSONArray() // Misskeyのお気に入りは通知されない // if(dont_show_favourite) ... if(dont_show_boost) { excludeList.put("renote") excludeList.put("quote") } if(dont_show_follow) { excludeList.put("follow") excludeList.put("receiveFollowRequest") } if(dont_show_reply) { excludeList.put("mention") excludeList.put("reply") } if(dont_show_reaction) { excludeList.put("reaction") } if(dont_show_vote) { excludeList.put("poll_vote") } if(excludeList.length() > 0) put("excludeTypes", excludeList) } // QUICK_FILTER_FAVOURITE // misskeyはお気に入りの通知はない QUICK_FILTER_BOOST -> put("includeTypes", jsonArray("renote", "quote")) QUICK_FILTER_FOLLOW -> put("includeTypes", jsonArray("follow", "receiveFollowRequest")) QUICK_FILTER_MENTION -> put("includeTypes", jsonArray("mention", "reply")) QUICK_FILTER_REACTION -> put("includeTypes", jsonArray("reaction")) QUICK_FILTER_VOTE -> put("includeTypes", jsonArray("poll_vote")) } return this } private fun makeListTlUrl() : String { return if(isMisskey) { "/api/notes/user-list-timeline" } else { String.format(Locale.JAPAN, PATH_LIST_TL, profile_id) } } private fun makeHashtagUrl( hashtag : String // 先頭の#を含まない ) : String { return if(isMisskey) { "/api/notes/search_by_tag" } else { val sb = StringBuilder("/api/v1/timelines/tag/") .append(hashtag.encodePercent()) .append("?limit=") .append(READ_LIMIT) if(with_attachment) sb.append("&only_media=true") if(instance_local) sb.append("&local=true") sb.toString() } } private fun loadFilter2(client : TootApiClient) : ArrayList? { if(access_info.isPseudo || access_info.isMisskey) return null val column_context = getFilterContext() if(column_context == 0) return null val result = client.request(PATH_FILTERS) val jsonArray = result?.jsonArray ?: return null return TootFilter.parseList(jsonArray) } private fun encodeFilterTree(filterList : ArrayList?) : WordTrieTree? { val column_context = getFilterContext() if(column_context == 0 || filterList == null) return null val tree = WordTrieTree() for(filter in filterList) { if((filter.context and column_context) != 0) { tree.add( filter.phrase, validator = when(filter.whole_word) { true -> WordTrieTree.WORD_VALIDATOR else -> WordTrieTree.EMPTY_VALIDATOR } ) } } return tree } private fun checkFiltersForListData(tree : WordTrieTree?) { tree ?: return val changeList = ArrayList() list_data.forEachIndexed { idx, item -> when(item) { is TootStatus -> { val old_filtered = item.filtered item.updateFiltered(tree) if(old_filtered != item.filtered) { changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx)) } } is TootNotification -> { val s = item.status if(s != null) { val old_filtered = s.filtered s.updateFiltered(tree) if(old_filtered != s.filtered) { changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx)) } } } } } fireShowContent(reason = "filter updated", changeList = changeList) } private fun onFiltersChanged2(filterList : ArrayList) { val newFilter = encodeFilterTree(filterList) ?: return this.muted_word2 = newFilter checkFiltersForListData(newFilter) } fun onFilterDeleted(filter : TootFilter, filterList : ArrayList) { if(column_type == TYPE_KEYWORD_FILTER) { val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o is TootFilter) { if(o.id == filter.id) continue } tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "onFilterDeleted") } } else { val context = getFilterContext() if(context != TootFilter.CONTEXT_NONE) { onFiltersChanged2(filterList) } } } fun onScheduleDeleted(item : TootScheduled) { val tmp_list = ArrayList(list_data.size) for(o in list_data) { if(o === item) continue tmp_list.add(o) } if(tmp_list.size != list_data.size) { list_data.clear() list_data.addAll(tmp_list) fireShowContent(reason = "onScheduleDeleted") } } val isMisskey : Boolean = access_info.isMisskey fun saveScrollPosition() { try { if(viewHolder?.saveScrollPosition() == true) { val ss = this.scroll_save if(ss != null) { val idx = toListIndex(ss.adapterIndex) if(0 <= idx && idx < list_data.size) { val item = list_data[idx] this.last_viewing_item_id = item.getOrderId() // とりあえず保存はするが // TLデータそのものを永続化しないかぎり出番はないっぽい } } } } catch(ex : Throwable) { log.e(ex, "can't get last_viewing_item_id.") } } fun getContentColor() : Int = when { content_color != 0 -> content_color else -> defaultColorContentText } fun getAcctColor() : Int = when { acct_color != 0 -> acct_color else -> defaultColorContentAcct } fun getHeaderPageNumberColor() = when { header_fg_color != 0 -> header_fg_color else -> defaultColorHeaderPageNumber } fun getHeaderNameColor() = when { header_fg_color != 0 -> header_fg_color else -> defaultColorHeaderName } fun getHeaderBackgroundColor() = when { header_bg_color != 0 -> header_bg_color else -> defaultColorHeaderBg } fun setHeaderBackground(view : View) { ViewCompat.setBackground( view, getAdaptiveRippleDrawable( getHeaderBackgroundColor(), getHeaderNameColor() ) ) } // fun findListIndexByTimelineId(orderId : EntityId) : Int? { // list_data.forEachIndexed { i, v -> // if(v.getOrderId() == orderId) return i // } // return null // } init { registerColumnId(column_id, this) } }