カラムがストリーミングイベントを受け取った際にカラムの状態を確認する

This commit is contained in:
tateisu 2020-12-22 08:49:26 +09:00
parent c3ea720265
commit 00b05d8646
9 changed files with 783 additions and 764 deletions

View File

@ -372,11 +372,8 @@ class Column(
// カラムオブジェクトの識別に使うID。
val internalId = internalIdSeed.incrementAndGet()
val type = ColumnType.parse(typeId)
internal var dont_close: Boolean = false
internal var with_attachment: Boolean = false
@ -553,16 +550,409 @@ class Column(
private val last_show_stream_data = AtomicLong(0L)
private val stream_data_queue = ConcurrentLinkedQueue<TimelineItem>()
@Volatile
private var bPutGap: Boolean = false
val isSearchColumn: Boolean
get() {
return when (type) {
ColumnType.SEARCH, ColumnType.SEARCH_MSP, ColumnType.SEARCH_TS, ColumnType.SEARCH_NOTESTOCK -> true
else -> false
private var cacheHeaderDesc: String? = null
// DMカラム更新時に新APIの利用に成功したなら真
internal var useConversationSummarys = false
// DMカラムのストリーミングイベントで新形式のイベントを利用できたなら真
internal var useConversationSummaryStreaming = false
////////////////////////////////////////////////////////////////
private fun canHandleStreamingMessage () = !is_dispose.get() && canStartStreaming()
private fun runOnMainLooperForStreamingEvent(proc : () -> Unit){
runOnMainLooper {
if(!canHandleStreamingMessage() )
return@runOnMainLooper
proc()
}
}
val streamCallback = object : StreamCallback {
override fun onListeningStateChanged(status: StreamStatus) {
if(!canHandleStreamingMessage() ) return
if (status == StreamStatus.Open) {
updateMisskeyCapture()
}
runOnMainLooperForStreamingEvent {
fireShowColumnStatus()
}
}
override fun onTimelineItem(item: TimelineItem, channelId: String?,stream:JsonArray?) {
if(!canHandleStreamingMessage() ) return
when (item) {
is TootConversationSummary -> {
if (type != ColumnType.DIRECT_MESSAGES) return
if (isFiltered(item.last_status)) return
if (use_old_api) {
useConversationSummaryStreaming = false
return
} else {
useConversationSummaryStreaming = true
}
}
is TootNotification -> {
if (!isNotificationColumn) return
if (isFiltered(item)) return
}
is TootStatus -> {
if (isNotificationColumn) return
// マストドン2.6.0形式のDMカラム用イベントを利用したならば、その直後に発生する普通の投稿イベントを無視する
if (useConversationSummaryStreaming) return
// マストドンはLTLに外部ユーザの投稿を表示しない
if (type == ColumnType.LOCAL && isMastodon && item.account.isRemote) return
if (isFiltered(item)) return
}
}
stream_data_queue.add(item)
app_state.handler.post(mergeStreamingMessage)
}
override fun onNoteUpdated(ev: MisskeyNoteUpdate, channelId: String?) {
runOnMainLooperForStreamingEvent {
// 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!!")
}
val byMe = myId == ev.userId
val changeList = ArrayList<AdapterChange>()
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)
}
}
}
if (changeList.isNotEmpty()) {
fireShowContent(reason = "onNoteUpdated", changeList = changeList)
}
}
}
override fun onAnnouncementUpdate(item: TootAnnouncement) {
runOnMainLooperForStreamingEvent {
if( type != ColumnType.HOME)
return@runOnMainLooperForStreamingEvent
val list = announcements
if (list == null) {
announcements = mutableListOf(item)
} else {
val index = list.indexOfFirst { it.id == item.id }
list.add(
0,
if (index == -1) {
item
} else {
TootAnnouncement.merge(list.removeAt(index), item)
}
)
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
}
override fun onAnnouncementDelete(id: EntityId) {
runOnMainLooperForStreamingEvent {
announcements?.iterator()?.let{
while (it.hasNext()) {
val item = it.next()
if (item.id != id) continue
it.remove()
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
break
}
}
}
}
override fun onAnnouncementReaction(reaction: TootAnnouncement.Reaction) {
runOnMainLooperForStreamingEvent {
// find announcement
val announcement_id = reaction.announcement_id
?: return@runOnMainLooperForStreamingEvent
val announcement = announcements?.find { it.id == announcement_id }
?: return@runOnMainLooperForStreamingEvent
// find reaction
val index = announcement.reactions?.indexOfFirst { it.name == reaction.name }
when {
reaction.count <= 0L -> {
if (index != null && index != -1) announcement.reactions?.removeAt(index)
}
index == null -> {
announcement.reactions = ArrayList<TootAnnouncement.Reaction>().apply {
add(reaction)
}
}
index == -1 -> announcement.reactions?.add(reaction)
else -> announcement.reactions?.get(index)?.let { old ->
old.count = reaction.count
// ストリーミングイベントにはmeが含まれないので、oldにあるmeは変更されない
}
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
}
}
private val mergeStreamingMessage: Runnable = object : Runnable {
override fun run() {
// 前回マージしてから暫くは待機してリトライ
val handler = app_state.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
}
// カラムがビジー状態なら待機してリトライ
if( !canStartStreaming() || bRefreshLoading){
handler.removeCallbacks(this)
handler.postDelayed(this, 333L)
return
}
last_show_stream_data.set(now)
val tmpList = ArrayList<TimelineItem>()
while (true) tmpList.add(stream_data_queue.poll() ?: break)
if (tmpList.isEmpty()) return
// キューから読めた件数が0の場合を除き、少し後に再処理させることでマージ漏れを防ぐ
handler.postDelayed(this, 333L)
// ストリーミングされるデータは全てID順に並んでいるはず
tmpList.sortByDescending { it.getOrderId() }
val list_new = duplicate_map.filterDuplicate(tmpList)
if (list_new.isEmpty()) return
for (item in list_new) {
if (enable_speech && item is TootStatus) {
app_state.addSpeech(item.reblog ?: item)
}
}
// 通知カラムならストリーミング経由で届いたデータを通知ワーカーに伝達する
if (isNotificationColumn) {
val list = ArrayList<TootNotification>()
for (o in list_new) {
if (o is TootNotification) {
list.add(o)
}
}
if (list.isNotEmpty()) {
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 != 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<AdapterChange>()
replaceConversationSummary(changeList, list_new, list_data)
val added = list_new.size // may 0
var doneSound = false
for (o in list_new) {
if (o is TootStatus) {
o.highlightSound?.let {
if (!doneSound) {
doneSound = true
App1.sound(it)
}
}
o.highlightSpeech?.let {
app_state.addSpeech(it.name, dedupMode = DedupMode.RecentExpire)
}
}
}
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
when {
// スクロール位置が先頭なら先頭のまま
scroll_save == null || scroll_save.isHead -> {
}
// 現在の要素が表示され続けるようにしたい
else -> scroll_save.adapterIndex += added
}
}
updateMisskeyCapture()
}
}
//////////////////////////////////////////////////////////////////////////////////////
internal constructor(
app_state: AppState,
access_info: SavedAccount,
@ -746,13 +1136,6 @@ class Column(
fun getColumnName(long: Boolean) =
type.name2(this, long) ?: type.name1(context)
private fun JsonObject.putIfTrue(key: String, value: Boolean) {
if (value) put(key, true)
}
@Throws(JsonException::class)
fun encodeJSON(dst: JsonObject, old_index: Int) {
dst[KEY_ACCOUNT_ROW_ID] = access_info.db_id
@ -1164,11 +1547,7 @@ class Column(
PollingWorker.queueNotificationCleared(context, access_info.db_id)
}
val isNotificationColumn: Boolean
get() = when (type) {
ColumnType.NOTIFICATIONS, ColumnType.NOTIFICATION_FROM_ACCT -> true
else -> false
}
fun removeNotificationOne(target_account: SavedAccount, notification: TootNotification) {
if (!isNotificationColumn) return
@ -1867,11 +2246,6 @@ class Column(
env.update(client, parser)
}
// DMカラム更新時に新APIの利用に成功したなら真
internal var useConversationSummarys = false
// DMカラムのストリーミングイベントで新形式のイベントを利用できたなら真
internal var useConversationSummaryStreaming = false
internal fun startLoading() {
cancelLastTask()
@ -2136,7 +2510,6 @@ class Column(
return context.loadRawResource(res_id).decodeUTF8()
}
private var cacheHeaderDesc: String? = null
fun getHeaderDesc(): String {
var cache = cacheHeaderDesc
@ -2209,10 +2582,10 @@ class Column(
} else if (isSearchColumn) {
// 検索カラムはリフレッシュもストリーミングもないが、表示開始のタイミングでリストの再描画を行いたい
fireShowContent(reason = "Column onStart isSearchColumn", reset = true)
} else {
} else if( canStartStreaming() && streamSpec !=null ){
// ギャップつきでストリーミング開始
log.d("onStart: start streaming with gap.")
resumeColumn(true)
this.bPutGap = true
fireShowColumnStatus()
}
}
@ -2400,196 +2773,6 @@ class Column(
}
}
val streamCallback = object : StreamCallback {
override fun onListeningStateChanged(status: StreamStatus) {
if (is_dispose.get()) return
if (status == StreamStatus.Open) {
updateMisskeyCapture()
}
runOnMainLooper {
if (is_dispose.get()) return@runOnMainLooper
fireShowColumnStatus()
}
}
override fun onTimelineItem(item: TimelineItem, channelId: String?,stream:JsonArray?) {
if (is_dispose.get()) return
if (item is TootConversationSummary) {
if (type != ColumnType.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 (!isNotificationColumn) return
if (isFiltered(item)) return
} else if (item is TootStatus) {
if (isNotificationColumn) return
// マストドン2.6.0形式のDMカラム用イベントを利用したならば、その直後に発生する普通の投稿イベントを無視する
if (useConversationSummaryStreaming) return
// マストドンはLTLに外部ユーザの投稿を表示しない
if (type == ColumnType.LOCAL && isMastodon && item.account.isRemote) return
if (isFiltered(item)) return
}
stream_data_queue.add(item)
app_state.handler.post(mergeStreamingMessage)
}
override fun onNoteUpdated(ev: MisskeyNoteUpdate, channelId: String?) {
runOnMainLooper {
if (is_dispose.get()) return@runOnMainLooper
// 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!!")
}
val byMe = myId == ev.userId
val changeList = ArrayList<AdapterChange>()
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)
}
}
}
if (changeList.isNotEmpty()) {
fireShowContent(reason = "onNoteUpdated", changeList = changeList)
}
}
}
override fun onAnnouncementUpdate(item: TootAnnouncement) {
runOnMainLooper {
val list = announcements
if (list == null) {
announcements = mutableListOf(item)
} else {
val index = list.indexOfFirst { it.id == item.id }
list.add(
0,
if (index == -1) {
item
} else {
TootAnnouncement.merge(list.removeAt(index), item)
}
)
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
}
override fun onAnnouncementDelete(id: EntityId) {
runOnMainLooper {
val it = announcements?.iterator() ?: return@runOnMainLooper
while (it.hasNext()) {
val item = it.next()
if (item.id == id) {
it.remove()
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
return@runOnMainLooper
}
}
}
}
override fun onAnnouncementReaction(reaction: TootAnnouncement.Reaction) {
runOnMainLooper {
// find announcement
val announcement_id = reaction.announcement_id ?: return@runOnMainLooper
val announcement = announcements?.find { it.id == announcement_id } ?: return@runOnMainLooper
// find reaction
val index = announcement.reactions?.indexOfFirst { it.name == reaction.name }
when {
reaction.count <= 0L -> {
if (index != null && index != -1) announcement.reactions?.removeAt(index)
}
index == null -> {
announcement.reactions = ArrayList<TootAnnouncement.Reaction>().apply {
add(reaction)
}
}
index == -1 -> announcement.reactions?.add(reaction)
else -> announcement.reactions?.get(index)?.let { old ->
old.count = reaction.count
// ストリーミングイベントにはmeが含まれないので、oldにあるmeは変更されない
}
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
}
}
// 別スレッドから呼ばれるが大丈夫か
fun canStartStreaming() = when {
// 未初期化なら何もしない
@ -2607,202 +2790,6 @@ class Column(
else -> true
}
private fun resumeColumn(bPutGap: Boolean) {
// カラム種別によってはストリーミングAPIを利用できない
streamSpec ?: return
if (!canStartStreaming()) return
this.bPutGap = bPutGap
// TODO キューのクリアって必要? stream_data_queue.clear()
// streamReader = app_state.stream_reader.register(
// access_info,
// stream_path,
// highlight_trie,
// streamCallback
// )
fireShowColumnStatus()
}
private val mergeStreamingMessage: Runnable = object : Runnable {
override fun run() {
// 前回マージしてから暫くは待機する
val handler = app_state.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<TimelineItem>()
while (true) tmpList.add(stream_data_queue.poll() ?: break)
if (tmpList.isEmpty()) return
// キューから読めた件数が0の場合を除き、少し後に再処理させることでマージ漏れを防ぐ
handler.postDelayed(this, 333L)
// ストリーミングされるデータは全てID順に並んでいるはず
tmpList.sortByDescending { it.getOrderId() }
val list_new = duplicate_map.filterDuplicate(tmpList)
if (list_new.isEmpty()) return
for (item in list_new) {
if (enable_speech && item is TootStatus) {
app_state.addSpeech(item.reblog ?: item)
}
}
// 通知カラムならストリーミング経由で届いたデータを通知ワーカーに伝達する
if (isNotificationColumn) {
val list = ArrayList<TootNotification>()
for (o in list_new) {
if (o is TootNotification) {
list.add(o)
}
}
if (list.isNotEmpty()) {
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 != 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<AdapterChange>()
replaceConversationSummary(changeList, list_new, list_data)
val added = list_new.size // may 0
var doneSound = false
for (o in list_new) {
if (o is TootStatus) {
o.highlightSound?.let {
if (!doneSound) {
doneSound = true
App1.sound(it)
}
}
o.highlightSpeech?.let {
app_state.addSpeech(it.name, dedupMode = DedupMode.RecentExpire)
}
}
}
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
when {
// スクロール位置が先頭なら先頭のまま
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
@ -2986,11 +2973,6 @@ class Column(
}
}
val isMastodon: Boolean = access_info.isMastodon
val isMisskey: Boolean = access_info.isMisskey
val misskeyVersion = access_info.misskeyVersion
fun saveScrollPosition() {
try {

View File

@ -70,7 +70,7 @@ enum class ColumnType(
val iconId: (Acct) -> Int = unusedIconId,
val name1: (context: Context) -> String = unusedName,
val name2: Column.(long: Boolean) -> String? = unusedName2,
val loading: suspend ColumnTask_Loading.(client: TootApiClient) -> TootApiResult?,
val loading: suspend ColumnTask_Loading.(client: TootApiClient) -> TootApiResult?,
val refresh: suspend ColumnTask_Refresh.(client: TootApiClient) -> TootApiResult? = unsupportedRefresh,
val gap: suspend ColumnTask_Gap.(client: TootApiClient) -> TootApiResult? = unsupportedGap,
val bAllowPseudo: Boolean = true,
@ -78,8 +78,12 @@ enum class ColumnType(
val bAllowMastodon: Boolean = true,
val headerType: HeaderType? = null,
val gapDirection: Column.(head: Boolean) -> Boolean = gapDirectionNone,
val streamKeyMastodon: Column.()->JsonObject? = {null},
val streamFilterMastodon: Column.(String?,TimelineItem)->Boolean = {_,_->true},
val canAutoRefresh: Boolean = false,
val streamKeyMastodon: Column.() -> JsonObject? = { null },
val streamFilterMastodon: Column.(String?, TimelineItem) -> Boolean = { _, _ -> true },
val streamNameMisskey: String? = null,
val streamParamMisskey: Column.() -> JsonObject? = { null },
val streamPathMisskey10: Column.() -> String? = { null },
) {
ProfileStatusMastodon(
@ -411,12 +415,18 @@ enum class ColumnType(
gapDirection = gapDirectionBoth,
bAllowPseudo = false,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to "user")
},
streamFilterMastodon = { stream,item->
streamFilterMastodon = { stream, item ->
item is TootStatus && (stream == null || stream == "user")
}
},
streamNameMisskey = "homeTimeline",
streamParamMisskey = { null },
streamPathMisskey10 = { "/" },
),
LOCAL(
@ -436,20 +446,25 @@ enum class ColumnType(
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to
"public:local"
.appendIf(":media", with_attachment)
)
},
streamFilterMastodon = { stream,item->
when{
streamFilterMastodon = { stream, item ->
when {
item !is TootStatus -> false
(stream != null && !stream.startsWith("public:local")) -> false
with_attachment && item.media_attachments.isNullOrEmpty() -> false
else->true
else -> true
}
}
},
streamNameMisskey = "localTimeline",
streamParamMisskey = { null },
streamPathMisskey10 = { "/local-timeline" },
),
FEDERATE(
@ -470,6 +485,8 @@ enum class ColumnType(
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to
"public"
@ -478,16 +495,20 @@ enum class ColumnType(
)
},
streamFilterMastodon = { stream,item->
when{
streamFilterMastodon = { stream, item ->
when {
item !is TootStatus -> false
(stream != null && !stream.startsWith("public")) -> false
(stream !=null && stream.contains(":local")) ->false
(stream != null && stream.contains(":local")) -> false
remote_only && item.account.acct == access_info.acct -> false
with_attachment && item.media_attachments.isNullOrEmpty() -> false
else->true
else -> true
}
}
},
streamNameMisskey = "globalTimeline",
streamParamMisskey = { null },
streamPathMisskey10 = { "/global-timeline" },
),
MISSKEY_HYBRID(
@ -507,6 +528,12 @@ enum class ColumnType(
)
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamNameMisskey = "hybridTimeline",
streamParamMisskey = { null },
streamPathMisskey10 = { "/hybrid-timeline" },
),
DOMAIN_TIMELINE(
@ -531,6 +558,8 @@ enum class ColumnType(
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to
"public:domain"
@ -539,13 +568,13 @@ enum class ColumnType(
)
},
streamFilterMastodon = { stream,item->
when{
streamFilterMastodon = { stream, item ->
when {
item !is TootStatus -> false
(stream != null && !stream.startsWith("public:domain")) -> false
(stream != null && !stream.endsWith(instance_uri)) -> false
with_attachment && item.media_attachments.isNullOrEmpty() -> false
else->true
else -> true
}
}
),
@ -715,6 +744,8 @@ enum class ColumnType(
bAllowPseudo = false,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to "user")
},
@ -722,9 +753,13 @@ enum class ColumnType(
when {
item !is TootNotification -> false
(stream != null && stream != "user") -> false
else->true
else -> true
}
}
},
streamNameMisskey = "main",
streamParamMisskey = { null },
streamPathMisskey10 = { "/" },
),
NOTIFICATION_FROM_ACCT(
@ -812,6 +847,8 @@ enum class ColumnType(
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(
StreamSpec.STREAM to "hashtag".appendIf(":local", instance_local),
@ -819,15 +856,20 @@ enum class ColumnType(
)
},
streamFilterMastodon = { stream,item->
when{
streamFilterMastodon = { stream, item ->
when {
item !is TootStatus -> false
(stream != null && !stream.startsWith("hashtag")) -> false
instance_local && (stream !=null && !stream.contains(":local")) ->false
instance_local && (stream != null && !stream.contains(":local")) -> false
else-> this.checkHashtagExtra(item)
else -> this.checkHashtagExtra(item)
}
}
},
streamNameMisskey = "hashtag",
streamParamMisskey = { jsonObject ("q" to hashtag) },
// Misskey10 というかめいすきーでタグTLのストリーミングができるのか不明
// streamPathMisskey10 = { "/???? ?q=${hashtag.encodePercent()}" },
),
HASHTAG_FROM_ACCT(
@ -1284,17 +1326,23 @@ enum class ColumnType(
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to "list", "list" to profile_id.toString())
},
streamFilterMastodon = { stream,item->
when{
streamFilterMastodon = { stream, item ->
when {
item !is TootStatus -> false
(stream != null && stream != "list:${profile_id}") -> false
else-> true
else -> true
}
}
},
streamNameMisskey = "userList",
streamParamMisskey = { jsonObject("listId" to profile_id.toString()) },
streamPathMisskey10 = { "/user-list?listId=${profile_id.toString()}" },
),
LIST_MEMBER(21,
@ -1393,15 +1441,17 @@ enum class ColumnType(
bAllowPseudo = false,
bAllowMisskey = false,
canAutoRefresh = true,
streamKeyMastodon = {
jsonObject(StreamSpec.STREAM to "direct")
},
streamFilterMastodon = { stream, _ ->
when{
when {
(stream != null && stream != "direct") -> false
else-> true
else -> true
}
}
),
@ -1655,6 +1705,11 @@ enum class ColumnType(
}
},
gapDirection = gapDirectionBoth,
canAutoRefresh = true,
streamNameMisskey = "antenna",
streamParamMisskey = { jsonObject("antennaId" to profile_id.toString()) },
// Misskey10 にアンテナはない
),
;

View File

@ -1,6 +1,5 @@
package jp.juggler.subwaytooter
import android.graphics.Bitmap
import android.util.LruCache
import jp.juggler.subwaytooter.Column.Companion.READ_LIMIT
import jp.juggler.subwaytooter.Column.Companion.log
@ -12,6 +11,49 @@ import jp.juggler.subwaytooter.api.syncAccountByAcct
import jp.juggler.util.*
import java.util.*
val Column.isMastodon: Boolean
get()= access_info.isMastodon
val Column.isMisskey: Boolean
get()= access_info.isMisskey
val Column.misskeyVersion :Int
get()= access_info.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() =
! access_info.isNA && type.canAutoRefresh
internal inline fun <reified T : TimelineItem> addAll(
dstArg: ArrayList<TimelineItem>?,

View File

@ -18,9 +18,8 @@ class StreamDestination(
val refCallback = WeakReference(column.streamCallback)
fun canStartStreaming(): Boolean {
val column = refColumn.get()
return when {
column == null -> {
return when (val column = refColumn.get()) {
null -> {
log.w("${spec.name} canStartStreaming: missing column.")
false
}

View File

@ -1,5 +1,7 @@
package jp.juggler.subwaytooter.streaming
import jp.juggler.subwaytooter.Column
// ストリーミング接続の状態
enum class StreamStatus {
Closed,
@ -13,3 +15,7 @@ enum class StreamIndicatorState {
REGISTERED, // registered, but not listening
LISTENING,
}
fun Column.getStreamingStatus() =
app_state.streamManager.getStreamingStatus(access_info, internalId)
?: StreamIndicatorState.NONE

View File

@ -1,10 +1,7 @@
package jp.juggler.subwaytooter.streaming
import jp.juggler.subwaytooter.Column
import jp.juggler.subwaytooter.ColumnType
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.entity.TimelineItem
import jp.juggler.subwaytooter.encodeQuery
import jp.juggler.subwaytooter.makeHashtagQueryParams
import jp.juggler.subwaytooter.streaming.StreamSpec.Companion.CHANNEL
import jp.juggler.subwaytooter.streaming.StreamSpec.Companion.PARAMS
import jp.juggler.subwaytooter.streaming.StreamSpec.Companion.STREAM
@ -40,7 +37,7 @@ class StreamSpec(
val params: JsonObject,
val path: String,
val name: String,
val streamFilter: Column.(String?,TimelineItem)->Boolean = { _, _ -> true }
val streamFilter: Column.(String?, TimelineItem) -> Boolean = { _, _ -> true }
) {
companion object {
const val STREAM = "stream"
@ -58,20 +55,10 @@ class StreamSpec(
if (other is StreamSpec) return keyString == other.keyString
return false
}
fun match(stream: JsonArray): Boolean {
return true
}
}
private fun Column.streamKeyMastodon(): StreamSpec? {
val root = type.streamKeyMastodon(this) ?: return null
val filter = type.streamFilterMastodon
val path = "/api/v1/streaming/?${root.encodeQuery()}"
val sw = StringWriter()
synchronized(sw.buffer) {
private fun encodeStreamNameMastodon(root: JsonObject) = StringWriter()
.also { sw ->
sw.append(root.string(STREAM)!!)
root.entries.sortedBy { it.key }.forEach { pair ->
val (k, v) = pair
@ -85,77 +72,23 @@ private fun Column.streamKeyMastodon(): StreamSpec? {
sw.append(',').append(k).append('=').appendValue(v)
}
}
}
}.toString()
return StreamSpec(root, path, sw.toString(),streamFilter=filter)
private fun Column.streamSpecMastodon(): StreamSpec? {
val root = type.streamKeyMastodon(this) ?: return null
return StreamSpec(
params= root,
path= "/api/v1/streaming/?${root.encodeQuery()}",
name=encodeStreamNameMastodon(root),
streamFilter = type.streamFilterMastodon
)
}
fun Column.streamKeyMisskey(): StreamSpec? {
// 使われ方は StreamConnection.subscribe を参照のこと
fun x(channel: String, params: JsonObject = JsonObject()) =
jsonObject(CHANNEL to channel, PARAMS to params)
val misskeyApiToken = access_info.misskeyApiToken
val root = when (misskeyApiToken) {
null -> when (type) {
ColumnType.LOCAL -> x("localTimeline")
else -> null
}
else -> when (type) {
ColumnType.HOME ->
x("homeTimeline")
ColumnType.LOCAL ->
x("localTimeline")
ColumnType.MISSKEY_HYBRID ->
x("hybridTimeline")
ColumnType.FEDERATE ->
x("globalTimeline")
ColumnType.NOTIFICATIONS ->
x("main")
ColumnType.MISSKEY_ANTENNA_TL ->
x("antenna", jsonObject { put("antennaId", profile_id.toString()) })
ColumnType.LIST_TL ->
x("userList", jsonObject { put("listId", profile_id.toString()) })
ColumnType.HASHTAG ->
x("hashtag", jsonObject { put("q", hashtag) })
else -> null
}
} ?: return null
val path = when {
// Misskey 11以降は統合されてる
misskeyVersion >= 11 -> "/streaming"
// Misskey 10 認証なし
// Misskey 8.25 からLTLだけ認証なしでも見れるようになった
access_info.isPseudo -> when (type) {
ColumnType.LOCAL -> "/local-timeline"
else -> null
}
// Misskey 10 認証あり
// Misskey 8.25 からLTLだけ認証なしでも見れるようになった
else -> when (type) {
ColumnType.HOME, ColumnType.NOTIFICATIONS -> "/"
ColumnType.LOCAL -> "/local-timeline"
ColumnType.MISSKEY_HYBRID -> "/hybrid-timeline"
ColumnType.FEDERATE -> "/global-timeline"
ColumnType.LIST_TL -> "/user-list?listId=${profile_id.toString()}"
// タグやアンテナには対応しない
else -> null
}
} ?: return null
val sw = StringWriter()
synchronized(sw.buffer) {
private fun encodeStreamNameMisskey(root:JsonObject) =
StringWriter().also{sw->
sw.append(root.string(CHANNEL)!!)
val params = root.jsonObject(PARAMS)!!
params.entries.sortedBy { it.key }.forEach { pair ->
@ -170,33 +103,41 @@ fun Column.streamKeyMisskey(): StreamSpec? {
sw.append(',').append(k).append('=').appendValue(v)
}
}
}
}.toString()
return StreamSpec(root, path, sw.toString())
fun Column.streamSpecMisskey(): StreamSpec? {
val channelName =
if( access_info.misskeyApiToken==null && type != ColumnType.LOCAL) {
null
}else {
type.streamNameMisskey
} ?: return null
val path = when {
// Misskey 11以降は統合されてる
misskeyVersion >= 11 -> "/streaming"
else -> type.streamPathMisskey10(this)
} ?: return null
val channelParam = type.streamParamMisskey(this) ?: JsonObject()
val root = jsonObject(CHANNEL to channelName, PARAMS to channelParam)
return StreamSpec(
params = root,
path = path,
name = encodeStreamNameMisskey(root),
// no stream filter
)
}
// 公開ストリームなら真
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
}
}
val Column.streamSpec: StreamSpec?
get() = when {
// 疑似アカウントではストリーミングAPIを利用できない
// 2.1 では公開ストリームのみ利用できるらしい
(access_info.isNA || access_info.isPseudo && !isPublicStream) -> null
access_info.isMastodon -> streamKeyMastodon()
access_info.isMisskey -> streamKeyMisskey()
access_info.isMastodon -> streamSpecMastodon()
access_info.isMisskey -> streamSpecMisskey()
else -> null
}
@ -207,4 +148,5 @@ fun Column.canStreaming() = when {
else -> streamSpec != null
}
fun Column.canAutoRefresh() = canStreaming()
fun Column.canSpeech() =
canStreaming() && !isNotificationColumn

View File

@ -1,11 +0,0 @@
package jp.juggler.subwaytooter.streaming
import jp.juggler.subwaytooter.Column
fun Column.getStreamingStatus() =
app_state.streamManager.getStreamingStatus(access_info, internalId)
?: StreamIndicatorState.NONE
fun Column.canSpeech() =
canStreaming() && !isNotificationColumn

View File

@ -276,6 +276,10 @@ class JsonObject : LinkedHashMap<String, Any?>() {
fun putNotNull(name: String, value: Any?) {
if (value != null) put(name, value)
}
fun putIfTrue(key: String, value: Boolean) {
if (value) put(key, true)
}
}
class JsonTokenizer(reader: Reader) {

View File

@ -14,100 +14,100 @@ import java.util.regex.Matcher
import java.util.regex.Pattern
object StringUtils {
val log = LogCategory("StringUtils")
val hexLower =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
// BDI制御文字からその制御文字を閉じる文字を得るためのマップ
val sanitizeBdiMap = HashMap<Char, Char>().apply {
val PDF = 0x202C.toChar() // Pop directional formatting (PDF)
this[0x202A.toChar()] = PDF // Left-to-right embedding (LRE)
this[0x202B.toChar()] = PDF // Right-to-left embedding (RLE)
this[0x202D.toChar()] = PDF // Left-to-right override (LRO)
this[0x202E.toChar()] = PDF // Right-to-left override (RLO)
val PDI = 0x2069.toChar() // Pop directional isolate (PDI)
this[0x2066.toChar()] = PDI // Left-to-right isolate (LRI)
this[0x2067.toChar()] = PDI // Right-to-left isolate (RLI)
this[0x2068.toChar()] = PDI // First strong isolate (FSI)
// private const val ALM = 0x061c.toChar() // Arabic letter mark (ALM)
// private const val LRM = 0x200E.toChar() // Left-to-right mark (LRM)
// private const val RLM = 0x200F.toChar() // Right-to-left mark (RLM)
}
val log = LogCategory("StringUtils")
val hexLower =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
// BDI制御文字からその制御文字を閉じる文字を得るためのマップ
val sanitizeBdiMap = HashMap<Char, Char>().apply {
val PDF = 0x202C.toChar() // Pop directional formatting (PDF)
this[0x202A.toChar()] = PDF // Left-to-right embedding (LRE)
this[0x202B.toChar()] = PDF // Right-to-left embedding (RLE)
this[0x202D.toChar()] = PDF // Left-to-right override (LRO)
this[0x202E.toChar()] = PDF // Right-to-left override (RLO)
val PDI = 0x2069.toChar() // Pop directional isolate (PDI)
this[0x2066.toChar()] = PDI // Left-to-right isolate (LRI)
this[0x2067.toChar()] = PDI // Right-to-left isolate (RLI)
this[0x2068.toChar()] = PDI // First strong isolate (FSI)
// private const val ALM = 0x061c.toChar() // Arabic letter mark (ALM)
// private const val LRM = 0x200E.toChar() // Left-to-right mark (LRM)
// private const val RLM = 0x200F.toChar() // Right-to-left mark (RLM)
}
}
////////////////////////////////////////////////////////////////////
// ByteArray
fun ByteArray.encodeBase64Url() : String =
Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
fun ByteArray.encodeBase64Url(): String =
Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
fun ByteArray.digestSHA256() : ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
digest.reset()
return digest.digest(this)
fun ByteArray.digestSHA256(): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
digest.reset()
return digest.digest(this)
}
fun ByteArray.startWith(
key : ByteArray,
thisOffset : Int = 0,
keyOffset : Int = 0,
length : Int = key.size - keyOffset
) : Boolean {
if(this.size - thisOffset >= length && key.size - keyOffset >= length) {
for(i in 0 until length) {
if(this[i + thisOffset] != key[i + keyOffset]) return false
}
return true
}
return false
key: ByteArray,
thisOffset: Int = 0,
keyOffset: Int = 0,
length: Int = key.size - keyOffset
): Boolean {
if (this.size - thisOffset >= length && key.size - keyOffset >= length) {
for (i in 0 until length) {
if (this[i + thisOffset] != key[i + keyOffset]) return false
}
return true
}
return false
}
// 各要素の下位8ビットを使ってバイト配列を作る
fun IntArray.toByteArray() : ByteArray {
val dst = ByteArray(this.size)
for(i in this.indices) {
dst[i] = this[i].toByte()
}
return dst
fun IntArray.toByteArray(): ByteArray {
val dst = ByteArray(this.size)
for (i in this.indices) {
dst[i] = this[i].toByte()
}
return dst
}
// 各要素の下位8ビットを使ってバイト配列を作る
fun CharArray.toLowerByteArray() : ByteArray {
val dst = ByteArray(this.size)
for(i in this.indices) {
dst[i] = this[i].toByte()
}
return dst
fun CharArray.toLowerByteArray(): ByteArray {
val dst = ByteArray(this.size)
for (i in this.indices) {
dst[i] = this[i].toByte()
}
return dst
}
////////////////////////////////////////////////////////////////////
// CharSequence
fun CharSequence.replaceFirst(pattern : Pattern, replacement : String) : String =
pattern.matcher(this).replaceFirst(replacement)
fun CharSequence.replaceFirst(pattern: Pattern, replacement: String): String =
pattern.matcher(this).replaceFirst(replacement)
fun CharSequence.replaceAll(pattern : Pattern, replacement : String) : String =
pattern.matcher(this).replaceAll(replacement)
fun CharSequence.replaceAll(pattern: Pattern, replacement: String): String =
pattern.matcher(this).replaceAll(replacement)
// %1$s を含む文字列リソースを利用して装飾テキストの前後に文字列を追加する
fun CharSequence?.intoStringResource(context : Context, string_id : Int) : Spannable {
val s = context.getString(string_id)
val end = s.length
val pos = s.indexOf("%1\$s")
if(pos == - 1) return SpannableString(s)
val sb = SpannableStringBuilder()
if(pos > 0) sb.append(s.substring(0, pos))
if(this != null) sb.append(this)
if(pos + 4 < end) sb.append(s.substring(pos + 4, end))
return sb
fun CharSequence?.intoStringResource(context: Context, string_id: Int): Spannable {
val s = context.getString(string_id)
val end = s.length
val pos = s.indexOf("%1\$s")
if (pos == -1) return SpannableString(s)
val sb = SpannableStringBuilder()
if (pos > 0) sb.append(s.substring(0, pos))
if (this != null) sb.append(this)
if (pos + 4 < end) sb.append(s.substring(pos + 4, end))
return sb
}
//fun Char.hex2int() : Int {
@ -117,41 +117,41 @@ fun CharSequence?.intoStringResource(context : Context, string_id : Int) : Spann
// return 0
//}
fun CharSequence.codePointBefore(index : Int) : Int {
if(index > 0) {
val c2 = this[index - 1]
if(Character.isLowSurrogate(c2) && index > 1) {
val c1 = this[index - 2]
if(Character.isHighSurrogate(c1)) return Character.toCodePoint(c1, c2)
}
return c2.toInt()
} else {
return - 1
}
fun CharSequence.codePointBefore(index: Int): Int {
if (index > 0) {
val c2 = this[index - 1]
if (Character.isLowSurrogate(c2) && index > 1) {
val c1 = this[index - 2]
if (Character.isHighSurrogate(c1)) return Character.toCodePoint(c1, c2)
}
return c2.toInt()
} else {
return -1
}
}
inline fun <S : CharSequence, Z : Any?> S?.letNotEmpty(block : (S) -> Z?) : Z? =
if(this?.isNotEmpty() == true) {
block(this)
} else {
null
}
inline fun <S : CharSequence, Z : Any?> S?.letNotEmpty(block: (S) -> Z?): Z? =
if (this?.isNotEmpty() == true) {
block(this)
} else {
null
}
// usage: str.notEmpty() ?: fallback
// equivalent: if(this.isNotEmpty() ) this else null
fun <S : CharSequence> S?.notEmpty() : S? = if(this?.isNotEmpty() == true) this else null
fun <S : CharSequence> S?.notEmpty(): S? = if (this?.isNotEmpty() == true) this else null
fun <S : CharSequence> S?.notBlank() : S? = if(this?.isNotBlank() == true) this else null
fun <S : CharSequence> S?.notBlank(): S? = if (this?.isNotBlank() == true) this else null
fun CharSequence.toUri() : Uri = Uri.parse(toString())
fun CharSequence.toUri(): Uri = Uri.parse(toString())
fun CharSequence?.mayUri() : Uri? = try {
if(this?.isNotEmpty() == true)
toUri()
else
null
} catch(ignored : Throwable) {
null
fun CharSequence?.mayUri(): Uri? = try {
if (this?.isNotEmpty() == true)
toUri()
else
null
} catch (ignored: Throwable) {
null
}
////////////////////////////////////////////////////////////////////
@ -166,52 +166,52 @@ fun String.encodeUTF8() = this.toByteArray(charsetUTF8)
fun ByteArray.decodeUTF8() = this.toString(charsetUTF8)
fun String.codePointCount(beginIndex : Int = 0) : Int = this.codePointCount(beginIndex, this.length)
fun String.codePointCount(beginIndex: Int = 0): Int = this.codePointCount(beginIndex, this.length)
// 16進ダンプ
fun ByteArray.encodeHex() : String {
val sb = StringBuilder()
for(b in this) {
sb.appendHex2(b.toInt())
}
return sb.toString()
fun ByteArray.encodeHex(): String {
val sb = StringBuilder()
for (b in this) {
sb.appendHex2(b.toInt())
}
return sb.toString()
}
fun StringBuilder.appendHex2(value : Int) : StringBuilder {
this.append(StringUtils.hexLower[(value shr 4) and 15])
this.append(StringUtils.hexLower[value and 15])
return this
fun StringBuilder.appendHex2(value: Int): StringBuilder {
this.append(StringUtils.hexLower[(value shr 4) and 15])
this.append(StringUtils.hexLower[value and 15])
return this
}
fun ByteArray.encodeHexLower() : String {
val size = this.size
val sb = StringBuilder(size * 2)
for(i in 0 until size) {
val value = this[i].toInt()
sb.append(StringUtils.hexLower[(value shr 4) and 15])
sb.append(StringUtils.hexLower[value and 15])
}
return sb.toString()
fun ByteArray.encodeHexLower(): String {
val size = this.size
val sb = StringBuilder(size * 2)
for (i in 0 until size) {
val value = this[i].toInt()
sb.append(StringUtils.hexLower[(value shr 4) and 15])
sb.append(StringUtils.hexLower[value and 15])
}
return sb.toString()
}
fun String?.optInt() : Int? {
return try {
this?.toInt(10)
} catch(ignored : Throwable) {
null
}
fun String?.optInt(): Int? {
return try {
this?.toInt(10)
} catch (ignored: Throwable) {
null
}
}
fun String?.filterNotEmpty() : String? = when {
this == null -> null
this.isEmpty() -> null
else -> this
fun String?.filterNotEmpty(): String? = when {
this == null -> null
this.isEmpty() -> null
else -> this
}
fun String?.ellipsizeDot3(max : Int) = when {
this == null -> null
this.length > max -> this.substring(0, max - 1) + ""
else -> this
fun String?.ellipsizeDot3(max: Int) = when {
this == null -> null
this.length > max -> this.substring(0, max - 1) + ""
else -> this
}
//fun String.toCamelCase() : String {
@ -224,37 +224,37 @@ fun String?.ellipsizeDot3(max : Int) = when {
// return sb.toString()
//}
fun ellipsize(src : String, limit : Int) : String =
if(src.codePointCount(0, src.length) <= limit) {
src
} else {
"${src.substring(0, src.offsetByCodePoints(0, limit))}"
}
fun ellipsize(src: String, limit: Int): String =
if (src.codePointCount(0, src.length) <= limit) {
src
} else {
"${src.substring(0, src.offsetByCodePoints(0, limit))}"
}
fun String.sanitizeBDI() : String {
// 文字列をスキャンしてBDI制御文字をスタックに入れていく
var stack : LinkedList<Char>? = null
for(c in this) {
val closer = StringUtils.sanitizeBdiMap[c]
if(closer != null) {
if(stack == null) stack = LinkedList()
stack.add(closer)
} else if(stack?.isNotEmpty() == true && stack.last == c) {
stack.removeLast()
}
}
if(stack?.isNotEmpty() == true) {
val sb = StringBuilder(this.length + stack.size)
sb.append(this)
while(! stack.isEmpty()) {
sb.append(stack.removeLast())
}
return sb.toString()
}
return this
fun String.sanitizeBDI(): String {
// 文字列をスキャンしてBDI制御文字をスタックに入れていく
var stack: LinkedList<Char>? = null
for (c in this) {
val closer = StringUtils.sanitizeBdiMap[c]
if (closer != null) {
if (stack == null) stack = LinkedList()
stack.add(closer)
} else if (stack?.isNotEmpty() == true && stack.last == c) {
stack.removeLast()
}
}
if (stack?.isNotEmpty() == true) {
val sb = StringBuilder(this.length + stack.size)
sb.append(this)
while (!stack.isEmpty()) {
sb.append(stack.removeLast())
}
return sb.toString()
}
return this
}
//fun String.dumpCodePoints() : CharSequence {
@ -284,36 +284,36 @@ fun String.sanitizeBDI() : String {
// return md.digest(this.encodeUTF8()).encodeHex()
//}
fun String.digestSHA256Hex() : String {
return this.encodeUTF8().digestSHA256().encodeHex()
fun String.digestSHA256Hex(): String {
return this.encodeUTF8().digestSHA256().encodeHex()
}
fun String.digestSHA256Base64Url() : String {
return this.encodeUTF8().digestSHA256().encodeBase64Url()
fun String.digestSHA256Base64Url(): String {
return this.encodeUTF8().digestSHA256().encodeBase64Url()
}
// Uri.encode(s:Nullable) だと nullチェックができないので、簡単なラッパーを用意する
fun String.encodePercent(allow : String? = null) : String = Uri.encode(this, allow)
fun String.encodePercent(allow: String? = null): String = Uri.encode(this, allow)
// replace + to %20, then decode it.
fun String.decodePercent() : String =
Uri.decode(replace("+", "%20"))
fun String.decodePercent(): String =
Uri.decode(replace("+", "%20"))
////////////////////////////////////////////////////////////////////
// Throwable
fun Throwable.withCaption(fmt : String?, vararg args : Any) =
"${
if(fmt == null || args.isEmpty())
fmt
else
String.format(fmt, *args)
}: ${this.javaClass.simpleName} ${this.message}"
fun Throwable.withCaption(fmt: String?, vararg args: Any) =
"${
if (fmt == null || args.isEmpty())
fmt
else
String.format(fmt, *args)
}: ${this.javaClass.simpleName} ${this.message}"
fun Throwable.withCaption(resources : Resources, string_id : Int, vararg args : Any) =
"${
resources.getString(string_id, *args)
}: ${this.javaClass.simpleName} ${this.message}"
fun Throwable.withCaption(resources: Resources, string_id: Int, vararg args: Any) =
"${
resources.getString(string_id, *args)
}: ${this.javaClass.simpleName} ${this.message}"
////////////////////////////////////////////////////////////////////
// Bundle
@ -329,12 +329,12 @@ fun Throwable.withCaption(resources : Resources, string_id : Int, vararg args :
////////////////////////////////////////////////////////////////
// Pattern
fun Matcher.groupEx(g : Int) : String? =
try {
group(g)
} catch(ex : Throwable) {
null
}
fun Matcher.groupEx(g: Int): String? =
try {
group(g)
} catch (ex: Throwable) {
null
}
// make Array<String> to HashSet<String>
fun <T> Array<T>.toHashSet() = HashSet<T>().also { it.addAll(this) }
@ -342,12 +342,12 @@ fun <T> Array<T>.toHashSet() = HashSet<T>().also { it.addAll(this) }
//fun <T> Iterable<T>.toHashSet() = HashSet<T>().also { it.addAll(this) }
//fun <T> Sequence<T>.toHashSet() = HashSet<T>().also { it.addAll(this) }
fun defaultLocale(context : Context) : Locale =
if(Build.VERSION.SDK_INT >= 24) {
context.resources.configuration.locales[0]
} else {
@Suppress("DEPRECATION")
context.resources.configuration.locale
}
fun defaultLocale(context: Context): Locale =
if (Build.VERSION.SDK_INT >= 24) {
context.resources.configuration.locales[0]
} else {
@Suppress("DEPRECATION")
context.resources.configuration.locale
}
fun Matcher.findOrNull() = if(find()) this else null
fun Matcher.findOrNull() = if (find()) this else null