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? = 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 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(view, parent, R.layout.lv_sidemenu_group).apply { text = actMain.getString(title) } ItemType.IT_NORMAL -> viewOrInflate(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(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(view, parent, R.layout.lv_sidemenu_item).apply { textSize = 14f isAllCaps = false background = null text = getTimeZoneString(context) } ItemType.IT_NOTIFICATION_PERMISSION -> viewOrInflate(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 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) } } }