SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/actmain/SideMenuAdapter.kt

672 lines
26 KiB
Kotlin

package jp.juggler.subwaytooter.actmain
import android.content.Context
import android.content.Intent
import android.graphics.drawable.StateListDrawable
import android.os.Build
import android.os.Handler
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.UnderlineSpan
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.FrameLayout
import android.widget.ListView
import android.widget.TextView
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import jp.juggler.subwaytooter.ActAbout
import jp.juggler.subwaytooter.ActAppSetting
import jp.juggler.subwaytooter.ActFavMute
import jp.juggler.subwaytooter.ActHighlightWordList
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.ActMutedApp
import jp.juggler.subwaytooter.ActMutedPseudoAccount
import jp.juggler.subwaytooter.ActMutedWord
import jp.juggler.subwaytooter.ui.ossLicense.ActOSSLicense
import jp.juggler.subwaytooter.ActPushMessageList
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.accountAdd
import jp.juggler.subwaytooter.action.accountOpenSetting
import jp.juggler.subwaytooter.action.openColumnFromUrl
import jp.juggler.subwaytooter.action.openColumnList
import jp.juggler.subwaytooter.action.serverProfileDirectoryFromSideMenu
import jp.juggler.subwaytooter.action.timeline
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefDevice.Companion.PUSH_DISTRIBUTOR_NONE
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.subwaytooter.push.fcmHandler
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.accountListCanSeeMyReactions
import jp.juggler.subwaytooter.util.VersionString
import jp.juggler.subwaytooter.util.openBrowser
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.decodeUTF8
import jp.juggler.util.data.notEmpty
import jp.juggler.util.getPackageInfoCompat
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.activity
import jp.juggler.util.ui.attrColor
import jp.juggler.util.ui.createColoredDrawable
import kotlinx.coroutines.withContext
import org.jetbrains.anko.backgroundColor
import java.lang.ref.WeakReference
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import kotlin.math.abs
class SideMenuAdapter(
private val actMain: ActMain,
val handler: Handler,
navigationView: ViewGroup,
private val drawer: DrawerLayout,
) : BaseAdapter() {
companion object {
private val log = LogCategory("SideMenuAdapter")
private const val urlAppVersion =
"https://mastodon-msg.juggler.jp/appVersion/appVersion.json"
private const val urlGithubReleases =
"https://github.com/tateisu/SubwayTooter/releases"
private const val urlOlderDevices =
"https://github.com/tateisu/SubwayTooter/discussions/192"
private val itemTypeCount = ItemType.entries.size
private var lastVersionView: WeakReference<TextView>? = null
private var versionText = SpannableStringBuilder("")
private var releaseInfo: JsonObject? = null
private fun clickableSpan(
url: String,
showUnderline: Boolean = false,
) = object : ClickableSpan() {
override fun onClick(widget: View) {
widget.activity?.openBrowser(url)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = showUnderline
}
}
private fun SpannableStringBuilder.appendSpanLine(
text: String,
vararg spans: Any,
) = this.apply {
if (isNotEmpty()) {
append("\n")
}
val start = length
append(text)
for (span in spans) {
setSpan(span, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
// バージョン情報と更新履歴と新リリース告知の文字列を組み立てる
// メインスレッドでもそれ以外でも動作すること
private fun Context.createVersionRow() = SpannableStringBuilder().apply {
val currentVersion = try {
packageManager.getPackageInfoCompat(packageName)!!.versionName
} catch (ignored: Throwable) {
"??"
}
append(
getString(
R.string.app_name_with_version,
getString(R.string.app_name),
currentVersion
)
)
val newRelease = releaseInfo?.jsonObject(
if (PrefB.bpCheckBetaVersion.value) "beta" else "stable"
)
// 使用中のアプリバージョンより新しいリリースがある?
val newVersion =
(newRelease?.string("name")?.notEmpty() ?: newRelease?.string("tag_name"))
?.replace("""(v(ersion)?)\s*""".toRegex(RegexOption.IGNORE_CASE), "")
?.trim()
?.notEmpty()
?.takeIf {
log.i("newVersion=$it, currentVersion=$currentVersion")
VersionString(it) > VersionString(currentVersion)
}
val releaseMinSdkVersion = newRelease?.int("minSdkVersion")
?: Build.VERSION.SDK_INT
val releaseMinSdkVersionScheduled = newRelease?.int("minSdkVersionScheduled")
?: Build.VERSION.SDK_INT
when {
// 新しいバージョンがある
// それはこの端末にインストール可能である
newVersion != null && Build.VERSION.SDK_INT >= releaseMinSdkVersion -> {
appendSpanLine(
getString(
R.string.new_version_available,
newVersion
),
ForegroundColorSpan(
attrColor(R.attr.colorRegexFilterError)
),
)
newRelease?.string("html_url")?.let {
appendSpanLine(
getString(R.string.release_note_with_assets),
clickableSpan(it)
)
}
}
// 通常時は更新履歴へのリンク
else -> appendSpanLine(
getString(R.string.release_note),
UnderlineSpan(),
clickableSpan(urlGithubReleases),
)
}
// 端末のOSバージョンがサポートから外れる予定なら、サイドメニューにリンクを追加する
if (Build.VERSION.SDK_INT < releaseMinSdkVersionScheduled) {
appendSpanLine(
getString(R.string.old_devices_warning),
clickableSpan(urlOlderDevices, showUnderline = true),
)
}
}
// メインスレッドから呼ばれる
private fun Context.checkVersion() {
// サイドメニューから参照されるバージョン文字列を初期化する
// この時点ではreleaseInfoはnullかもしれない
versionText = createVersionRow()
// releaseInfoが既にあり、更新時刻が十分に新しいなら情報を取得し直す必要はない
releaseInfo?.string("updated_at")
?.let { TootStatus.parseTime(it) }
?.takeIf { it >= System.currentTimeMillis() - 86400000L }
?.let { return }
// リリース情報を取得し直す
launchIO {
try {
val json = App1.getHttpCached(urlAppVersion)
?.decodeUTF8()
?.decodeJsonObject()
?: error("missing appVersion json")
releaseInfo = json
versionText = createVersionRow()
withContext(AppDispatchers.MainImmediate) {
lastVersionView?.get()?.text = versionText
}
} catch (ex: Throwable) {
log.e(ex, "checkVersion failed")
}
}
}
}
private enum class ItemType(val id: Int) {
IT_NORMAL(0),
IT_GROUP_HEADER(1),
IT_DIVIDER(2),
IT_VERSION(3),
IT_TIMEZONE(4),
IT_NOTIFICATION_PERMISSION(5),
}
private class Item(
// 項目の文字列リソース or 0: divider, 1: バージョン表記, 2: タイムゾーン
val title: Int = 0,
val icon: Int = 0,
val action: ActMain.() -> Unit = {},
) {
val itemType: ItemType
get() = when {
title == 0 -> ItemType.IT_DIVIDER
title == 1 -> ItemType.IT_VERSION
title == 2 -> ItemType.IT_TIMEZONE
title == 3 -> ItemType.IT_NOTIFICATION_PERMISSION
icon == 0 -> ItemType.IT_GROUP_HEADER
else -> ItemType.IT_NORMAL
}
}
/*
no title => section divider
else no icon => section header with title
else => menu item with icon and title
*/
private val originalList = listOf(
Item(icon = R.drawable.ic_info_outline, title = 1),
Item(icon = R.drawable.ic_info_outline, title = 2),
Item(icon = R.drawable.ic_info_outline, title = 3),
Item(),
Item(title = R.string.account),
Item(title = R.string.account_add, icon = R.drawable.ic_person_add) {
accountAdd()
},
Item(icon = R.drawable.ic_settings, title = R.string.account_setting) {
accountOpenSetting()
},
Item(icon = R.drawable.outline_delivery_dining_24, title = R.string.push_message_history) {
startActivity(Intent(this, ActPushMessageList::class.java))
},
Item(),
Item(title = R.string.column),
Item(icon = R.drawable.ic_list_numbered, title = R.string.column_list) {
openColumnList()
},
Item(icon = R.drawable.ic_close, title = R.string.close_all_columns) {
closeColumnAll()
},
Item(icon = R.drawable.ic_paste, title = R.string.open_column_from_url) {
openColumnFromUrl()
},
Item(icon = R.drawable.ic_home, title = R.string.home) {
timeline(defaultInsertPosition, ColumnType.HOME)
},
Item(icon = R.drawable.ic_announcement, title = R.string.notifications) {
timeline(defaultInsertPosition, ColumnType.NOTIFICATIONS)
},
Item(icon = R.drawable.ic_mail, title = R.string.direct_messages) {
timeline(defaultInsertPosition, ColumnType.DIRECT_MESSAGES)
},
Item(icon = R.drawable.ic_share, title = R.string.misskey_hybrid_timeline_long) {
timeline(defaultInsertPosition, ColumnType.MISSKEY_HYBRID)
},
Item(icon = R.drawable.ic_run, title = R.string.local_timeline) {
timeline(defaultInsertPosition, ColumnType.LOCAL)
},
Item(icon = R.drawable.ic_bike, title = R.string.federate_timeline) {
timeline(defaultInsertPosition, ColumnType.FEDERATE)
},
Item(icon = R.drawable.ic_list_list, title = R.string.lists) {
timeline(defaultInsertPosition, ColumnType.LIST_LIST)
},
Item(icon = R.drawable.ic_satellite, title = R.string.antenna_list_misskey) {
timeline(defaultInsertPosition, ColumnType.MISSKEY_ANTENNA_LIST)
},
Item(icon = R.drawable.ic_hashtag, title = R.string.followed_tags) {
timeline(defaultInsertPosition, ColumnType.FOLLOWED_HASHTAGS)
},
Item(icon = R.drawable.ic_search, title = R.string.search) {
timeline(defaultInsertPosition, ColumnType.SEARCH, args = arrayOf("", false))
},
Item(icon = R.drawable.ic_trend, title = R.string.trend_tag) {
timeline(defaultInsertPosition, ColumnType.TREND_TAG)
},
Item(icon = R.drawable.ic_trend, title = R.string.trend_link) {
timeline(defaultInsertPosition, ColumnType.TREND_LINK)
},
Item(icon = R.drawable.ic_trend, title = R.string.trend_post) {
timeline(defaultInsertPosition, ColumnType.TREND_POST)
},
Item(icon = R.drawable.ic_star_outline, title = R.string.favourites) {
timeline(defaultInsertPosition, ColumnType.FAVOURITES)
},
Item(icon = R.drawable.ic_bookmark, title = R.string.bookmarks) {
timeline(defaultInsertPosition, ColumnType.BOOKMARKS)
},
Item(icon = R.drawable.ic_face, title = R.string.reactioned_posts) {
launchAndShowError {
accountListCanSeeMyReactions()?.let { list ->
if (list.isEmpty()) {
showToast(false, R.string.not_available_for_current_accounts)
} else {
val columnType = ColumnType.REACTIONS
pickAccount(
accountListArg = list.toMutableList(),
bAuto = true,
message = getString(
R.string.account_picker_add_timeline_of,
columnType.name1(applicationContext)
)
)?.let { addColumn(defaultInsertPosition, it, columnType) }
}
}
}
},
Item(icon = R.drawable.ic_account_box, title = R.string.profile) {
timeline(defaultInsertPosition, ColumnType.PROFILE)
},
Item(icon = R.drawable.ic_follow_wait, title = R.string.follow_requests) {
timeline(defaultInsertPosition, ColumnType.FOLLOW_REQUESTS)
},
Item(icon = R.drawable.ic_person_add, title = R.string.follow_suggestion) {
timeline(defaultInsertPosition, ColumnType.FOLLOW_SUGGESTION)
},
Item(icon = R.drawable.ic_person_add, title = R.string.endorse_set) {
timeline(defaultInsertPosition, ColumnType.ENDORSEMENT)
},
Item(icon = R.drawable.ic_person_add, title = R.string.profile_directory) {
serverProfileDirectoryFromSideMenu()
},
Item(icon = R.drawable.ic_volume_off, title = R.string.muted_users) {
timeline(defaultInsertPosition, ColumnType.MUTES)
},
Item(icon = R.drawable.ic_block, title = R.string.blocked_users) {
timeline(defaultInsertPosition, ColumnType.BLOCKS)
},
Item(icon = R.drawable.ic_volume_off, title = R.string.keyword_filters) {
timeline(defaultInsertPosition, ColumnType.KEYWORD_FILTER)
},
Item(icon = R.drawable.ic_cloud_off, title = R.string.blocked_domains) {
timeline(defaultInsertPosition, ColumnType.DOMAIN_BLOCKS)
},
Item(icon = R.drawable.ic_timer, title = R.string.scheduled_status_list) {
timeline(defaultInsertPosition, ColumnType.SCHEDULED_STATUS)
},
Item(),
Item(title = R.string.toot_search),
// Item(icon = R.drawable.ic_search, title = R.string.mastodon_search_portal) {
// addColumn(defaultInsertPosition, SavedAccount.na, ColumnType.SEARCH_MSP, "")
// },
// Item(icon = R.drawable.ic_search, title = R.string.tootsearch) {
// addColumn(defaultInsertPosition, SavedAccount.na, ColumnType.SEARCH_TS, "")
// },
Item(icon = R.drawable.ic_search, title = R.string.notestock) {
addColumn(
defaultInsertPosition,
SavedAccount.na,
ColumnType.SEARCH_NOTESTOCK,
params = arrayOf("")
)
},
Item(),
Item(title = R.string.setting),
Item(icon = R.drawable.ic_settings, title = R.string.app_setting) {
arAppSetting.launch(
ActAppSetting.createIntent(this)
)
},
Item(icon = R.drawable.ic_settings, title = R.string.highlight_word) {
startActivity(Intent(this, ActHighlightWordList::class.java))
},
Item(icon = R.drawable.ic_volume_off, title = R.string.muted_app) {
startActivity(Intent(this, ActMutedApp::class.java))
},
Item(icon = R.drawable.ic_volume_off, title = R.string.muted_word) {
startActivity(Intent(this, ActMutedWord::class.java))
},
Item(icon = R.drawable.ic_volume_off, title = R.string.fav_muted_user) {
startActivity(Intent(this, ActFavMute::class.java))
},
Item(
icon = R.drawable.ic_volume_off,
title = R.string.muted_users_from_pseudo_account
) {
startActivity(Intent(this, ActMutedPseudoAccount::class.java))
},
Item(icon = R.drawable.ic_info_outline, title = R.string.app_about) {
arAbout.launch(
Intent(this, ActAbout::class.java)
)
},
Item(icon = R.drawable.ic_info_outline, title = R.string.oss_license) {
startActivity(Intent(this, ActOSSLicense::class.java))
},
Item(icon = R.drawable.ic_hot_tub, title = R.string.app_exit) {
finish()
}
)
private var list = originalList
private val iconColor = actMain.attrColor(R.attr.colorTimeSmall)
override fun getCount(): Int = list.size
override fun getItem(position: Int): Any = list[position]
override fun getItemId(position: Int): Long = 0L
override fun getViewTypeCount(): Int = itemTypeCount
override fun getItemViewType(position: Int): Int = list[position].itemType.id
private inline fun <reified T : View> viewOrInflate(
view: View?,
parent: ViewGroup?,
resId: Int,
): T =
(view ?: actMain.layoutInflater.inflate(resId, parent, false))
as? T ?: error("invalid view type! ${T::class.java.simpleName}")
override fun getView(position: Int, view: View?, parent: ViewGroup?): View =
list[position].run {
when (itemType) {
ItemType.IT_DIVIDER ->
viewOrInflate(view, parent, R.layout.lv_sidemenu_separator)
ItemType.IT_GROUP_HEADER ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_group).apply {
text = actMain.getString(title)
}
ItemType.IT_NORMAL ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
isAllCaps = false
text = actMain.getString(title)
val drawable = createColoredDrawable(actMain, icon, iconColor, 1f)
setCompoundDrawablesRelativeWithIntrinsicBounds(
drawable,
null,
null,
null
)
setOnClickListener {
action(actMain)
drawer.closeDrawer(GravityCompat.START)
}
}
ItemType.IT_VERSION ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
lastVersionView = WeakReference(this)
movementMethod = LinkMovementMethod.getInstance()
textSize = 18f
isAllCaps = false
setLineSpacing(
1f,
1.1f
)
background = null
text = versionText
}
ItemType.IT_TIMEZONE ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
textSize = 14f
isAllCaps = false
background = null
text = getTimeZoneString(context)
}
ItemType.IT_NOTIFICATION_PERMISSION ->
viewOrInflate<TextView>(view, parent, R.layout.lv_sidemenu_item).apply {
isAllCaps = false
val action = notificationActionRecommend() ?: return@apply
text = actMain.getString(action.first)
val drawable = createColoredDrawable(actMain, icon, iconColor, 1f)
setCompoundDrawablesRelativeWithIntrinsicBounds(
drawable,
null,
null,
null
)
setOnClickListener {
drawer.closeDrawer(GravityCompat.START)
notificationActionRecommend()?.second?.invoke()
filterListItems()
}
}
}
}
private fun getTimeZoneString(context: Context): String {
try {
var tz = TimeZone.getDefault()
val tzId = PrefS.spTimeZone.value
if (tzId.isBlank()) {
return tz.displayName + "(" + context.getString(R.string.device_timezone) + ")"
}
tz = TimeZone.getTimeZone(tzId)
var offset = tz.rawOffset.toLong()
return when (offset) {
0L -> "(UTC\u00B100:00) ${tz.id} ${tz.displayName}"
else -> {
val format = when {
offset > 0 -> "(UTC+%02d:%02d) %s %s"
else -> "(UTC-%02d:%02d) %s %s"
}
offset = abs(offset)
val hours = TimeUnit.MILLISECONDS.toHours(offset)
val minutes =
TimeUnit.MILLISECONDS.toMinutes(offset) - TimeUnit.HOURS.toMinutes(hours)
String.format(format, hours, minutes, tz.id, tz.displayName)
}
}
} catch (ex: Throwable) {
log.w(ex, "getTimeZoneString failed.")
return "(incorrect TimeZone)"
}
}
fun onActivityStart() {
this.notifyDataSetChanged()
}
private fun notificationActionRecommend(): Pair<Int, () -> Unit>? = when {
// 通知権限がない場合の警告とアクション
actMain.prNotification.spec.listNotGranded(actMain).isNotEmpty() ->
Pair(R.string.notification_permission_not_granted) {
actMain.prNotification.openAppSetting(actMain)
}
// プッシュ配送が選択されていない場合の警告とアクション
(actMain.prefDevice.pushDistributor.isNullOrEmpty() && fcmHandler.noFcm(actMain)) ||
actMain.prefDevice.pushDistributor == PUSH_DISTRIBUTOR_NONE ->
Pair(R.string.notification_push_distributor_disabled) {
actMain.selectPushDistributor()
}
else -> null
}
fun filterListItems(notify: Boolean = true) {
log.i("filterListItems notify=$notify")
list = originalList.filter {
when (it.itemType) {
ItemType.IT_NOTIFICATION_PERMISSION ->
notificationActionRecommend() != null
ItemType.IT_NORMAL -> when (it.title) {
R.string.antenna_list_misskey,
R.string.misskey_hybrid_timeline_long,
-> PrefB.bpEnableDeprecatedSomething.value
else -> true
}
else -> true
}
}
if (notify) notifyDataSetChanged()
}
init {
actMain.applicationContext.checkVersion()
filterListItems(notify = false)
ListView(actMain).apply {
adapter = this@SideMenuAdapter
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
backgroundColor = actMain.attrColor(R.attr.colorWindowBackground)
selector = StateListDrawable()
divider = null
dividerHeight = 0
isScrollbarFadingEnabled = false
val padV = (actMain.density * 12f + 0.5f).toInt()
setPadding(0, padV, 0, padV)
clipToPadding = false
scrollBarStyle = ListView.SCROLLBARS_OUTSIDE_OVERLAY
navigationView.addView(this)
}
}
}