package jp.juggler.subwaytooter import android.app.Activity import android.app.Dialog import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Typeface import android.net.Uri import android.os.* import android.text.InputType import android.text.Spannable import android.text.SpannableStringBuilder import android.util.JsonReader import android.view.* import android.view.inputmethod.EditorInfo import android.widget.* import androidx.appcompat.app.AlertDialog import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager.widget.ViewPager import jp.juggler.subwaytooter.action.* import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromUrl import jp.juggler.subwaytooter.api.entity.TootTag.Companion.findHashtagFromUrl import jp.juggler.subwaytooter.dialog.AccountPicker import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgQuickTootMenu import jp.juggler.subwaytooter.dialog.DlgTextInput import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.view.* import jp.juggler.util.* import kotlinx.coroutines.delay import org.apache.commons.io.IOUtils import org.jetbrains.anko.backgroundDrawable import org.jetbrains.anko.imageResource import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStreamReader import java.lang.ref.WeakReference import java.util.* import java.util.zip.ZipInputStream import kotlin.math.abs import kotlin.math.max import kotlin.math.min class ActMain : AsyncActivity() , Column.Callback , View.OnClickListener , ViewPager.OnPageChangeListener , DrawerLayout.DrawerListener { class PhoneEnv { internal lateinit var pager : MyViewPager internal lateinit var pager_adapter : ColumnPagerAdapter } class TabletEnv { internal lateinit var tablet_pager : RecyclerView internal lateinit var tablet_pager_adapter : TabletColumnPagerAdapter internal lateinit var tablet_layout_manager : LinearLayoutManager internal lateinit var tablet_snap_helper : GravitySnapHelper } companion object { val log = LogCategory("ActMain") // リザルト const val RESULT_APP_DATA_IMPORT = Activity.RESULT_FIRST_USER // リクエスト const val REQUEST_CODE_COLUMN_LIST = 1 const val REQUEST_CODE_ACCOUNT_SETTING = 2 const val REQUEST_APP_ABOUT = 3 const val REQUEST_CODE_NICKNAME = 4 const val REQUEST_CODE_POST = 5 const val REQUEST_CODE_COLUMN_COLOR = 6 const val REQUEST_CODE_APP_SETTING = 7 const val REQUEST_CODE_TEXT = 8 const val REQUEST_CODE_LANGUAGE_FILTER = 9 const val COLUMN_WIDTH_MIN_DP = 300 const val STATE_CURRENT_PAGE = "current_page" // 外部からインテントを受信した後、アカウント選択中に画面回転したらアカウント選択からやり直す internal var sent_intent2 : Intent? = null // アプリ設定のキャッシュ var boostButtonSize = 1 var replyIconSize = 1 var headerIconSize = 1 var stripIconSize = 1 var screenBottomPadding = 0 var timeline_font : Typeface = Typeface.DEFAULT var timeline_font_bold : Typeface = Typeface.DEFAULT_BOLD private fun Float.clipFontSize() : Float = if(isNaN()) this else max(1f, this) } // アプリ設定のキャッシュ var density = 0f var acct_pad_lr = 0 var timeline_font_size_sp = Float.NaN var acct_font_size_sp = Float.NaN var notification_tl_font_size_sp = Float.NaN var header_text_size_sp = Float.NaN var timeline_spacing : Float? = null var avatarIconSize : Int = 0 var notificationTlIconSize : Int = 0 // onResume() .. onPause() の間なら真 private var isResumed = false // onStart() .. onStop() の間なら真 private var isStart_ = false // onActivityResultで設定されてonResumeで消化される // 状態保存の必要なし private var posted_acct : Acct? = null // acctAscii private var posted_status_id : EntityId? = null private var posted_reply_id : EntityId? = null private var posted_redraft_id : EntityId? = null // 画面上のUI操作で生成されて // onPause,onPageDestroy 等のタイミングで閉じられる // 状態保存の必要なし internal var listItemPopup : StatusButtonsPopup? = null private var phoneEnv : PhoneEnv? = null private var tabletEnv : TabletEnv? = null private var nScreenColumn : Int = 0 private var nColumnWidth : Int = 0 // dividerの幅を含む private var nAutoCwCellWidth = 0 private var nAutoCwLines = 0 private var dlgPrivacyPolicy : WeakReference? = null private var quickTootVisibility : TootVisibility = TootVisibility.AccountSetting ////////////////////////////////////////////////////////////////// // 変更しない変数(lateinit) private lateinit var llQuickTootBar : LinearLayout private lateinit var etQuickToot : MyEditText private lateinit var btnQuickToot : ImageButton private lateinit var btnQuickTootMenu : ImageButton private lateinit var llEmpty : View private lateinit var llColumnStrip : ColumnStripLinearLayout private lateinit var svColumnStrip : HorizontalScrollView private lateinit var btnMenu : ImageButton private lateinit var btnToot : ImageButton private lateinit var vFooterDivider1 : View private lateinit var vFooterDivider2 : View lateinit var drawer : MyDrawerLayout lateinit var post_helper : PostHelper lateinit var pref : SharedPreferences lateinit var handler : Handler lateinit var app_state : AppState ////////////////////////////////////////////////////////////////// // 変更しない変数 val follow_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.follow_succeeded) } val unfollow_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.unfollow_succeeded) } val cancel_follow_request_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.follow_request_cancelled) } val favourite_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.favourite_succeeded) } val unfavourite_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.unfavourite_succeeded) } val bookmark_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.bookmark_succeeded) } val unbookmark_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.unbookmark_succeeded) } val boost_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.boost_succeeded) } val unboost_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.unboost_succeeded) } val reaction_complete_callback : EmptyCallback = { showToast(this@ActMain, false, R.string.reaction_succeeded) } // 相対時刻の表記を定期的に更新する private val proc_updateRelativeTime = object : Runnable { override fun run() { handler.removeCallbacks(this) if(! isStart_) return if(Pref.bpRelativeTimestamp(pref)) { for(c in app_state.column_list) { c.fireRelativeTime() } handler.postDelayed(this, 10000L) } } } private val link_click_listener : (View, MyClickableSpan) -> Unit = { viewClicked, span -> val linkInfo = span.linkInfo var view = viewClicked var column : Column? = null var whoRef : TootAccountRef? = null while(true) { val tag = view.tag if(tag is ItemViewHolder) { column = tag.column whoRef = tag.getAccount() break } else if(tag is ViewHolderItem) { column = tag.ivh.column whoRef = tag.ivh.getAccount() break } else if(tag is ColumnViewHolder) { column = tag.column whoRef = null break } else if(tag is ViewHolderHeaderBase) { column = tag.column whoRef = tag.getAccount() break } else if(tag is TabletColumnViewHolder) { column = tag.columnViewHolder.column break } else { val parent = view.parent if(parent is View) { view = parent } else { break } } } val pos = nextPosition(column) val access_info = column?.access_info var tag_list : ArrayList? = null try { val cs = (viewClicked as TextView).text if(cs is Spannable) { for(s in cs.getSpans(0, cs.length, MyClickableSpan::class.java)) { val li = s.linkInfo val pair = li.url.findHashtagFromUrl() if(pair != null) { if(tag_list == null) tag_list = ArrayList() tag_list.add(if(li.text.startsWith('#')) li.text else "#${pair.first}") } } } } catch(ex : Throwable) { log.trace(ex) } ChromeTabOpener( this@ActMain, pos, linkInfo.url, accessInfo = access_info, tagList = tag_list, whoRef = whoRef, linkInfo = linkInfo ).open() } private fun showQuickTootVisibility() { btnQuickTootMenu.imageResource = when(val resId = Styler.getVisibilityIconId(false, quickTootVisibility)) { R.drawable.ic_question -> R.drawable.ic_description else -> resId } } private fun performQuickTootMenu() { dlgQuickTootMenu.toggle() } private val dlgQuickTootMenu = DlgQuickTootMenu(this, object : DlgQuickTootMenu.Callback { override var visibility : TootVisibility get() = quickTootVisibility set(value) { if(value != quickTootVisibility) { quickTootVisibility = value pref.edit().put(Pref.spQuickTootVisibility, value.id.toString()).apply() showQuickTootVisibility() } } override fun onMacro(text : String) { val editable = etQuickToot.text if(editable?.isNotEmpty() == true) { val start = etQuickToot.selectionStart val end = etQuickToot.selectionEnd editable.replace(start, end, text) etQuickToot.requestFocus() etQuickToot.setSelection(start + text.length) } else { etQuickToot.setText(text) etQuickToot.requestFocus() etQuickToot.setSelection(text.length) } } }) val viewPool = RecyclerView.RecycledViewPool() ////////////////////////////////////////////////////////////////// // 読み取り専用のプロパティ override val isActivityStart : Boolean get() = isStart_ // スマホモードなら現在のカラムを、タブレットモードなら-1Lを返す // (カラム一覧画面のデフォルト選択位置に使われる) val currentColumn : Int get() = phoneTab( { it.pager.currentItem }, { - 1 } ) // 新しいカラムをどこに挿入するか // 現在のページの次の位置か、終端 val defaultInsertPosition : Int get() = phoneTab( { it.pager.currentItem + 1 }, { Integer.MAX_VALUE } ) private val TabletEnv.visibleColumnsIndices : IntRange get() { var vs = tablet_layout_manager.findFirstVisibleItemPosition() var ve = tablet_layout_manager.findLastVisibleItemPosition() if(vs == RecyclerView.NO_POSITION || ve == RecyclerView.NO_POSITION) { return IntRange(- 1, - 2) // empty and less than zero } val child = tablet_layout_manager.findViewByPosition(vs) val slide_ratio = clipRange(0f, 1f, abs((child?.left ?: 0) / nColumnWidth.toFloat())) if(slide_ratio >= 0.95f) { ++ vs ++ ve } return IntRange(vs, min(ve, vs + nScreenColumn - 1)) } private val TabletEnv.visibleColumns : List get() = visibleColumnsIndices .mapNotNull { try { app_state.column_list[it] } catch(ex : Throwable) { null } } // デフォルトの投稿先アカウントを探す。アカウント選択が必要な状況ならnull val currentPostTarget : SavedAccount? get() = phoneTab( { env -> val c = env.pager_adapter.getColumn(env.pager.currentItem) return when { c == null || c.access_info.isPseudo -> null else -> c.access_info } }, { env -> val db_id = Pref.lpTabletTootDefaultAccount(App1.pref) if(db_id != - 1L) { val a = SavedAccount.loadAccount(this@ActMain, db_id) if(a != null && ! a.isPseudo) return a } val accounts = ArrayList() for(c in env.visibleColumns) { try { val a = c.access_info // 画面内に疑似アカウントがあれば常にアカウント選択が必要 if(a.isPseudo) { accounts.clear() break } // 既出でなければ追加する if(null == accounts.find { it == a }) accounts.add(a) } catch(ex : Throwable) { } } return when(accounts.size) { // 候補が1つだけならアカウント選択は不要 1 -> accounts.first() // 候補が2つ以上ならアカウント選択は必要 else -> null } }) // 簡易投稿入力のテキスト val quickTootText : String get() = etQuickToot.text.toString() ////////////////////////////////////////////////////////////////// // アクティビティイベント override fun onCreate(savedInstanceState : Bundle?) { log.d("onCreate") super.onCreate(savedInstanceState) requestWindowFeature(Window.FEATURE_NO_TITLE) App1.setActivityTheme(this, noActionBar = true) handler = Handler() app_state = App1.getAppState(this) pref = App1.pref density = app_state.density acct_pad_lr = (0.5f + 4f * density).toInt() timeline_font_size_sp = Pref.fpTimelineFontSize(pref).clipFontSize() acct_font_size_sp = Pref.fpAcctFontSize(pref).clipFontSize() notification_tl_font_size_sp = Pref.fpNotificationTlFontSize(pref).clipFontSize() header_text_size_sp = Pref.fpHeaderTextSize(pref).clipFontSize() val fv = Pref.spTimelineSpacing(pref).toFloatOrNull() timeline_spacing = if(fv != null && fv.isFinite() && fv != 0f) fv else null initUI() updateColumnStrip() if(app_state.column_list.isNotEmpty()) { // 前回最後に表示していたカラムの位置にスクロールする val column_pos = Pref.ipLastColumnPos(pref) log.d("ipLastColumnPos load $column_pos") if(column_pos in 0 until app_state.column_list.size) { scrollToColumn(column_pos, false) } // 表示位置に合わせたイベントを発行 phoneTab( { env -> onPageSelected(env.pager.currentItem) }, { env -> resizeColumnWidth(env) } ) } PollingWorker.queueUpdateNotification(this) if(savedInstanceState != null) { sent_intent2?.let { handleSentIntent(it) } } checkPrivacyPolicy() } override fun onDestroy() { log.d("onDestroy") super.onDestroy() post_helper.onDestroy() // このアクティビティに関連する ColumnViewHolder への参照を全カラムから除去する for(c in app_state.column_list) { c.removeColumnViewHolderByActivity(this) } } override fun onNewIntent(intent : Intent?) { super.onNewIntent(intent) log.w("onNewIntent: isResumed = isResumed") } override fun onConfigurationChanged(newConfig : Configuration) { log.d("onConfigurationChanged") super.onConfigurationChanged(newConfig) if(newConfig.screenHeightDp > 0 || newConfig.screenHeightDp > 0) { tabOnly { env -> resizeColumnWidth(env) } } } override fun onSaveInstanceState(outState : Bundle) { super.onSaveInstanceState(outState) log.d("onSaveInstanceState") phoneTab( { env -> outState.putInt(STATE_CURRENT_PAGE, env.pager.currentItem) }, { env -> env.tablet_layout_manager.findLastVisibleItemPosition() .takeIf { it != RecyclerView.NO_POSITION } ?.let { outState.putInt(STATE_CURRENT_PAGE, it) } } ) for(column in app_state.column_list) { column.saveScrollPosition() } } override fun onRestoreInstanceState(savedInstanceState : Bundle) { log.d("onRestoreInstanceState") super.onRestoreInstanceState(savedInstanceState) val pos = savedInstanceState.getInt(STATE_CURRENT_PAGE) if(pos > 0 && pos < app_state.column_list.size) { phoneTab( { env -> env.pager.currentItem = pos }, { env -> env.tablet_layout_manager .smoothScrollToPosition(env.tablet_pager, null, pos) } ) } } override fun onStart() { val tsTotal = SystemClock.elapsedRealtime() super.onStart() isStart_ = true log.d("onStart") var ts = SystemClock.elapsedRealtime() var te : Long // カラーカスタマイズを読み直す ListDivider.color = Pref.ipListDividerColor(pref) TabletColumnDivider.color = Pref.ipListDividerColor(pref) ItemViewHolder.toot_color_unlisted = Pref.ipTootColorUnlisted(pref) ItemViewHolder.toot_color_follower = Pref.ipTootColorFollower(pref) ItemViewHolder.toot_color_direct_user = Pref.ipTootColorDirectUser(pref) ItemViewHolder.toot_color_direct_me = Pref.ipTootColorDirectMe(pref) MyClickableSpan.showLinkUnderline = Pref.bpShowLinkUnderline(pref) MyClickableSpan.defaultLinkColor = Pref.ipLinkColor(pref).notZero() ?: getAttributeColor(this, R.attr.colorLink) CustomShare.reloadCache(this, pref) te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms : reload color") ts = SystemClock.elapsedRealtime() var tz = TimeZone.getDefault() try { val tz_id = Pref.spTimeZone(pref) if(tz_id.isNotEmpty()) { tz = TimeZone.getTimeZone(tz_id) } } catch(ex : Throwable) { log.e(ex, "getTimeZone failed.") } TootStatus.date_format.timeZone = tz te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms : reload timezone") ts = SystemClock.elapsedRealtime() // バグいアカウントデータを消す try { SavedAccount.sweepBuggieData() } catch(ex : Throwable) { log.trace(ex) } te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms : sweepBuggieData") ts = SystemClock.elapsedRealtime() // アカウント設定から戻ってきたら、カラムを消す必要があるかもしれない val new_order = ArrayList() for(i in 0 until app_state.column_list.size) { val column = app_state.column_list[i] if(! column.access_info.isNA) { // 存在確認 SavedAccount.loadAccount(this@ActMain, column.access_info.db_id) ?: continue } new_order.add(i) } if(new_order.size != app_state.column_list.size) { setOrder(new_order) } te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms : column order") ts = SystemClock.elapsedRealtime() // 背景画像を表示しない設定が変更された時にカラムの背景を設定しなおす for(column in app_state.column_list) { column.viewHolder?.lastAnnouncementShown = 0L column.fireColumnColor() } te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :fireColumnColor") ts = SystemClock.elapsedRealtime() // 各カラムのアカウント設定を読み直す reloadAccountSetting() te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :reloadAccountSetting") ts = SystemClock.elapsedRealtime() // 投稿直後ならカラムの再取得を行う refreshAfterPost() te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :refreshAfterPost") ts = SystemClock.elapsedRealtime() // 画面復帰時に再取得やストリーミング開始を行う for(column in app_state.column_list) { column.onStart(this) } te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :column.onStart") ts = SystemClock.elapsedRealtime() // カラムの表示範囲インジケータを更新 updateColumnStripSelection(- 1, - 1f) te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :updateColumnStripSelection") ts = SystemClock.elapsedRealtime() for(c in app_state.column_list) { c.fireShowContent(reason = "ActMain onStart", reset = true) } te = SystemClock.elapsedRealtime() if(te - ts >= 100L) log.w("onStart: ${te - ts}ms :fireShowContent") // 相対時刻表示 proc_updateRelativeTime.run() te = SystemClock.elapsedRealtime() if(te - tsTotal >= 100L) log.w("onStart: ${te - tsTotal}ms : total") app_state.enableSpeech() } override fun onStop() { log.d("onStop") isStart_ = false handler.removeCallbacks(proc_updateRelativeTime) post_helper.closeAcctPopup() closeListItemPopup() app_state.stream_reader.stopAll() for(column in app_state.column_list) { column.saveScrollPosition() } app_state.saveColumnList(bEnableSpeech = false) super.onStop() } override fun onResume() { log.d("onResume") isResumed = true super.onResume() /* super.onResume() から呼ばれる isTopOfTask() が android.os.RemoteException 例外をたまに出すが、放置することにした。 java.lang.RuntimeException: at android.app.ActivityThread.performResumeActivity (ActivityThread.java:4430) at android.app.ActivityThread.handleResumeActivity (ActivityThread.java:4470) at android.app.servertransaction.TransactionExecutor.performLifecycleSequence (TransactionExecutor.java:183) at android.app.servertransaction.TransactionExecutor.cycleToPath (TransactionExecutor.java:165) at android.app.servertransaction.TransactionExecutor.executeLifecycleState (TransactionExecutor.java:142) at android.app.servertransaction.TransactionExecutor.execute (TransactionExecutor.java:70) at android.app.ActivityThread$H.handleMessage (ActivityThread.java:2199) at android.os.Handler.dispatchMessage (Handler.java:112) at android.os.Looper.loop (Looper.java:216) at android.app.ActivityThread.main (ActivityThread.java:7625) at java.lang.reflect.Method.invoke (Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:524) at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:987) Caused by: java.lang.IllegalArgumentException: at android.os.Parcel.createException (Parcel.java:1957) at android.os.Parcel.readException (Parcel.java:1921) at android.os.Parcel.readException (Parcel.java:1871) at android.app.IActivityManager$Stub$Proxy.isTopOfTask (IActivityManager.java:7912) at android.app.Activity.isTopOfTask (Activity.java:6724) at android.app.Activity.onResume (Activity.java:1425) at androidx.fragment.app.FragmentActivity.onResume (FragmentActivity.java:456) at jp.juggler.subwaytooter.ActMain.onResume (ActMain.kt:685) at android.app.Instrumentation.callActivityOnResume (Instrumentation.java:1456) at android.app.Activity.performResume (Activity.java:7614) at android.app.ActivityThread.performResumeActivity (ActivityThread.java:4412) Caused by: android.os.RemoteException: at com.android.server.am.ActivityManagerService.isTopOfTask (ActivityManagerService.java:16128) at android.app.IActivityManager$Stub.onTransact (IActivityManager.java:2376) at com.android.server.am.ActivityManagerService.onTransact (ActivityManagerService.java:3648) at com.android.server.am.HwActivityManagerService.onTransact (HwActivityManagerService.java:609) at android.os.Binder.execTransact (Binder.java:739) */ MyClickableSpan.link_callback = WeakReference(link_click_listener) if(Pref.bpDontScreenOff(pref)) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } // 外部から受け取ったUriの処理 val uri = ActCallback.last_uri.getAndSet(null) if(uri != null) { handleIntentUri(uri) } // 外部から受け取ったUriの処理 val intent = ActCallback.sent_intent.getAndSet(null) if(intent != null) { handleSentIntent(intent) } } override fun onPause() { log.d("onPause") isResumed = false // 最後に表示していたカラムの位置 val last_pos = phoneTab( { env -> env.pager.currentItem }, { env -> env.visibleColumnsIndices.first }) log.d("ipLastColumnPos save $last_pos") pref.edit().put(Pref.ipLastColumnPos, last_pos).apply() for(column in app_state.column_list) { column.saveScrollPosition() } app_state.saveColumnList(bEnableSpeech = false) super.onPause() } ////////////////////////////////////////////////////////////////// // UIイベント override fun onPageScrollStateChanged(state : Int) { } override fun onPageScrolled( position : Int, positionOffset : Float, positionOffsetPixels : Int ) { updateColumnStripSelection(position, positionOffset) } override fun onPageSelected(position : Int) { handler.post { if(position >= 0 && position < app_state.column_list.size) { val column = app_state.column_list[position] if(! column.bFirstInitialized) { column.startLoading() } scrollColumnStrip(position) post_helper.setInstance( when { column.access_info.isNA -> null else -> column.access_info } ) } } } override fun onClick(v : View) { when(v.id) { R.id.btnMenu -> if(! drawer.isDrawerOpen(GravityCompat.START)) { drawer.openDrawer(GravityCompat.START) } R.id.btnToot -> Action_Account.openPost(this@ActMain) R.id.btnQuickToot -> performQuickPost(null) R.id.btnQuickTootMenu -> performQuickTootMenu() } } //////////////////////////////////////////////////////////////////// // スマホモードとタブレットモードでコードを切り替える private inline fun phoneTab( codePhone : (PhoneEnv) -> R, codeTablet : (TabletEnv) -> R ) : R { val pe = phoneEnv if(pe != null) return codePhone(pe) val te = tabletEnv if(te != null) return codeTablet(te) throw RuntimeException("missing phoneEnv or tabletEnv") } // スマホモードならラムダを実行する。タブレットモードならnullを返す private inline fun phoneOnly(code : (PhoneEnv) -> R) : R? { val pe = phoneEnv return if(pe != null) code(pe) else null } // タブレットモードならラムダを実行する。スマホモードならnullを返す @Suppress("unused") private inline fun tabOnly(code : (TabletEnv) -> R) : R? { val te = tabletEnv return if(te != null) code(te) else null } // 新しいカラムをどこに挿入するか // カラムの次の位置か、現在のページの次の位置か、終端 fun nextPosition(column : Column?) : Int { if(column != null) { val pos = app_state.column_list.indexOf(column) if(pos != - 1) return pos + 1 } return defaultInsertPosition } private fun refreshAfterPost() { val posted_acct = this.posted_acct val posted_status_id = this.posted_status_id if(posted_acct != null && posted_status_id == null) { // 予約投稿なら予約投稿リストをリロードする for(column in app_state.column_list) { if(column.type == ColumnType.SCHEDULED_STATUS && column.access_info.acct == posted_acct ) { column.startLoading() } } } else if(posted_acct != null && posted_status_id != null) { val posted_redraft_id = this.posted_redraft_id if(posted_redraft_id != null) { val host = posted_acct.host if(host != null) { for(column in app_state.column_list) { column.onStatusRemoved(host, posted_redraft_id) } } this.posted_redraft_id = null } val refresh_after_toot = Pref.ipRefreshAfterToot(pref) if(refresh_after_toot != Pref.RAT_DONT_REFRESH) { for(column in app_state.column_list) { if(column.access_info.acct != posted_acct) continue column.startRefreshForPost( refresh_after_toot, posted_status_id, posted_reply_id ) } } } this.posted_acct = null this.posted_status_id = null } private fun handleSentIntent(intent : Intent) { sent_intent2 = intent // Galaxy S8+ で STのSSを取った後に出るポップアップからそのまま共有でSTを選ぶと何も起きない問題への対策 handler.post { AccountPicker.pick( this, bAllowPseudo = false, bAuto = true, message = getString(R.string.account_picker_toot) , dismiss_callback = { sent_intent2 = null } ) { ai -> sent_intent2 = null ActPost.open(this@ActMain, REQUEST_CODE_POST, ai.db_id, sent_intent = intent) } } } fun closeListItemPopup() { try { listItemPopup?.dismiss() } catch(ignored : Throwable) { } listItemPopup = null } private fun performQuickPost(account : SavedAccount?) { if(account == null) { val a = if(tabletEnv != null && ! Pref.bpQuickTootOmitAccountSelection(pref)) { // タブレットモードでオプションが無効なら // 簡易投稿は常にアカウント選択する null } else { currentPostTarget } if(a != null && ! a.isPseudo) { performQuickPost(a) } else { // アカウントを選択してやり直し AccountPicker.pick( this, bAllowPseudo = false, bAuto = true, message = getString(R.string.account_picker_toot) ) { ai -> performQuickPost(ai) } } return } post_helper.content = etQuickToot.text.toString().trim { it <= ' ' } post_helper.spoiler_text = null post_helper.visibility = when(quickTootVisibility) { TootVisibility.AccountSetting -> account.visibility else -> quickTootVisibility } post_helper.bNSFW = false post_helper.in_reply_to_id = null post_helper.attachment_list = null post_helper.emojiMapCustom = App1.custom_emoji_lister.getMap(account) etQuickToot.hideKeyboard() post_helper.post(account, callback = object : PostHelper.PostCompleteCallback { override fun onPostComplete( target_account : SavedAccount, status : TootStatus ) { etQuickToot.setText("") posted_acct = target_account.acct posted_status_id = status.id posted_reply_id = status.in_reply_to_id posted_redraft_id = null refreshAfterPost() } override fun onScheduledPostComplete(target_account : SavedAccount) { } }) } private fun isOrderChanged(new_order : ArrayList) : Boolean { if(new_order.size != app_state.column_list.size) return true var i = 0 val ie = new_order.size while(i < ie) { if(new_order[i] != i) return true ++ i } return false } override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { log.d("onActivityResult req=$requestCode res=$resultCode data=$data") if(resultCode == Activity.RESULT_OK) { when(requestCode) { REQUEST_CODE_COLUMN_LIST -> if(data != null) { val order = data.getIntegerArrayListExtra(ActColumnList.EXTRA_ORDER) if(order != null && isOrderChanged(order)) { setOrder(order) } if(app_state.column_list.isNotEmpty()) { val select = data.getIntExtra(ActColumnList.EXTRA_SELECTION, - 1) if(0 <= select && select < app_state.column_list.size) { scrollToColumn(select) } } } REQUEST_APP_ABOUT -> if(data != null) { val search = data.getStringExtra(ActAbout.EXTRA_SEARCH) if(search?.isNotEmpty() == true) { Action_Account.timeline( this@ActMain, defaultInsertPosition, ColumnType.SEARCH, args = arrayOf(search, true) ) } return } REQUEST_CODE_NICKNAME -> { updateColumnStrip() for(column in app_state.column_list) { column.fireShowColumnHeader() } } REQUEST_CODE_POST -> if(data != null) { etQuickToot.setText("") posted_acct = data.getStringExtra(ActPost.EXTRA_POSTED_ACCT)?.let { Acct.parse(it) } if(data.extras?.containsKey(ActPost.EXTRA_POSTED_STATUS_ID) == true) { posted_status_id = EntityId.from(data, ActPost.EXTRA_POSTED_STATUS_ID) posted_reply_id = EntityId.from(data, ActPost.EXTRA_POSTED_REPLY_ID) posted_redraft_id = EntityId.from(data, ActPost.EXTRA_POSTED_REDRAFT_ID) } else { posted_status_id = null } } REQUEST_CODE_COLUMN_COLOR -> if(data != null) { app_state.saveColumnList() val idx = data.getIntExtra(ActColumnCustomize.EXTRA_COLUMN_INDEX, 0) if(idx in app_state.column_list.indices) { app_state.column_list[idx].fireColumnColor() app_state.column_list[idx].fireShowContent( reason = "ActMain column color changed", reset = true ) } updateColumnStrip() } REQUEST_CODE_LANGUAGE_FILTER -> if(data != null) { app_state.saveColumnList() val idx = data.getIntExtra(ActLanguageFilter.EXTRA_COLUMN_INDEX, 0) if(idx in app_state.column_list.indices) { app_state.column_list[idx].onLanguageFilterChanged() } } } } when(requestCode) { REQUEST_CODE_ACCOUNT_SETTING -> { updateColumnStrip() for(column in app_state.column_list) { column.fireShowColumnHeader() } if(resultCode == Activity.RESULT_OK && data != null) { startAccessTokenUpdate(data) } else if(resultCode == ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN && data != null) { val db_id = data.getLongExtra(ActAccountSetting.EXTRA_DB_ID, - 1L) checkAccessToken2(db_id) } } REQUEST_CODE_APP_SETTING -> { Column.reloadDefaultColor(this, pref) showFooterColor() updateColumnStrip() if(resultCode == RESULT_APP_DATA_IMPORT) { importAppData(data?.data) } } REQUEST_CODE_TEXT -> when(resultCode) { ActText.RESULT_SEARCH_MSP -> { val text = data?.getStringExtra(Intent.EXTRA_TEXT) ?: "" addColumn( false, defaultInsertPosition, SavedAccount.na, ColumnType.SEARCH_MSP, text ) } ActText.RESULT_SEARCH_TS -> { val text = data?.getStringExtra(Intent.EXTRA_TEXT) ?: "" addColumn( false, defaultInsertPosition, SavedAccount.na, ColumnType.SEARCH_TS, text ) } } } super.onActivityResult(requestCode, resultCode, data) } override fun onBackPressed() { // メニューが開いていたら閉じる if(drawer.isDrawerOpen(GravityCompat.START)) { drawer.closeDrawer(GravityCompat.START) return } // カラムが0個ならアプリを終了する if(app_state.column_list.isEmpty()) { this@ActMain.finish() return } // カラム設定が開いているならカラム設定を閉じる if(closeColumnSetting()) { return } fun getClosableColumnList() : List { val visibleColumnList = ArrayList() phoneTab({ env -> try { visibleColumnList.add(app_state.column_list[env.pager.currentItem]) } catch(ex : Throwable) { } }, { env -> visibleColumnList.addAll(env.visibleColumns) }) return visibleColumnList.filter { ! it.dont_close } } // カラムが1個以上ある場合は設定に合わせて挙動を変える when(Pref.ipBackButtonAction(pref)) { Pref.BACK_EXIT_APP -> this@ActMain.finish() Pref.BACK_OPEN_COLUMN_LIST -> Action_App.columnList(this@ActMain) Pref.BACK_CLOSE_COLUMN -> { val closeableColumnList = getClosableColumnList() when(closeableColumnList.size) { 0 -> { if(Pref.bpExitAppWhenCloseProtectedColumn(pref) && Pref.bpDontConfirmBeforeCloseColumn(pref) ) { this@ActMain.finish() } else { showToast(this@ActMain, false, R.string.missing_closeable_column) } } 1 -> { closeColumn(closeableColumnList.first()) } else -> { showToast( this@ActMain, false, R.string.cant_close_column_by_back_button_when_multiple_column_shown ) } } } // ActAppSetting.BACK_ASK_ALWAYS else -> { val closeableColumnList = getClosableColumnList() val dialog = ActionsDialog() if(closeableColumnList.size == 1) { val column = closeableColumnList.first() dialog.addAction(getString(R.string.close_column)) { closeColumn(column, bConfirmed = true) } } dialog.addAction(getString(R.string.open_column_list)) { Action_App.columnList(this@ActMain) } dialog.addAction(getString(R.string.app_exit)) { this@ActMain.finish() } dialog.show(this, null) } } } internal fun initUI() { setContentView(R.layout.act_main) App1.initEdgeToEdge(this) quickTootVisibility = TootVisibility.parseSavedVisibility(Pref.spQuickTootVisibility(pref)) ?: quickTootVisibility showQuickTootVisibility() Column.reloadDefaultColor(this, pref) var sv = Pref.spTimelineFont(pref) if(sv.isNotEmpty()) { try { timeline_font = Typeface.createFromFile(sv) } catch(ex : Throwable) { log.trace(ex) } } sv = Pref.spTimelineFontBold(pref) if(sv.isNotEmpty()) { try { timeline_font_bold = Typeface.createFromFile(sv) } catch(ex : Throwable) { log.trace(ex) } } else { try { timeline_font_bold = Typeface.create(timeline_font, Typeface.BOLD) } catch(ex : Throwable) { log.trace(ex) } } fun parseIconSize(stringPref : StringPref, minDp : Float = 1f) : Int { var icon_size_dp = stringPref.defVal.toFloat() try { sv = stringPref(pref) val fv = if(sv.isEmpty()) Float.NaN else sv.toFloat() if(fv.isFinite() && fv >= minDp) { icon_size_dp = fv } } catch(ex : Throwable) { log.trace(ex) } return (0.5f + icon_size_dp * density).toInt() } avatarIconSize = parseIconSize(Pref.spAvatarIconSize) notificationTlIconSize = parseIconSize(Pref.spNotificationTlIconSize) boostButtonSize = parseIconSize(Pref.spBoostButtonSize) replyIconSize = parseIconSize(Pref.spReplyIconSize) headerIconSize = parseIconSize(Pref.spHeaderIconSize) stripIconSize = parseIconSize(Pref.spStripIconSize) screenBottomPadding = parseIconSize(Pref.spScreenBottomPadding, minDp = 0f) run { var round_ratio = 33f try { if(Pref.bpDontRound(pref)) { round_ratio = 0f } else { sv = Pref.spRoundRatio(pref) if(sv.isNotEmpty()) { val fv = sv.toFloat() if(fv.isFinite()) { round_ratio = fv } } } } catch(ex : Throwable) { log.trace(ex) } Styler.round_ratio = clipRange(0f, 1f, round_ratio / 100f) * 0.5f } run { var boost_alpha = 0.8f try { val f = (Pref.spBoostAlpha.toInt(pref).toFloat() + 0.5f) / 100f boost_alpha = when { f >= 1f -> 1f f < 0f -> 0.66f else -> f } } catch(ex : Throwable) { log.trace(ex) } Styler.boost_alpha = boost_alpha } llEmpty = findViewById(R.id.llEmpty) drawer = findViewById(R.id.drawer_layout) drawer.addDrawerListener(this) drawer.setExclusionSize(stripIconSize) SideMenuAdapter(this, findViewById(R.id.nav_view), drawer) btnMenu = findViewById(R.id.btnMenu) btnToot = findViewById(R.id.btnToot) vFooterDivider1 = findViewById(R.id.vFooterDivider1) vFooterDivider2 = findViewById(R.id.vFooterDivider2) llColumnStrip = findViewById(R.id.llColumnStrip) svColumnStrip = findViewById(R.id.svColumnStrip) llQuickTootBar = findViewById(R.id.llQuickTootBar) etQuickToot = findViewById(R.id.etQuickToot) btnQuickToot = findViewById(R.id.btnQuickToot) btnQuickTootMenu = findViewById(R.id.btnQuickTootMenu) val llFormRoot : LinearLayout = findViewById(R.id.llFormRoot) llFormRoot.setPadding(0, 0, 0, screenBottomPadding) when(Pref.ipJustifyWindowContentPortrait(pref)) { Pref.JWCP_START -> { val iconW = (stripIconSize * 1.5f + 0.5f).toInt() val padding = resources.displayMetrics.widthPixels / 2 - iconW fun ViewGroup.addViewBeforeLast(v : View) = addView(v, childCount - 1) (svColumnStrip.parent as LinearLayout).addViewBeforeLast( View(this).apply { layoutParams = LinearLayout.LayoutParams(padding, 0) } ) llQuickTootBar.addViewBeforeLast( View(this).apply { layoutParams = LinearLayout.LayoutParams(padding, 0) } ) } Pref.JWCP_END -> { val iconW = (stripIconSize * 1.5f + 0.5f).toInt() val borderWidth = (1f * density + 0.5f).toInt() val padding = resources.displayMetrics.widthPixels / 2 - iconW - borderWidth fun ViewGroup.addViewAfterFirst(v : View) = addView(v, 1) (svColumnStrip.parent as LinearLayout).addViewAfterFirst( View(this).apply { layoutParams = LinearLayout.LayoutParams(padding, 0) } ) llQuickTootBar.addViewAfterFirst( View(this).apply { layoutParams = LinearLayout.LayoutParams(padding, 0) } ) } } if(! Pref.bpQuickTootBar(pref)) { llQuickTootBar.visibility = View.GONE } btnToot.setOnClickListener(this) btnMenu.setOnClickListener(this) btnQuickToot.setOnClickListener(this) btnQuickTootMenu.setOnClickListener(this) if(Pref.bpDontUseActionButtonWithQuickTootBar(pref)) { etQuickToot.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE etQuickToot.imeOptions = EditorInfo.IME_ACTION_NONE // 最後に指定する必要がある? etQuickToot.maxLines = 5 etQuickToot.isVerticalScrollBarEnabled = true etQuickToot.isScrollbarFadingEnabled = false } else { etQuickToot.inputType = InputType.TYPE_CLASS_TEXT etQuickToot.imeOptions = EditorInfo.IME_ACTION_SEND etQuickToot.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ -> if(actionId == EditorInfo.IME_ACTION_SEND) { btnQuickToot.performClick() return@OnEditorActionListener true } false }) // 最後に指定する必要がある? etQuickToot.maxLines = 1 } svColumnStrip.isHorizontalFadingEdgeEnabled = true post_helper = PostHelper(this, pref, app_state.handler) val dm = resources.displayMetrics val density = dm.density var media_thumb_height = 64 sv = Pref.spMediaThumbHeight(pref) if(sv.isNotEmpty()) { try { val iv = Integer.parseInt(sv) if(iv >= 32) { media_thumb_height = iv } } catch(ex : Throwable) { log.trace(ex) } } app_state.media_thumb_height = (0.5f + media_thumb_height * density).toInt() var column_w_min_dp = COLUMN_WIDTH_MIN_DP sv = Pref.spColumnWidth(pref) if(sv.isNotEmpty()) { try { val iv = Integer.parseInt(sv) if(iv >= 100) { column_w_min_dp = iv } } catch(ex : Throwable) { log.trace(ex) } } val column_w_min = (0.5f + column_w_min_dp * density).toInt() val sw = dm.widthPixels if(Pref.bpDisableTabletMode(pref) || sw < column_w_min * 2) { // SmartPhone mode findViewById(R.id.rvPager).visibility = View.GONE phoneEnv = PhoneEnv() } else { // Tablet mode findViewById(R.id.viewPager).visibility = View.GONE tabletEnv = TabletEnv() } phoneTab({ env -> env.pager = findViewById(R.id.viewPager) env.pager_adapter = ColumnPagerAdapter(this) env.pager.adapter = env.pager_adapter env.pager.addOnPageChangeListener(this) resizeAutoCW(sw) }, { env -> env.tablet_pager = findViewById(R.id.rvPager) env.tablet_pager_adapter = TabletColumnPagerAdapter(this) env.tablet_layout_manager = LinearLayoutManager( this, LinearLayoutManager.HORIZONTAL, false ) if(env.tablet_pager.itemDecorationCount == 0) { env.tablet_pager.addItemDecoration(TabletColumnDivider(this@ActMain)) } env.tablet_pager.adapter = env.tablet_pager_adapter env.tablet_pager.layoutManager = env.tablet_layout_manager env.tablet_pager.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged( recyclerView : RecyclerView, newState : Int ) { super.onScrollStateChanged(recyclerView, newState) val vs = env.tablet_layout_manager.findFirstVisibleItemPosition() val ve = env.tablet_layout_manager.findLastVisibleItemPosition() // 端に近い方に合わせる val distance_left = abs(vs) val distance_right = abs(app_state.column_list.size - 1 - ve) if(distance_left < distance_right) { scrollColumnStrip(vs) } else { scrollColumnStrip(ve) } } override fun onScrolled( recyclerView : RecyclerView, dx : Int, dy : Int ) { super.onScrolled(recyclerView, dx, dy) updateColumnStripSelection(- 1, - 1f) } }) env.tablet_pager.itemAnimator = null // val animator = env.tablet_pager.itemAnimator // if( animator is DefaultItemAnimator){ // animator.supportsChangeAnimations = false // } env.tablet_snap_helper = GravitySnapHelper(Gravity.START) env.tablet_snap_helper.attachToRecyclerView(env.tablet_pager) }) showFooterColor() post_helper.attachEditText( llFormRoot, etQuickToot, true, object : PostHelper.Callback2 { override fun onTextUpdate() {} override fun canOpenPopup() : Boolean { return ! drawer.isDrawerOpen(GravityCompat.START) } }) } private fun isVisibleColumn(idx : Int) = phoneTab( { env -> val c = env.pager.currentItem c == idx }, { env -> idx >= 0 && idx in env.visibleColumnsIndices } ) private fun updateColumnStrip() { llEmpty.vg(app_state.column_list.isEmpty()) val iconSize = stripIconSize val rootW = (iconSize * 1.25f + 0.5f).toInt() val rootH = (iconSize * 1.5f + 0.5f).toInt() val iconTopMargin = (iconSize * 0.125f + 0.5f).toInt() val barHeight = (iconSize * 0.094f + 0.5f).toInt() val barTopMargin = (iconSize * 0.094f + 0.5f).toInt() // 両端のメニューと投稿ボタンの大きさ val pad = (rootH - iconSize) shr 1 for(btn in arrayOf(btnToot, btnMenu, btnQuickTootMenu, btnQuickToot)) { btn.layoutParams.width = rootH // not W btn.layoutParams.height = rootH btn.setPaddingRelative(pad, pad, pad, pad) } llColumnStrip.removeAllViews() for(i in 0 until app_state.column_list.size) { val column = app_state.column_list[i] val viewRoot = layoutInflater.inflate(R.layout.lv_column_strip, llColumnStrip, false) val ivIcon = viewRoot.findViewById(R.id.ivIcon) val vAcctColor = viewRoot.findViewById(R.id.vAcctColor) // root: 48x48dp LinearLayout(vertical), gravity=center viewRoot.layoutParams.width = rootW viewRoot.layoutParams.height = rootH // ivIcon: 32x32dp marginTop="4dp" 図柄が32x32dp、パディングなし ivIcon.layoutParams.width = iconSize ivIcon.layoutParams.height = iconSize (ivIcon.layoutParams as? LinearLayout.LayoutParams)?.topMargin = iconTopMargin // vAcctColor: 32x3dp marginTop="3dp" vAcctColor.layoutParams.width = iconSize vAcctColor.layoutParams.height = barHeight (vAcctColor.layoutParams as? LinearLayout.LayoutParams)?.topMargin = barTopMargin viewRoot.tag = i viewRoot.setOnClickListener { v -> val idx = v.tag as Int if(Pref.bpScrollTopFromColumnStrip(pref) && isVisibleColumn(idx)) { app_state.column_list[i].viewHolder?.scrollToTop2() return@setOnClickListener } scrollToColumn(idx) } viewRoot.contentDescription = column.getColumnName(true) viewRoot.backgroundDrawable = getAdaptiveRippleDrawableRound( this, column.getHeaderBackgroundColor(), column.getHeaderNameColor() ) ivIcon.setImageResource(column.getIconId()) ivIcon.imageTintList = ColorStateList.valueOf(column.getHeaderNameColor()) // val ac = AcctColor.load(column.access_info) if(AcctColor.hasColorForeground(ac)) { vAcctColor.setBackgroundColor(ac.color_fg) } else { vAcctColor.visibility = View.INVISIBLE } // llColumnStrip.addView(viewRoot) } svColumnStrip.requestLayout() updateColumnStripSelection(- 1, - 1f) } private fun updateColumnStripSelection(position : Int, positionOffset : Float) { handler.post(Runnable { if(isFinishing) return@Runnable if(app_state.column_list.isEmpty()) { llColumnStrip.setVisibleRange(- 1, - 1, 0f) } else { phoneTab({ env -> if(position >= 0) { llColumnStrip.setVisibleRange(position, position, positionOffset) } else { val c = env.pager.currentItem llColumnStrip.setVisibleRange(c, c, 0f) } }, { env -> val vs = env.tablet_layout_manager.findFirstVisibleItemPosition() val ve = env.tablet_layout_manager.findLastVisibleItemPosition() val vr = if(vs == RecyclerView.NO_POSITION || ve == RecyclerView.NO_POSITION) { IntRange(- 1, - 2) // empty and less than zero } else { IntRange(vs, min(ve, vs + nScreenColumn - 1)) } var slide_ratio = 0f if(vr.first <= vr.last) { val child = env.tablet_layout_manager.findViewByPosition(vr.first) slide_ratio = clipRange(0f, 1f, abs((child?.left ?: 0) / nColumnWidth.toFloat())) } llColumnStrip.setVisibleRange(vr.first, vr.last, slide_ratio) }) } }) } private fun scrollColumnStrip(select : Int) { val child_count = llColumnStrip.childCount if(select < 0 || select >= child_count) { return } val icon = llColumnStrip.getChildAt(select) val sv_width = (llColumnStrip.parent as View).width val ll_width = llColumnStrip.width val icon_width = icon.width val icon_left = icon.left if(sv_width == 0 || ll_width == 0 || icon_width == 0) { handler.postDelayed({ scrollColumnStrip(select) }, 20L) } val sx = icon_left + icon_width / 2 - sv_width / 2 svColumnStrip.smoothScrollTo(sx, 0) } fun startAccessTokenUpdate(data : Intent) { App1.openBrowser(this, data.data) } // ActOAuthCallbackで受け取ったUriを処理する private fun handleIntentUri(uri : Uri) { log.d("handleIntentUri ${uri}") when(uri.scheme) { "subwaytooter", "misskeyclientproto" -> return try { handleOAuth2CallbackUri(uri) } catch(ex : Throwable) { log.trace(ex) } } val url = uri.toString() val statusInfo = url.findStatusIdFromUrl() if(statusInfo != null) { // ステータスをアプリ内で開く Action_Toot.conversationOtherInstance( this@ActMain, defaultInsertPosition, statusInfo.url, statusInfo.statusId, statusInfo.host, statusInfo.statusId ) return } // ユーザページをアプリ内で開く var m = TootAccount.reAccountUrl.matcher(url) if(m.find()) { val host = m.groupEx(1) !! val user = m.groupEx(2) !!.decodePercent() val instance = m.groupEx(3)?.decodePercent() if(instance?.isNotEmpty() == true) { Action_User.profile( this@ActMain, defaultInsertPosition, null, "https://$instance/@$user", Host.parse(instance), user, original_url = url ) } else { Action_User.profile( this@ActMain, defaultInsertPosition, null, url, Host.parse(host), user ) } return } // intentFilterの都合でこの形式のURLが飛んでくることはないのだが…。 m = TootAccount.reAccountUrl2.matcher(url) if(m.find()) { val host = m.groupEx(1) !! val user = m.groupEx(2) !!.decodePercent() Action_User.profile( this@ActMain, defaultInsertPosition, null, url, Host.parse(host), user ) return } // このアプリでは処理できないURLだった // 外部ブラウザを開きなおそうとすると無限ループの恐れがある // アプリケーションチューザーを表示する val error_message = getString(R.string.cant_handle_uri_of, url) try { val query_flag = if(Build.VERSION.SDK_INT >= 23) { // Android 6.0以降 // MATCH_DEFAULT_ONLY だと標準の設定に指定されたアプリがあるとソレしか出てこない // MATCH_ALL を指定すると 以前と同じ挙動になる PackageManager.MATCH_ALL } else { // Android 5.xまでは MATCH_DEFAULT_ONLY でマッチするすべてのアプリを取得できる PackageManager.MATCH_DEFAULT_ONLY } // queryIntentActivities に渡すURLは実在しないホストのものにする val intent = Intent(Intent.ACTION_VIEW, "https://dummy.subwaytooter.club/".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK val resolveInfoList = packageManager.queryIntentActivities(intent, query_flag) if(resolveInfoList.isEmpty()) { throw RuntimeException("resolveInfoList is empty.") } // このアプリ以外の選択肢を集める val my_name = packageName val choice_list = ArrayList() for(ri in resolveInfoList) { // 選択肢からこのアプリを除外 if(my_name == ri.activityInfo.packageName) continue // 選択肢のIntentは目的のUriで作成する val choice = Intent(Intent.ACTION_VIEW, uri) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK choice.`package` = ri.activityInfo.packageName choice.setClassName(ri.activityInfo.packageName, ri.activityInfo.name) choice_list.add(choice) } if(choice_list.isEmpty()) { throw RuntimeException("choice_list is empty.") } // 指定した選択肢でチューザーを作成して開く val chooser = Intent.createChooser(choice_list.removeAt(0), error_message) chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, choice_list.toTypedArray()) startActivity(chooser) return } catch(ex : Throwable) { log.trace(ex) } AlertDialog.Builder(this) .setCancelable(true) .setMessage(error_message) .setPositiveButton(R.string.close, null) .show() } private fun handleOAuth2CallbackUri(uri : Uri) { // 通知タップ // subwaytooter://notification_click/?db_id=(db_id) val dataIdString = uri.getQueryParameter("db_id") if(dataIdString != null) { PollingWorker.queueNotificationClicked(this, uri) try { val dataId = dataIdString.toLong() val account = SavedAccount.loadAccount(this@ActMain, dataId) if(account != null) { var column = app_state.column_list.firstOrNull { it.type == ColumnType.NOTIFICATIONS && account == it.access_info && ! it.system_notification_not_related } if(column != null) { val index = app_state.column_list.indexOf(column) scrollToColumn(index) } else { column = addColumn( true, defaultInsertPosition, account, ColumnType.NOTIFICATIONS ) } // 通知を読み直す if(! column.bInitialLoading) { column.startLoading() } } } catch(ex : Throwable) { log.trace(ex) } return } // OAuth2 認証コールバック // subwaytooter://oauth(\d*)/?... TootTaskRunner(this@ActMain).run(object : TootTask { var ta : TootAccount? = null var sa : SavedAccount? = null var host : Host? = null override fun background(client : TootApiClient) : TootApiResult? { val uriStr = uri.toString() if(uriStr.startsWith("subwaytooter://misskey/auth_callback") || uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback") ) { // Misskey 認証コールバック val token = uri.getQueryParameter("token") if(token?.isEmpty() != false) { return TootApiResult("missing token in callback URL") } val prefDevice = PrefDevice.prefDevice(this@ActMain) val db_id = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, - 1L) val instance = Host.parse( prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null) ?: return TootApiResult("missing instance name.") ) if(db_id != - 1L) { try { val sa = SavedAccount.loadAccount(this@ActMain, db_id) ?: return TootApiResult("missing account db_id=$db_id") this.sa = sa client.account = sa } catch(ex : Throwable) { log.trace(ex) return TootApiResult(ex.withCaption("invalid state")) } } else { client.instance = instance } val (ti, r2) = TootInstance.get(client) ti ?: return r2 this.host = instance val client_name = Pref.spClientName(this@ActMain) val result = client.authentication2Misskey(client_name, token, ti.misskeyVersion) this.ta = TootParser( this@ActMain , LinkHelper.newLinkHelper(instance, misskeyVersion = ti.misskeyVersion) ).account(result?.jsonObject) return result } else { // Mastodon 認証コールバック // エラー時 // subwaytooter://oauth(\d*)/ // ?error=access_denied // &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82 // &state=db%3A3 val error = uri.getQueryParameter("error_description") if(error?.isNotEmpty() == true) { return TootApiResult(error) } // subwaytooter://oauth(\d*)/ // ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2 // &state=host%3Amastodon.juggler.jp val code = uri.getQueryParameter("code") if(code?.isEmpty() != false) { return TootApiResult("missing code in callback url.") } val sv = uri.getQueryParameter("state") if(sv?.isEmpty() != false) { return TootApiResult("missing state in callback url.") } for(param in sv.split(",")) { when { param.startsWith("db:") -> try { val dataId = param.substring(3).toLong(10) val sa = SavedAccount.loadAccount(this@ActMain, dataId) ?: return TootApiResult("missing account db_id=$dataId") this.sa = sa client.account = sa } catch(ex : Throwable) { log.trace(ex) return TootApiResult(ex.withCaption("invalid state")) } param.startsWith("host:") -> { val host = Host.parse(param.substring(5)) client.instance = host } else -> { // ignore other parameter } } } val instance = client.instance ?: return TootApiResult("missing instance in callback url.") this.host = instance val client_name = Pref.spClientName(this@ActMain) val result = client.authentication2(client_name, code) this.ta = TootParser( this@ActMain , LinkHelper.newLinkHelper(instance) ).account(result?.jsonObject) return result } } override fun handleResult(result : TootApiResult?) { val host = this.host val ta = this.ta var sa = this.sa if(ta != null && host?.isValid == true && sa == null) { val acct = Acct.parse(ta.username, host) // アカウント追加時に、アプリ内に既にあるアカウントと同じものを登録していたかもしれない sa = SavedAccount.loadAccountByAcct(this@ActMain, acct.ascii) } afterAccountVerify(result, ta, sa, host) } }) } internal fun afterAccountVerify( result : TootApiResult?, ta : TootAccount?, sa : SavedAccount?, host : Host? ) : Boolean { val jsonObject = result?.jsonObject val token_info = result?.tokenInfo val error = result?.error when { result == null -> { // cancelled. } error != null -> showToast( this@ActMain, true, "${result.error} ${result.requestInfo}".trim() ) token_info == null -> showToast(this@ActMain, true, "can't get access token.") jsonObject == null -> showToast(this@ActMain, true, "can't parse json response.") // 自分のユーザネームを取れなかった // …普通はエラーメッセージが設定されてるはずだが ta == null -> showToast(this@ActMain, true, "can't verify user credential.") // アクセストークン更新時 // インスタンスは同じだと思うが、ユーザ名が異なる可能性がある sa != null -> if(sa.username != ta.username) { showToast(this@ActMain, true, R.string.user_name_not_match) } else { showToast( this@ActMain, false, R.string.access_token_updated_for, sa.acct.pretty ) // DBの情報を更新する sa.updateTokenInfo(token_info) // 各カラムの持つアカウント情報をリロードする reloadAccountSetting() // 自動でリロードする for(it in app_state.column_list) { if(it.access_info == sa) { it.startLoading() } } // 通知の更新が必要かもしれない PushSubscriptionHelper.clearLastCheck(sa) PollingWorker.queueUpdateNotification(this@ActMain) return true } host != null -> { // アカウント追加時 val user = Acct.parse(ta.username, host) val row_id = SavedAccount.insert( host.ascii, user.ascii, jsonObject, token_info, misskeyVersion = TootInstance.parseMisskeyVersion(token_info) ) val account = SavedAccount.loadAccount(this@ActMain, row_id) if(account != null) { var bModified = false if(account.loginAccount?.locked == true) { bModified = true account.visibility = TootVisibility.PrivateFollowers } if(! account.isMisskey) { val source = ta.source if(source != null) { val privacy = TootVisibility.parseMastodon(source.privacy) if(privacy != null) { bModified = true account.visibility = privacy } // XXX ta.source.sensitive パラメータを読んで「添付画像をデフォルトでNSFWにする」を実現する // 現在、アカウント設定にはこの項目はない( 「NSFWな添付メディアを隠さない」はあるが全く別の効果) } if(bModified) { account.saveSetting() } } showToast(this@ActMain, false, R.string.account_confirmed) // 通知の更新が必要かもしれない PollingWorker.queueUpdateNotification(this@ActMain) // 適当にカラムを追加する val count = SavedAccount.count if(count > 1) { addColumn(false, defaultInsertPosition, account, ColumnType.HOME) } else { addColumn(false, defaultInsertPosition, account, ColumnType.HOME) addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS) addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL) addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE) } return true } } } return false } // アクセストークンを手動で入力した場合 fun checkAccessToken( dialog_host : Dialog?, dialog_token : Dialog?, host : Host, access_token : String, sa : SavedAccount? ) { TootTaskRunner(this@ActMain).run(host, object : TootTask { var ta : TootAccount? = null override fun background(client : TootApiClient) : TootApiResult? { val (instance, instanceResult) = TootInstance.get(client, host) instance ?: return instanceResult val misskeyVersion = instance.misskeyVersion val linkHelper = LinkHelper.newLinkHelper(host, misskeyVersion = misskeyVersion) val result = client.getUserCredential(access_token, misskeyVersion = misskeyVersion) this.ta = TootParser(this@ActMain, linkHelper) .account(result?.jsonObject) return result } override fun handleResult(result : TootApiResult?) { if(afterAccountVerify(result, ta, sa, host)) { dialog_host?.dismissSafe() dialog_token?.dismissSafe() } } }) } // アクセストークンの手動入力(更新) private fun checkAccessToken2(db_id : Long) { val sa = SavedAccount.loadAccount(this, db_id) ?: return DlgTextInput.show( this, getString(R.string.access_token_or_api_token), null, callback = object : DlgTextInput.Callback { override fun onOK(dialog : Dialog, text : String) { checkAccessToken(null, dialog, sa.host, text, sa) } override fun onEmptyError() { showToast(this@ActMain, true, R.string.token_not_specified) } }) } private fun reloadAccountSetting() { val done_list = ArrayList() for(column in app_state.column_list) { val a = column.access_info if(done_list.contains(a)) continue done_list.add(a) if(! a.isNA) a.reloadSetting(this@ActMain) column.fireShowColumnHeader() } } fun reloadAccountSetting(account : SavedAccount) { val done_list = ArrayList() for(column in app_state.column_list) { val a = column.access_info if(a != account) continue if(done_list.contains(a)) continue done_list.add(a) if(! a.isNA) a.reloadSetting(this@ActMain) column.fireShowColumnHeader() } } fun closeColumn(column : Column, bConfirmed : Boolean = false) { if(column.dont_close) { showToast(this, false, R.string.column_has_dont_close_option) return } if(! bConfirmed && ! Pref.bpDontConfirmBeforeCloseColumn(pref)) { AlertDialog.Builder(this) .setMessage(R.string.confirm_close_column) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> closeColumn(column, bConfirmed = true) } .show() return } val page_delete = app_state.column_list.indexOf(column) phoneTab({ env -> val page_showing = env.pager.currentItem removeColumn(column) if(app_state.column_list.isNotEmpty() && page_delete > 0 && page_showing == page_delete) { val idx = page_delete - 1 scrollToColumn(idx) val c = app_state.column_list[idx] if(! c.bFirstInitialized) { c.startLoading() } } }, { removeColumn(column) if(app_state.column_list.isNotEmpty() && page_delete > 0) { val idx = page_delete - 1 scrollToColumn(idx) val c = app_state.column_list[idx] if(! c.bFirstInitialized) { c.startLoading() } } }) } fun closeColumnAll( _lastColumnIndex : Int = - 1, bConfirmed : Boolean = false ) { if(! bConfirmed) { AlertDialog.Builder(this) .setMessage(R.string.confirm_close_column_all) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> closeColumnAll(_lastColumnIndex, true) } .show() return } var lastColumnIndex = when(_lastColumnIndex) { - 1 -> phoneTab( { it.pager.currentItem }, { 0 } ) else -> _lastColumnIndex } phoneOnly { env -> env.pager.adapter = null } for(i in (0 until app_state.column_list.size).reversed()) { val column = app_state.column_list[i] if(column.dont_close) continue app_state.column_list.removeAt(i).dispose() if(lastColumnIndex >= i) -- lastColumnIndex } phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) app_state.saveColumnList() updateColumnStrip() if(app_state.column_list.isNotEmpty() && lastColumnIndex >= 0 && lastColumnIndex < app_state.column_list.size) { scrollToColumn(lastColumnIndex) val c = app_state.column_list[lastColumnIndex] if(! c.bFirstInitialized) { c.startLoading() } } } ////////////////////////////////////////////////////////////// // カラム追加系 fun addColumn( indexArg : Int, ai : SavedAccount, type : ColumnType, vararg params : Any ) : Column { return addColumn( Pref.bpAllowColumnDuplication(pref), indexArg, ai, type, *params ) } fun addColumn( allowColumnDuplication : Boolean, indexArg : Int, ai : SavedAccount, type : ColumnType, vararg params : Any ) : Column { if(! allowColumnDuplication) { // 既に同じカラムがあればそこに移動する for(column in app_state.column_list) { if(column.isSameSpec(ai, type, params)) { val indexColumn = app_state.column_list.indexOf(column) scrollToColumn(indexColumn) return column } } } // val col = Column(app_state, ai, this, type.id, *params) val index = addColumn(col, indexArg) scrollToColumn(index) if(! col.bFirstInitialized) { col.startLoading() } return col } fun openChromeTab(opener : ChromeTabOpener) { try { log.d("openChromeTab url=%s", opener.url) val accessInfo = opener.accessInfo val whoRef = opener.whoRef val whoAcct = if(whoRef != null) { accessInfo?.getFullAcct(whoRef.get()) } else { null } if(opener.allowIntercept && accessInfo != null) { // ハッシュタグはいきなり開くのではなくメニューがある val tagInfo = opener.url.findHashtagFromUrl() if(tagInfo != null) { Action_HashTag.dialog( this@ActMain, opener.pos, opener.url, Host.parse(tagInfo.second), tagInfo.first, opener.tagList, whoAcct ) return } val statusInfo = opener.url.findStatusIdFromUrl() if(statusInfo != null) { if(accessInfo.isNA || statusInfo.statusId == null || statusInfo.host != accessInfo.host ) { Action_Toot.conversationOtherInstance( this@ActMain, opener.pos, statusInfo.url, statusInfo.statusId, statusInfo.host, statusInfo.statusId ) } else { Action_Toot.conversationLocal( this@ActMain, opener.pos, accessInfo, statusInfo.statusId ) } return } // opener.linkInfo をチェックしてメンションを判別する val mention = opener.linkInfo?.mention if(mention != null) { val fullAcct = getFullAcctOrNull(accessInfo, mention.acct.ascii, mention.url) if(fullAcct != null) { if(fullAcct.host != null) { when(fullAcct.host.ascii) { "github.com", "twitter.com" -> App1.openCustomTab(this, mention.url) "gmail.com" -> App1.openBrowser(this, "mailto:${fullAcct.pretty}") else -> Action_User.profile( this@ActMain, opener.pos, accessInfo, // FIXME nullが必要なケースがあったっけなかったっけ… mention.url, fullAcct.host, fullAcct.username, original_url = opener.url ) } return } } } // ユーザページをアプリ内で開く var m = TootAccount.reAccountUrl.matcher(opener.url) if(m.find()) { val host = m.groupEx(1) !! val user = m.groupEx(2) !!.decodePercent() val instance = m.groupEx(3)?.decodePercent()?.notEmpty() // https://misskey.xyz/@tateisu@github.com // https://misskey.xyz/@tateisu@twitter.com if(instance != null) { val instanceHost = Host.parse(instance) when(instanceHost.ascii) { "github.com", "twitter.com" -> { App1.openCustomTab(this, "https://$instance/$user") } "gmail.com" -> { App1.openBrowser(this, "mailto:$user@$instance") } else -> { Action_User.profile( this@ActMain, opener.pos, null, // Misskeyだと疑似アカが必要なんだっけ…? "https://$instance/@$user", instanceHost, user, original_url = opener.url ) } } } else { Action_User.profile( this@ActMain, opener.pos, accessInfo, opener.url, Host.parse(host), user ) } return } m = TootAccount.reAccountUrl2.matcher(opener.url) if(m.find()) { val host = m.groupEx(1) !! val user = m.groupEx(2) !!.decodePercent() Action_User.profile( this@ActMain, opener.pos, accessInfo, opener.url, Host.parse(host), user ) return } } App1.openCustomTab(this, opener.url) } catch(ex : Throwable) { // warning.trace( ex ); log.e(ex, "openChromeTab failed. url=%s", opener.url) } } ///////////////////////////////////////////////////////////////////////// fun showColumnMatchAccount(account : SavedAccount) { for(column in app_state.column_list) { if(account == column.access_info) { column.fireRebindAdapterItems() } } } private fun showFooterColor() { val footer_button_bg_color = Pref.ipFooterButtonBgColor(pref) val footer_button_fg_color = Pref.ipFooterButtonFgColor(pref) val footer_tab_bg_color = Pref.ipFooterTabBgColor(pref) val footer_tab_divider_color = Pref.ipFooterTabDividerColor(pref) val footer_tab_indicator_color = Pref.ipFooterTabIndicatorColor(pref) val colorBg = footer_button_bg_color.notZero() ?: getAttributeColor( this, R.attr.colorStatusButtonsPopupBg ) val colorRipple = footer_button_fg_color.notZero() ?: getAttributeColor(this, R.attr.colorRippleEffect) btnMenu.backgroundDrawable = getAdaptiveRippleDrawableRound(this, colorBg, colorRipple) btnToot.backgroundDrawable = getAdaptiveRippleDrawableRound(this, colorBg, colorRipple) btnQuickToot.backgroundDrawable = getAdaptiveRippleDrawableRound(this, colorBg, colorRipple) btnQuickTootMenu.backgroundDrawable = getAdaptiveRippleDrawableRound(this, colorBg, colorRipple) val csl = ColorStateList.valueOf( footer_button_fg_color.notZero() ?: getAttributeColor(this, R.attr.colorVectorDrawable) ) btnToot.imageTintList = csl btnMenu.imageTintList = csl btnQuickToot.imageTintList = csl btnQuickTootMenu.imageTintList = csl var c = footer_tab_bg_color.notZero() ?: getAttributeColor(this, R.attr.colorColumnStripBackground) svColumnStrip.setBackgroundColor(c) llQuickTootBar.setBackgroundColor(c) c = footer_tab_divider_color.notZero() ?: getAttributeColor(this, R.attr.colorImageButton) vFooterDivider1.setBackgroundColor(c) vFooterDivider2.setBackgroundColor(c) llColumnStrip.indicatorColor = footer_tab_indicator_color } ///////////////////////////////////////////////////////////////////////// // タブレット対応で必要になった関数など private fun closeColumnSetting() : Boolean { phoneTab({ env -> val vh = env.pager_adapter.getColumnViewHolder(env.pager.currentItem) if(vh?.isColumnSettingShown == true) { vh.showColumnSetting(false) return@closeColumnSetting true } }, { env -> for(i in 0 until env.tablet_layout_manager.childCount) { val columnViewHolder = when(val v = env.tablet_layout_manager.getChildAt(i)) { null -> null else -> (env.tablet_pager.getChildViewHolder(v) as? TabletColumnViewHolder)?.columnViewHolder } if(columnViewHolder?.isColumnSettingShown == true) { columnViewHolder.showColumnSetting(false) return@closeColumnSetting true } } }) return false } private fun addColumn(column : Column, indexArg : Int) : Int { var index = indexArg val size = app_state.column_list.size if(index > size) index = size phoneOnly { env -> env.pager.adapter = null } app_state.column_list.add(index, column) phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) app_state.saveColumnList() updateColumnStrip() return index } private fun removeColumn(column : Column) { val idx_column = app_state.column_list.indexOf(column) if(idx_column == - 1) return phoneOnly { env -> env.pager.adapter = null } app_state.column_list.removeAt(idx_column).dispose() phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) app_state.saveColumnList() updateColumnStrip() } private fun setOrder(new_order : ArrayList) { phoneOnly { env -> env.pager.adapter = null } val ie = app_state.column_list.size val tmp_list = ArrayList() val used_set = HashSet() // copy by new_order for(i in new_order) { if(0 <= i && i < ie) { used_set.add(i) tmp_list.add(app_state.column_list[i]) } } // dispose unused elements. for(i in 0 until ie) { if(used_set.contains(i)) continue app_state.column_list[i].dispose() } app_state.column_list.clear() app_state.column_list.addAll(tmp_list) phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) app_state.saveColumnList() updateColumnStrip() } private fun resizeColumnWidth(env : TabletEnv) { var column_w_min_dp = COLUMN_WIDTH_MIN_DP val sv = Pref.spColumnWidth(pref) if(sv.isNotEmpty()) { try { val iv = Integer.parseInt(sv) if(iv >= 100) { column_w_min_dp = iv } } catch(ex : Throwable) { log.trace(ex) } } val dm = resources.displayMetrics val screen_width = dm.widthPixels val density = dm.density var column_w_min = (0.5f + column_w_min_dp * density).toInt() if(column_w_min < 1) column_w_min = 1 var column_w : Int if(screen_width < column_w_min * 2) { // 最小幅で2つ表示できないのなら1カラム表示 nScreenColumn = 1 column_w = screen_width } else { // カラム最小幅から計算した表示カラム数 nScreenColumn = screen_width / column_w_min if(nScreenColumn < 1) nScreenColumn = 1 // データのカラム数より大きくならないようにする // (でも最小は1) val column_count = app_state.column_list.size if(column_count > 0 && column_count < nScreenColumn) { nScreenColumn = column_count } // 表示カラム数から計算したカラム幅 column_w = screen_width / nScreenColumn // 最小カラム幅の1.5倍よりは大きくならないようにする val column_w_max = (0.5f + column_w_min * 1.5f).toInt() if(column_w > column_w_max) { column_w = column_w_max } } nColumnWidth = column_w // dividerの幅を含む val divider_width = (0.5f + 1f * density).toInt() column_w -= divider_width env.tablet_pager_adapter.columnWidth = column_w // dividerの幅を含まない // env.tablet_snap_helper.columnWidth = column_w //使われていない resizeAutoCW(column_w) // dividerの幅を含まない // 並べ直す env.tablet_pager_adapter.notifyDataSetChanged() } private fun scrollToColumn(index : Int, smoothScroll : Boolean = true) { scrollColumnStrip(index) phoneTab( // スマホはスムーススクロール基本ありだがたまにしない { env -> log.d("ipLastColumnPos beforeScroll=${env.pager.currentItem}") env.pager.setCurrentItem(index, smoothScroll) }, // タブレットでスムーススクロールさせると頻繁にオーバーランするので絶対しない { env -> log.d("ipLastColumnPos beforeScroll=${env.visibleColumnsIndices.first}") env.tablet_pager.scrollToPosition(index) } ) } ////////////////////////////////////////////////////////////////////////////////////////////// private fun importAppData(uri : Uri?) { uri ?: return // remove all columns phoneOnly { env -> env.pager.adapter = null } for(c in app_state.column_list) { c.dispose() } app_state.column_list.clear() phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) updateColumnStrip() runWithProgress( "importing app data", doInBackground = { progress -> fun setProgressMessage(sv : String) = runOnMainLooper { progress.setMessageEx(sv) } var newColumnList : ArrayList? = null setProgressMessage("import data to local storage...") // アプリ内領域に一時ファイルを作ってコピーする val cacheDir = cacheDir cacheDir.mkdir() val file = File( cacheDir, "SubwayTooter.${Process.myPid()}.${Process.myTid()}.tmp" ) val source = contentResolver.openInputStream(uri) if(source == null) { showToast(true, "openInputStream failed.") return@runWithProgress null } source.use { inStream -> FileOutputStream(file).use { outStream -> IOUtils.copy(inStream, outStream) } } // 通知サービスを止める setProgressMessage("syncing notification poller…") PollingWorker.queueAppDataImportBefore(this@ActMain) while(PollingWorker.mBusyAppDataImportBefore.get()) { delay(1000L) log.d("syncing polling task...") } // データを読み込む setProgressMessage("reading app data...") var zipEntryCount = 0 try { ZipInputStream(FileInputStream(file)).use { zipStream -> while(true) { val entry = zipStream.nextEntry ?: break ++ zipEntryCount try { // val entryName = entry.name if(entryName.endsWith(".json")) { newColumnList = AppDataExporter.decodeAppData( this@ActMain, JsonReader(InputStreamReader(zipStream, "UTF-8")) ) continue } if(AppDataExporter.restoreBackgroundImage( this@ActMain, newColumnList, zipStream, entryName ) ) { continue } } finally { zipStream.closeEntry() } } } } catch(ex : Throwable) { log.trace(ex) if(zipEntryCount != 0) { showToast(this@ActMain, ex, "importAppData failed.") } } // zipではなかった場合、zipEntryがない状態になる。例外はPH-1では出なかったが、出ても問題ないようにする。 if(zipEntryCount == 0) { InputStreamReader(FileInputStream(file), "UTF-8").use { inStream -> newColumnList = AppDataExporter.decodeAppData( this@ActMain, JsonReader(inStream) ) } } newColumnList }, afterProc = { // cancelled. if(it == null) return@runWithProgress try { phoneOnly { env -> env.pager.adapter = null } app_state.column_list.clear() app_state.column_list.addAll(it) app_state.saveColumnList() phoneTab( { env -> env.pager.adapter = env.pager_adapter }, { env -> resizeColumnWidth(env) } ) updateColumnStrip() } finally { // 通知サービスをリスタート PollingWorker.queueAppDataImportAfter(this@ActMain) } showToast(this@ActMain, true, R.string.import_completed_please_restart_app) finish() }, preProc = { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) }, postProc = { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } ) } override fun onDrawerSlide(drawerView : View, slideOffset : Float) { post_helper.closeAcctPopup() } override fun onDrawerOpened(drawerView : View) { post_helper.closeAcctPopup() } override fun onDrawerClosed(drawerView : View) { post_helper.closeAcctPopup() } override fun onDrawerStateChanged(newState : Int) { post_helper.closeAcctPopup() } private fun resizeAutoCW(column_w : Int) { val sv = Pref.spAutoCWLines(pref) nAutoCwLines = sv.optInt() ?: - 1 if(nAutoCwLines > 0) { val lv_pad = (0.5f + 12 * density).toInt() val icon_width = avatarIconSize val icon_end = (0.5f + 4 * density).toInt() nAutoCwCellWidth = column_w - lv_pad * 2 - icon_width - icon_end } // この後各カラムは再描画される } fun checkAutoCW(status : TootStatus, text : CharSequence) { if(nAutoCwCellWidth <= 0) { // 設定が無効 status.auto_cw = null return } var auto_cw = status.auto_cw if(auto_cw != null && auto_cw.refActivity?.get() === this@ActMain && auto_cw.cell_width == nAutoCwCellWidth) { // 以前に計算した値がまだ使える return } if(auto_cw == null) { auto_cw = TootStatus.AutoCW() status.auto_cw = auto_cw } // 計算時の条件(文字フォント、文字サイズ、カラム幅)を覚えておいて、再利用時に同じか確認する auto_cw.refActivity = WeakReference(this@ActMain) auto_cw.cell_width = nAutoCwCellWidth auto_cw.decoded_spoiler_text = null // テキストをレイアウトして行数を測定 val lp = LinearLayout.LayoutParams(nAutoCwCellWidth, LinearLayout.LayoutParams.WRAP_CONTENT) val tv = TextView(this) tv.layoutParams = lp if(! timeline_font_size_sp.isNaN()) { tv.textSize = timeline_font_size_sp } val fv = timeline_spacing if(fv != null) tv.setLineSpacing(0f, fv) tv.typeface = timeline_font tv.text = text tv.measure( View.MeasureSpec.makeMeasureSpec(nAutoCwCellWidth, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ) val l = tv.layout if(l != null) { auto_cw.originalLineCount = l.lineCount val line_count = auto_cw.originalLineCount if((nAutoCwLines > 0 && line_count > nAutoCwLines) && status.spoiler_text.isEmpty() && (status.mentions?.size ?: 0) <= nAutoCwLines ) { val sb = SpannableStringBuilder() sb.append(getString(R.string.auto_cw_prefix)) sb.append(text, 0, l.getLineEnd(nAutoCwLines - 1)) var last = sb.length while(last > 0) { val c = sb[last - 1] if(c == '\n' || Character.isWhitespace(c)) { -- last continue } break } if(last < sb.length) { sb.delete(last, sb.length) } sb.append('…') auto_cw.decoded_spoiler_text = sb } } } private fun checkPrivacyPolicy() { // 既に表示中かもしれない if(dlgPrivacyPolicy?.get()?.isShowing == true) return val res_id = when(getString(R.string.language_code)) { "ja" -> R.raw.privacy_policy_ja "fr" -> R.raw.privacy_policy_fr else -> R.raw.privacy_policy_en } // プライバシーポリシーデータの読み込み val bytes = loadRawResource(res_id) if(bytes.isEmpty()) return // 同意ずみなら表示しない val digest = bytes.digestSHA256().encodeBase64Url() if(digest == Pref.spAgreedPrivacyPolicyDigest(pref)) return val dialog = AlertDialog.Builder(this) .setTitle(R.string.privacy_policy) .setMessage(bytes.decodeUTF8()) .setNegativeButton(R.string.cancel) { _, _ -> finish() } .setOnCancelListener { finish() } .setPositiveButton(R.string.agree) { _, _ -> pref.edit().put(Pref.spAgreedPrivacyPolicyDigest, digest).apply() } .create() dlgPrivacyPolicy = WeakReference(dialog) dialog.show() } }