SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/column/ColumnExtra2.kt

507 lines
16 KiB
Kotlin

package jp.juggler.subwaytooter.column
import android.content.Context
import android.os.Environment
import androidx.annotation.RawRes
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.columnviewholder.saveScrollPosition
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.network.toPostRequestBuilder
import java.io.File
private val log = LogCategory("ColumnExtra2")
val Column.isMastodon: Boolean
get() = accessInfo.isMastodon
val Column.isMisskey: Boolean
get() = accessInfo.isMisskey
val Column.misskeyVersion: Int
get() = accessInfo.misskeyVersion
val Column.isSearchColumn: Boolean
get() {
return when (type) {
ColumnType.SEARCH,
ColumnType.SEARCH_MSP,
ColumnType.SEARCH_TS,
ColumnType.SEARCH_NOTESTOCK,
-> true
else -> false
}
}
val Column.isNotificationColumn: Boolean
get() = when (type) {
ColumnType.NOTIFICATIONS, ColumnType.NOTIFICATION_FROM_ACCT -> true
else -> false
}
// 公開ストリームなら真
val Column.isPublicStream: Boolean
get() {
return when (type) {
ColumnType.LOCAL,
ColumnType.FEDERATE,
ColumnType.HASHTAG,
ColumnType.LOCAL_AROUND,
ColumnType.FEDERATED_AROUND,
ColumnType.DOMAIN_TIMELINE,
-> true
else -> false
}
}
fun Column.canAutoRefresh() =
!accessInfo.isNA && type.canAutoRefresh
val Column.isConversation
get() = when (type) {
ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
-> true
else -> false
}
/////////////////////////////////////////////////////////////////////////////
// 読み込み処理の内部で使うメソッド
fun Column.getNotificationTypeString(): String {
val list = buildList {
when (quickFilter) {
Column.QUICK_FILTER_MENTION -> add(R.string.notification_type_mention)
Column.QUICK_FILTER_FAVOURITE -> add(R.string.notification_type_favourite)
Column.QUICK_FILTER_BOOST -> add(R.string.notification_type_boost)
Column.QUICK_FILTER_FOLLOW -> add(R.string.notification_type_follow)
Column.QUICK_FILTER_REACTION -> add(R.string.notification_type_reaction)
Column.QUICK_FILTER_VOTE -> add(R.string.notification_type_vote)
Column.QUICK_FILTER_POST -> add(R.string.notification_type_post)
Column.QUICK_FILTER_ALL -> {
if (!dontShowReply) add(R.string.notification_type_mention)
if (!dontShowFollow) add(R.string.notification_type_follow)
if (!dontShowBoost) add(R.string.notification_type_boost)
if (!dontShowFavourite) add(R.string.notification_type_favourite)
if (isMisskey && !dontShowReaction) add(R.string.notification_type_reaction)
if (!dontShowVote) add(R.string.notification_type_vote)
}
}
}
val nMax = when {
isMisskey -> 6
else -> 5
}
return when {
// 全部か皆無なら部分表記は要らない
list.isEmpty() || list.size == nMax -> ""
else -> "(${list.joinToString(", ") { context.getString(it) }})"
}
}
suspend fun Column.loadProfileAccount(
client: TootApiClient,
parser: TootParser,
bForceReload: Boolean,
): TootApiResult? =
when {
// リロード不要なら何もしない
this.whoAccount != null && !bForceReload -> null
isMisskey -> client.request(
"/api/users/show",
accessInfo.putMisskeyApiToken().apply {
put("userId", profileId)
}.toPostRequestBuilder()
)?.also { result1 ->
// ユーザリレーションの取り扱いのため、別のparserを作ってはいけない
parser.misskeyDecodeProfilePin = true
try {
tootAccountRefOrNull(parser, parser.account(result1.jsonObject))?.also { a ->
this.whoAccount = a
client.publishApiProgress("") // カラムヘッダの再表示
}
} finally {
parser.misskeyDecodeProfilePin = false
}
}
else -> client.request(
"/api/v1/accounts/$profileId"
)?.also { result1 ->
tootAccountRefOrNull(parser, parser.account(result1.jsonObject))?.also { a ->
this.whoAccount = a
this.whoFeaturedTags = null
client.request("/api/v1/accounts/$profileId/featured_tags")
?.also { result2 ->
this.whoFeaturedTags =
TootTag.parseListOrNull(parser, result2.jsonArray)
}
client.publishApiProgress("") // カラムヘッダの再表示
}
}
}
fun Column.loadSearchDesc(@RawRes rawEn: Int, @RawRes rawJa: Int): String {
@RawRes val rawId = if ("ja" == context.getString(R.string.language_code)) rawJa else rawEn
return context.loadRawResource(rawId).decodeUTF8()
}
suspend fun Column.updateRelation(
client: TootApiClient,
list: ArrayList<TimelineItem>?,
whoRef: TootAccountRef?,
parser: TootParser,
) {
if (accessInfo.isPseudo) return
val env = UserRelationLoader(this)
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)
}
fun Column.parseRange(
result: TootApiResult?,
list: List<TimelineItem>?,
): Pair<EntityId?, EntityId?> {
var idMin: EntityId? = null
var idMax: EntityId? = null
if (isMisskey && list != null) {
// MisskeyはLinkヘッダがないので、常にデータからIDを読む
for (item in list) {
// injectされたデータをデータ範囲に追加しない
if (item.isInjected()) continue
val id = item.getOrderId()
if (id.notDefaultOrConfirming) {
if (idMin == null || id < idMin) idMin = id
if (idMax == null || id > idMax) idMax = id
}
}
} else {
// Linkヘッダを読む
idMin = Column.reMaxId.matcher(result?.linkOlder ?: "").findOrNull()
?.let {
EntityId(it.groupEx(1)!!)
}
idMax = Column.reMinId.matcher(result?.linkNewer ?: "").findOrNull()
?.let {
// min_idとsince_idの読み分けは現在利用してない it.groupEx(1)=="min_id"
EntityId(it.groupEx(2)!!)
}
}
return Pair(idMin, idMax)
}
// int scroll_hack;
// return true if list bottom may have unread remain
// カラムが既に範囲を持ってる場合、その範囲を拡張する。
fun Column.saveRange(
bBottom: Boolean,
bTop: Boolean,
result: TootApiResult?,
list: List<TimelineItem>?,
): Boolean {
val (idMin, idMax) = parseRange(result, list)
var hasBottomRemain = false
if (bBottom) when (idMin) {
null -> idOld = null // リストの終端
else -> {
val i = idOld?.compareTo(idMin)
if (i == null || i > 0) {
idOld = idMin
hasBottomRemain = true
}
}
}
if (bTop) when (idMax) {
null -> {
// リロードを許容するため、取得内容がカラでもidRecentを変更しない
}
else -> {
val i = idRecent?.compareTo(idMax)
if (i == null || i < 0) {
idRecent = idMax
}
}
}
return hasBottomRemain
}
// return true if list bottom may have unread remain
fun Column.saveRangeBottom(result: TootApiResult?, list: List<TimelineItem>?) =
saveRange(true, bTop = false, result = result, list = list)
// no return value: can't find there may be more items.
fun Column.saveRangeTop(result: TootApiResult?, list: List<TimelineItem>?) {
saveRange(false, bTop = true, result = result, list = list)
}
fun Column.addRange(
bBottom: Boolean,
path: String,
delimiter: Char = if (-1 == path.indexOf('?')) '?' else '&',
) = if (bBottom) {
if (idOld != null) "$path${delimiter}max_id=$idOld" else path
} else {
if (idRecent != null) "$path${delimiter}since_id=$idRecent" else path
}
fun Column.addRangeMin(
path: String,
delimiter: Char = if (-1 != path.indexOf('?')) '&' else '?',
) = if (idRecent == null) path else "$path${delimiter}min_id=$idRecent"
fun Column.toAdapterIndex(listIndex: Int): Int {
return if (type.headerType != null) listIndex + 1 else listIndex
}
fun Column.toListIndex(adapterIndex: Int): Int {
return if (type.headerType != null) adapterIndex - 1 else adapterIndex
}
fun Column.saveScrollPosition() {
try {
if (viewHolder?.saveScrollPosition() == true) {
val ss = this.scrollSave
if (ss != null) {
val idx = toListIndex(ss.adapterIndex)
if (0 <= idx && idx < listData.size) {
val item = listData[idx]
this.lastViewingItemId = item.getOrderId()
// とりあえず保存はするが
// TLデータそのものを永続化しないかぎり出番はないっぽい
}
}
}
} catch (ex: Throwable) {
log.e(ex, "can't get last_viewing_item_id.")
}
}
inline fun <reified T : TimelineItem> addAll(
dstArg: ArrayList<TimelineItem>?,
src: List<T>,
head: Boolean = false,
): ArrayList<TimelineItem> =
(dstArg ?: ArrayList(src.size)).apply {
if (head) {
addAll(0, src)
} else {
addAll(src)
}
}
fun addOne(
dstArg: ArrayList<TimelineItem>?,
item: TimelineItem?,
head: Boolean = false,
): ArrayList<TimelineItem> =
(dstArg ?: ArrayList()).apply {
if (item != null) {
if (head) {
add(0, item)
} else {
add(item)
}
}
}
fun ColumnTask.addWithFilterStatus(
dstArg: ArrayList<TimelineItem>?,
srcArg: List<TootStatus>,
head: Boolean = false,
): ArrayList<TimelineItem> =
(dstArg ?: ArrayList(srcArg.size)).apply {
val src = srcArg.filter { !column.isFiltered(it) }
if (head) {
addAll(0, src)
} else {
addAll(src)
}
}
fun ColumnTask.addWithFilterConversationSummary(
dstArg: ArrayList<TimelineItem>?,
srcArg: List<TootConversationSummary>,
head: Boolean = false,
): ArrayList<TimelineItem> =
(dstArg ?: ArrayList(srcArg.size)).apply {
val src = srcArg.filter { !column.isFiltered(it.last_status) }
if (head) {
addAll(0, src)
} else {
addAll(src)
}
}
fun ColumnTask.addWithFilterNotification(
dstArg: ArrayList<TimelineItem>?,
srcArg: List<TootNotification>,
head: Boolean = false,
): ArrayList<TimelineItem> =
(dstArg ?: ArrayList(srcArg.size)).apply {
val src = srcArg.filter { !column.isFiltered(it) }
if (head) {
addAll(0, src)
} else {
addAll(src)
}
}
fun Column.dispatchProfileTabStatus() =
when {
isMisskey -> ColumnType.ProfileStatusMisskey
else -> ColumnType.ProfileStatusMastodon
}
fun Column.dispatchProfileTabFollowing() =
when {
misskeyVersion >= 11 -> ColumnType.FollowingMisskey11
isMisskey -> ColumnType.FollowingMisskey10
accessInfo.isPseudo -> ColumnType.FollowingMastodonPseudo
else -> ColumnType.FollowingMastodon
}
fun Column.dispatchProfileTabFollowers() =
when {
misskeyVersion >= 11 -> ColumnType.FollowersMisskey11
isMisskey -> ColumnType.FollowersMisskey10
accessInfo.isPseudo -> ColumnType.FollowersMastodonPseudo
else -> ColumnType.FollowersMastodon
}
fun ColumnTask.dispatchProfileTabStatus() =
column.dispatchProfileTabStatus()
fun ColumnTask.dispatchProfileTabFollowing() =
column.dispatchProfileTabFollowing()
fun ColumnTask.dispatchProfileTabFollowers() =
column.dispatchProfileTabFollowers()
suspend fun Column.loadListInfo(client: TootApiClient, bForceReload: Boolean) {
val parser = TootParser(context, accessInfo)
if (bForceReload || this.listInfo == null) {
val result = if (isMisskey) {
client.request(
"/api/users/lists/show",
makeMisskeyBaseParameter(parser).apply {
put("listId", profileId)
}.toPostRequestBuilder()
)
} else {
client.request("/api/v1/lists/$profileId")
}
val jsonObject = result?.jsonObject
if (jsonObject != null) {
val data =
parseItem(jsonObject) { TootList(parser, it) }
if (data != null) {
this.listInfo = data
client.publishApiProgress("") // カラムヘッダの再表示
}
}
}
}
suspend fun Column.loadAntennaInfo(client: TootApiClient, bForceReload: Boolean) {
val parser = TootParser(context, accessInfo)
if (bForceReload || this.antennaInfo == null) {
val result = if (isMisskey) {
client.request(
"/api/antennas/show",
makeMisskeyBaseParameter(parser).apply {
put("antennaId", profileId)
}.toPostRequestBuilder()
)
} else {
TootApiResult("antenna feature is not supported on Mastodon")
}
val jsonObject = result?.jsonObject
if (jsonObject != null) {
val data = parseItem(jsonObject) { MisskeyAntenna(it) }
if (data != null) {
this.antennaInfo = data
client.publishApiProgress("") // カラムヘッダの再表示
}
}
}
}
fun JsonObject.putMisskeyUntil(id: EntityId?): JsonObject {
if (id != null) put("untilId", id.toString())
return this
}
fun JsonObject.putMisskeySince(id: EntityId?): JsonObject {
if (id != null) put("sinceId", id.toString())
return this
}
fun JsonObject.addRangeMisskey(column: Column, bBottom: Boolean): JsonObject {
if (bBottom) {
putMisskeyUntil(column.idOld)
} else {
putMisskeySince(column.idRecent)
}
return this
}
///////////////////////////////////////////////////////////////////////
private const val DIR_BACKGROUND_IMAGE = "columnBackground"
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(DIR_BACKGROUND_IMAGE, Context.MODE_PRIVATE)
log.i("backgroundDir: $backgroundDir exists=${backgroundDir.exists()}")
return backgroundDir
}