package jp.juggler.subwaytooter import android.os.SystemClock import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.notification.PollingWorker import jp.juggler.util.* import java.lang.StringBuilder @Suppress("ClassNaming") class ColumnTask_Gap( columnArg: Column, private val gap: TimelineItem, private val isHead: Boolean, ) : ColumnTask(columnArg, ColumnTaskType.GAP) { companion object { private val log = LogCategory("CT_Gap") private val reIToken = """"i":"[^"]+"""".toRegex() private fun String.removeIToken() = reIToken.replace(this, """"i":"**"""") } private var maxId: EntityId? = (gap as? TootGap)?.maxId private var sinceId: EntityId? = (gap as? TootGap)?.sinceId override suspend fun background(): TootApiResult? { ctStarted.set(true) val client = TootApiClient(context, callback = object : TootApiCallback { override val isApiCancelled: Boolean get() = isCancelled || column.isDispose.get() override suspend fun publishApiProgress(s: String) { runOnMainLooper { if (isCancelled) return@runOnMainLooper column.taskProgress = s column.fireShowContent(reason = "gap progress", changeList = ArrayList()) } } }) client.account = accessInfo try { return column.type.gap(this, client) } catch (ex: Throwable) { return TootApiResult(ex.withCaption("gap loading failed.")) } finally { try { column.updateRelation(client, listTmp, column.whoAccount, parser) } catch (ex: Throwable) { log.trace(ex) } ctClosed.set(true) runOnMainLooperDelayed(333L) { if (!isCancelled) column.fireShowColumnStatus() } } } override suspend fun handleResult(result: TootApiResult?) { if (column.isDispose.get()) return if (isCancelled || result == null) { return } try { column.lastTask = null column.bRefreshLoading = false val error = result.error if (error != null) { column.mRefreshLoadingError = error column.fireShowContent(reason = "gap error", changeList = ArrayList()) return } val listTmp = this.listTmp if (listTmp == null) { column.fireShowContent(reason = "gap list_tmp is null", changeList = ArrayList()) return } val listNew = when (column.type) { // 検索カラムはIDによる重複排除が不可能 ColumnType.SEARCH -> listTmp // 他のカラムは重複排除してから追加 else -> column.duplicateMap.filterDuplicate(listTmp) } // 0個でもギャップを消すために以下の処理を続ける val changeList = ArrayList() replaceConversationSummary(changeList, listNew, column.listData) val added = listNew.size // may 0 val position = column.listData.indexOf(gap) if (position == -1) { log.d("gap not found..") column.fireShowContent(reason = "gap not found", changeList = ArrayList()) return } val iv = when { isHead -> PrefI.ipGapHeadScrollPosition else -> PrefI.ipGapTailScrollPosition }.invoke(pref) val scrollHead = iv == PrefI.GSP_HEAD if (scrollHead) { // ギャップを頭から読んだ場合、スクロール位置の調整は不要 column.listData.removeAt(position) column.listData.addAll(position, listNew) changeList.add(AdapterChange(AdapterChangeType.RangeRemove, position)) if (added > 0) { changeList.add( AdapterChange( AdapterChangeType.RangeInsert, position, added ) ) } column.fireShowContent(reason = "gap updated", changeList = changeList) } else { // ギャップを下から読んだ場合、ギャップの次の要素が画面内で同じ位置になるようスクロール位置を調整する必要がある // idx番目の要素がListViewのtopから何ピクセル下にあるか var restoreIdx = position + 1 var restoreY = 0 val holder = column.viewHolder if (holder != null) { try { restoreY = holder.getListItemOffset(restoreIdx) } catch (ex: IndexOutOfBoundsException) { Column.log.w(ex, "getListItemOffset failed.") restoreIdx = position try { restoreY = holder.getListItemOffset(restoreIdx) } catch (ex2: IndexOutOfBoundsException) { Column.log.w(ex2, "getListItemOffset failed.") restoreIdx = -1 } } } column.listData.removeAt(position) column.listData.addAll(position, listNew) changeList.add(AdapterChange(AdapterChangeType.RangeRemove, position)) if (added > 0) { changeList.add( AdapterChange( AdapterChangeType.RangeInsert, position, added ) ) } column.fireShowContent(reason = "gap updated", changeList = changeList) when { // ViewHolderがない holder == null -> { val scrollSave = column.scrollSave if (scrollSave != null) { scrollSave.adapterIndex += added - 1 } } // ギャップが画面内にあるなら restoreIdx >= 0 -> holder.setListItemTop(restoreIdx + added - 1, restoreY) // ギャップが画面内にない場合、何もしない else -> { } } } column.updateMisskeyCapture() } finally { column.fireShowColumnStatus() } } private fun allRangeChecked(logCaption: String): Boolean { val tmpMaxId = maxId val tmpMinId = sinceId if (tmpMaxId != null && tmpMinId != null && tmpMinId >= tmpMaxId) { log.d("$logCaption: allRangeChecked. $tmpMinId >= $tmpMaxId") return true } return false } // max_id を指定してギャップの上から読む private suspend fun readGapHeadMisskey( logCaption: String, client: TootApiClient, pathBase: String, paramsCreator: (EntityId?) -> JsonObject, arrayFinder: (JsonObject) -> JsonArray? = { null }, listParser: (TootParser, JsonArray) -> List, adder: (List) -> Unit, ): TootApiResult? { listTmp = ArrayList() val timeStart = SystemClock.elapsedRealtime() var result: TootApiResult? = null var bAddGap = false val olderLimit = sinceId while (true) { if (isCancelled) { log.d("$logCaption: cancelled.") break } if (result != null && SystemClock.elapsedRealtime() - timeStart > Column.LOOP_TIMEOUT) { log.d("$logCaption: timeout.") bAddGap = true break } if (allRangeChecked(logCaption)) break val params = paramsCreator(maxId) log.d("$logCaption: $pathBase ${params.toString().removeIToken()}") val r2 = client.request( pathBase, params.toPostRequestBuilder() ) r2?.jsonObject?.let { r2.data = arrayFinder(it) } val jsonArray = r2?.jsonArray if (jsonArray == null) { log.d("$logCaption: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if (result == null) result = r2 bAddGap = true break } // 成功した場合はそれを返したい result = r2 var src = listParser(parser, jsonArray) if (olderLimit != null) { src = src.filter { it.isInjected() || it.getOrderId() > olderLimit } } if (src.none { !it.isInjected() }) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("$logCaption: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである maxId = column.parseRange(result, src).first adder(src) } val sortAllowed = true if (sortAllowed) listTmp?.sortByDescending { it.getOrderId() } if (bAddGap) addOne(listTmp, TootGap.mayNull(maxId, sinceId)) return result } // since_idを指定してギャップの下から読む private suspend fun readGapTailMisskey( logCaption: String, client: TootApiClient, pathBase: String, paramsCreator: (EntityId?) -> JsonObject, arrayFinder: (JsonObject) -> JsonArray? = { null }, listParser: (TootParser, JsonArray) -> List, adder: (List) -> Unit, ): TootApiResult? { listTmp = ArrayList() val timeStart = SystemClock.elapsedRealtime() var result: TootApiResult? = null var bAddGap = false val newerLimit = maxId while (true) { if (isCancelled) { log.d("$logCaption: cancelled.") break } if (result != null && SystemClock.elapsedRealtime() - timeStart > Column.LOOP_TIMEOUT) { log.d("$logCaption: timeout.") bAddGap = true break } if (allRangeChecked(logCaption)) break val params = paramsCreator(sinceId) log.d("$logCaption: $pathBase ${params.toString().removeIToken()}") val r2 = client.request( pathBase, params.toPostRequestBuilder() ) r2?.jsonObject?.let { r2.data = arrayFinder(it) } val jsonArray = r2?.jsonArray if (jsonArray == null) { log.d("$logCaption: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if (result == null) result = r2 bAddGap = true break } // 成功した場合はそれを返したい result = r2 var src = listParser(parser, jsonArray) if (newerLimit != null) { src = src.filter { it.isInjected() || it.getOrderId() < newerLimit } } if (src.none { !it.isInjected() }) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("$logCaption: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである sinceId = column.parseRange(result, src).second adder(src) } val sortAllowed = true if (sortAllowed) listTmp?.sortByDescending { it.getOrderId() } if (bAddGap) addOne(listTmp, TootGap.mayNull(maxId, sinceId), head = true) return result } // max_id を指定してギャップの上から読む private suspend fun readGapHeadMastodon( logCaption: String, client: TootApiClient, pathBase: String, filterByIdRange: Boolean, listParser: (TootParser, JsonArray) -> List, adder: (List) -> Unit, ): TootApiResult? { listTmp = ArrayList() val delimiter = if (-1 != pathBase.indexOf('?')) '&' else '?' val requester: suspend (EntityId?) -> TootApiResult? = { val path = StringBuilder().apply { append(pathBase) val list = ArrayList() if (it != null) list.add("max_id=$it") if (sinceId != null) list.add("since_id=$sinceId") list.forEachIndexed { index, s -> append(if (index == 0) delimiter else '&') append(s) } }.toString() log.d("readGapHeadMastodon $path") client.request(path) } val timeStart = SystemClock.elapsedRealtime() var result: TootApiResult? = null var bAddGap = false val olderLimit = if (filterByIdRange) sinceId else null while (true) { if (isCancelled) { log.d("$logCaption: cancelled.") break } if (result != null && SystemClock.elapsedRealtime() - timeStart > Column.LOOP_TIMEOUT) { log.d("$logCaption: timeout.") // タイムアウト bAddGap = true break } if (maxId == null) { context.showToast(false, "$logCaption: missing max_id") log.d("$logCaption: missing max_id") break } if (allRangeChecked(logCaption)) break val r2 = requester(maxId) val jsonArray = r2?.jsonArray if (jsonArray == null) { log.d("$logCaption: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if (result == null) result = r2 bAddGap = true break } // 成功した場合はそれを返したい result = r2 var src = listParser(parser, jsonArray) if (olderLimit != null) { src = src.filter { it.getOrderId() > olderLimit } } if (src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("$logCaption: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである maxId = column.parseRange(result, src).first adder(src) } val sortAllowed = false if (sortAllowed) listTmp?.sortByDescending { it.getOrderId() } if (bAddGap) addOne(listTmp, TootGap.mayNull(maxId, sinceId)) return result } // since_idを指定してギャップの下から読む private suspend fun readGapTailMastodon( logCaption: String, client: TootApiClient, pathBase: String, filterByIdRange: Boolean, listParser: (TootParser, JsonArray) -> List, adder: (List) -> Unit, ): TootApiResult? { listTmp = ArrayList() val delimiter = if (-1 != pathBase.indexOf('?')) '&' else '?' val requester: suspend (EntityId?) -> TootApiResult? = { val path = StringBuilder().apply { append(pathBase) val list = ArrayList() if (it != null) list.add("min_id=$it") if (maxId != null) list.add("max_id=$maxId") list.forEachIndexed { index, s -> append(if (index == 0) delimiter else '&') append(s) } }.toString() log.d("$logCaption: $path") client.request(path) } val timeStart = SystemClock.elapsedRealtime() var result: TootApiResult? = null var bAddGap = false val newerLimit = if (filterByIdRange) maxId else null while (true) { if (isCancelled) { log.d("$logCaption: cancelled.") break } if (result != null && SystemClock.elapsedRealtime() - timeStart > Column.LOOP_TIMEOUT) { log.d("$logCaption: timeout.") bAddGap = true break } if (allRangeChecked(logCaption)) break val r2 = requester(sinceId) val jsonArray = r2?.jsonArray if (jsonArray == null) { log.d("$logCaption: error or cancelled. make gap.") // 成功データがない場合だけ、今回のエラーを返すようにする if (result == null) result = r2 bAddGap = true break } // 成功した場合はそれを返したい result = r2 var src = listParser(parser, jsonArray) if (newerLimit != null) { src = src.filter { it.getOrderId() < newerLimit } } if (src.isEmpty()) { // 直前の取得でカラのデータが帰ってきたら終了 log.d("$logCaption: empty.") break } // 隙間の最新のステータスIDは取得データ末尾のステータスIDである sinceId = column.parseRange(result, src).second adder(src) } val sortAllowed = false if (sortAllowed) listTmp?.sortByDescending { it.getOrderId() } if (bAddGap) addOne(listTmp, TootGap.mayNull(maxId, sinceId), head = true) return result } ////////////////////////////////////////////////////////////////////// suspend fun getAccountList( client: TootApiClient, pathBase: String, mastodonFilterByIdRange: Boolean, misskeyParams: JsonObject? = null, arrayFinder: (jsonObject: JsonObject) -> JsonArray? = nullArrayFinder, listParser: (parser: TootParser, jsonArray: JsonArray) -> List = defaultAccountListParser, ): TootApiResult? { if (column.pagingType != ColumnPagingType.Default) { return TootApiResult("can't support gap") } val adder: (List) -> Unit = { addAll(listTmp, it, head = !isHead) } return if (accessInfo.isMisskey) { val logCaption = "getAccountList.Misskey" val params = misskeyParams ?: column.makeMisskeyBaseParameter(parser) if (isHead) { readGapHeadMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeyUntil(it) }, arrayFinder = arrayFinder, listParser = listParser, adder = adder, ) } else { readGapTailMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeySince(it) }, arrayFinder = arrayFinder, listParser = listParser, adder = adder ) } } else { val logCaption = "getAccountList.Mastodon" if (isHead) { readGapHeadMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } else { readGapTailMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } } } suspend fun getReportList( client: TootApiClient, pathBase: String, mastodonFilterByIdRange: Boolean, listParser: (parser: TootParser, jsonArray: JsonArray) -> List = defaultReportListParser, ): TootApiResult? { val adder: (List) -> Unit = { addAll(listTmp, it, head = !isHead) } return if (accessInfo.isMisskey) { val logCaption = "getReportList.Misskey" val params = column.makeMisskeyBaseParameter(parser) if (isHead) { readGapHeadMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeyUntil(it) }, listParser = listParser, adder = adder ) } else { readGapTailMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeySince(it) }, listParser = listParser, adder = adder ) } } else { val logCaption = "getReportList.Mastodon" if (isHead) { readGapHeadMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } else { readGapTailMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } } } suspend fun getNotificationList( client: TootApiClient, fromAcct: String? = null, mastodonFilterByIdRange: Boolean, ): TootApiResult? { val pathBase: String = column.makeNotificationUrl(client, fromAcct) val listParser: (parser: TootParser, jsonArray: JsonArray) -> List = defaultNotificationListParser val adder: (List) -> Unit = { addWithFilterNotification(listTmp, it, head = !isHead) } return if (isMisskey) { val logCaption = "getNotificationList.Misskey" val params = column.makeMisskeyBaseParameter(parser) .addMisskeyNotificationFilter(column) if (isHead) { readGapHeadMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeyUntil(it) }, listParser = listParser, adder = adder ) } else { readGapTailMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeySince(it) }, listParser = listParser, adder = adder ) } } else { val logCaption = "getNotificationList.Mastodon" if (isHead) { readGapHeadMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } else { readGapTailMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } }.also { listTmp?.mapNotNull { it as? TootNotification }.notEmpty()?.let { PollingWorker.injectData(context, accessInfo, it) } } } suspend fun getStatusList( client: TootApiClient, pathBase: String?, mastodonFilterByIdRange: Boolean, misskeyParams: JsonObject? = null, listParser: (parser: TootParser, jsonArray: JsonArray) -> List = defaultStatusListParser, ): TootApiResult? { pathBase ?: return null // cancelled. val adder: (List) -> Unit = { addWithFilterStatus(listTmp, it, head = !isHead) } return if (accessInfo.isMisskey) { val logCaption = "getStatusList.Misskey" val params = misskeyParams ?: column.makeMisskeyTimelineParameter(parser) if (isHead) { readGapHeadMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeyUntil(it) }, listParser = listParser, adder = adder ) } else { readGapTailMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeySince(it) }, listParser = listParser, adder = adder ) } } else { val logCaption = "getStatusList.Mastodon" if (isHead) { readGapHeadMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } else { readGapTailMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } } } suspend fun getConversationSummaryList( client: TootApiClient, pathBase: String, mastodonFilterByIdRange: Boolean, misskeyParams: JsonObject? = null, listParser: (TootParser, JsonArray) -> List = defaultConversationSummaryListParser, ): TootApiResult? { val adder: (List) -> Unit = { addWithFilterConversationSummary(listTmp, it, head = !isHead) } return if (accessInfo.isMisskey) { val logCaption = "getConversationSummaryList.Misskey" val params = misskeyParams ?: column.makeMisskeyTimelineParameter(parser) if (isHead) { readGapHeadMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeyUntil(it) }, listParser = listParser, adder = adder ) } else { readGapTailMisskey( logCaption, client, pathBase, paramsCreator = { params.putMisskeySince(it) }, listParser = listParser, adder = adder ) } } else { val logCaption = "getConversationSummaryList.Mastodon" if (isHead) { readGapHeadMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } else { readGapTailMastodon( logCaption, client, pathBase, filterByIdRange = mastodonFilterByIdRange, listParser = listParser, adder = adder ) } } } suspend fun getSearchGap(client: TootApiClient): TootApiResult? { if (gap !is TootSearchGap) return null if (isMisskey) { val countStatuses: (TimelineItem, EntityId?) -> EntityId? = { it, minId -> if (it is TootStatus && (minId == null || it.id < minId)) it.id else minId } val (_, counter) = when (gap.type) { TootSearchGap.SearchType.Status -> Pair("statuses", countStatuses) //TootSearchGap.SearchType.Hashtag -> Pair("hashtags", countTag) //TootSearchGap.SearchType.Account -> Pair("accounts", countAccount) else -> return TootApiResult("paging for ${gap.type} is not yet supported") } var minId: EntityId? = null for (it in column.listData) minId = counter(it, minId) minId ?: return TootApiResult("can't detect paging parameter.") val result = client.request( "/api/notes/search", accessInfo.putMisskeyApiToken().apply { put("query", column.searchQuery) put("untilId", minId.toString()) } .toPostRequestBuilder() ) val jsonArray = result?.jsonArray if (jsonArray != null) { val src = parser.statusList(jsonArray) listTmp = addWithFilterStatus(listTmp, src) if (src.isNotEmpty()) { addOne(listTmp, TootSearchGap(TootSearchGap.SearchType.Status)) } } return result } else { var offset = 0 val countAccounts: (TimelineItem) -> Unit = { if (it is TootAccountRef) ++offset } val countTags: (TimelineItem) -> Unit = { if (it is TootTag) ++offset } val countStatuses: (TimelineItem) -> Unit = { if (it is TootStatus) ++offset } val (type, counter) = when (gap.type) { TootSearchGap.SearchType.Account -> Pair("accounts", countAccounts) TootSearchGap.SearchType.Hashtag -> Pair("hashtags", countTags) TootSearchGap.SearchType.Status -> Pair("statuses", countStatuses) } column.listData.forEach { counter(it) } // https://mastodon2.juggler.jp/api/v2/search?q=gargron&type=accounts&offset=5 var query = "q=${column.searchQuery.encodePercent()}&type=$type&offset=$offset" if (column.searchResolve) query += "&resolve=1" val (apiResult, searchResult) = client.requestMastodonSearch(parser, query) if (searchResult != null) { listTmp = ArrayList() addAll(listTmp, searchResult.hashtags) addAll(listTmp, searchResult.accounts) addAll(listTmp, searchResult.statuses) if (listTmp?.isNotEmpty() == true) { addOne(listTmp, TootSearchGap(gap.type)) } } return apiResult } } }