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

2824 lines
102 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.notification.PollingWorker
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(), 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<Dialog>? = 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: () -> Unit = {
showToast(false, R.string.follow_succeeded)
}
val unfollow_complete_callback: () -> Unit = {
showToast(false, R.string.unfollow_succeeded)
}
val cancel_follow_request_complete_callback: () -> Unit = {
showToast(false, R.string.follow_request_cancelled)
}
val favourite_complete_callback: () -> Unit = {
showToast(false, R.string.favourite_succeeded)
}
val unfavourite_complete_callback: () -> Unit = {
showToast(false, R.string.unfavourite_succeeded)
}
val bookmark_complete_callback: () -> Unit = {
showToast(false, R.string.bookmark_succeeded)
}
val unbookmark_complete_callback: () -> Unit = {
showToast(false, R.string.unbookmark_succeeded)
}
val boost_complete_callback: () -> Unit = {
showToast(false, R.string.boost_succeeded)
}
val unboost_complete_callback: () -> Unit = {
showToast(false, R.string.unboost_succeeded)
}
val reaction_complete_callback: () -> Unit = {
showToast(false, R.string.reaction_succeeded)
}
// 相対時刻の表記を定期的に更新する
private val proc_updateRelativeTime = object : Runnable {
override fun run() {
handler.removeCallbacks(this)
if (!isStart_) return
if (Pref.bpRelativeTimestamp(pref)) {
app_state.columnList.forEach { it.fireRelativeTime() }
handler.postDelayed(this, 10000L)
}
}
}
private val link_click_listener: (View, MyClickableSpan) -> Unit = { viewClicked, span ->
// ビュー階層を下から辿って文脈を取得する
var column: Column? = null
var whoRef: TootAccountRef? = null
var view = viewClicked
loop@ while (true) {
when (val tag = view.tag) {
is ItemViewHolder -> {
column = tag.column
whoRef = tag.getAccount()
break@loop
}
is ViewHolderItem -> {
column = tag.ivh.column
whoRef = tag.ivh.getAccount()
break@loop
}
is ColumnViewHolder -> {
column = tag.column
whoRef = null
break@loop
}
is ViewHolderHeaderBase -> {
column = tag.column
whoRef = tag.getAccount()
break@loop
}
is TabletColumnViewHolder -> {
column = tag.columnViewHolder.column
break@loop
}
else -> when (val parent = view.parent) {
is View -> view = parent
else -> break@loop
}
}
}
val hashtagList = ArrayList<String>().apply {
try {
val cs = viewClicked.cast<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) add(if (li.text.startsWith('#')) li.text else "#${pair.first}")
}
}
} catch (ex: Throwable) {
log.trace(ex)
}
}
val linkInfo = span.linkInfo
openCustomTab(
this,
nextPosition(column),
linkInfo.url,
accessInfo = column?.access_info,
tagList = hashtagList.notEmpty(),
whoRef = whoRef,
linkInfo = linkInfo
)
}
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()
// スマホモードなら現在のカラムを、タブレットモードなら-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<Column>
get() {
val list = app_state.columnList
return visibleColumnsIndices.mapNotNull { list.elementAtOrNull(it) }
}
// デフォルトの投稿先アカウントを探す。アカウント選択が必要な状況なら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<SavedAccount>()
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 = App1.getAppState(this).handler
app_state = App1.getAppState(this)
pref = App1.pref
EmojiDecoder.handleUnicodeEmoji = Pref.bpInAppUnicodeEmoji(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.columnCount > 0) {
val column_pos = Pref.ipLastColumnPos(pref)
log.d("ipLastColumnPos load $column_pos")
// 前回最後に表示していたカラムの位置にスクロールする
if (column_pos in 0 until app_state.columnCount) {
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 への参照を全カラムから除去する
app_state.columnList.forEach {
it.removeColumnViewHolderByActivity(this)
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
log.w("onNewIntent: isResumed=$isResumed")
}
override fun onConfigurationChanged(newConfig: Configuration) {
log.w("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) }
}
)
app_state.columnList.forEach { it.saveScrollPosition() }
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
log.d("onRestoreInstanceState")
super.onRestoreInstanceState(savedInstanceState)
val pos = savedInstanceState.getInt(STATE_CURRENT_PAGE)
// 注意開始は0じゃなく1
if (pos in 1 until app_state.columnCount) {
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()
?: attrColor(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 = app_state.columnList
.mapIndexedNotNull { index, column ->
if (!column.access_info.isNA && null == SavedAccount.loadAccount(this@ActMain, column.access_info.db_id)) {
null
} else {
index
}
}
if (new_order.size != app_state.columnCount) {
setOrder(new_order)
}
te = SystemClock.elapsedRealtime()
if (te - ts >= 100L) log.w("onStart: ${te - ts}ms : column order")
ts = SystemClock.elapsedRealtime()
// 背景画像を表示しない設定が変更された時にカラムの背景を設定しなおす
app_state.columnList.forEach { column ->
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()
// 画面復帰時に再取得などを行う
app_state.columnList.forEach { it.onStart() }
te = SystemClock.elapsedRealtime()
if (te - ts >= 100L) log.w("onStart: ${te - ts}ms :column.onStart")
ts = SystemClock.elapsedRealtime()
// 画面復帰時にストリーミング接続を開始する
app_state.streamManager.onScreenStart()
te = SystemClock.elapsedRealtime()
if (te - ts >= 100L) log.w("onStart: ${te - ts}ms :multi_stream_reader.onScreenStart")
ts = SystemClock.elapsedRealtime()
// カラムの表示範囲インジケータを更新
updateColumnStripSelection(-1, -1f)
te = SystemClock.elapsedRealtime()
if (te - ts >= 100L) log.w("onStart: ${te - ts}ms :updateColumnStripSelection")
ts = SystemClock.elapsedRealtime()
app_state.columnList.forEach { it.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.streamManager.onScreenStop()
app_state.columnList.forEach { it.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)
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()
app_state.columnList.forEach { it.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 {
app_state.column(position)?.let { column ->
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)
R.id.btnQuickToot -> performQuickPost(null)
R.id.btnQuickTootMenu -> performQuickTootMenu()
}
}
////////////////////////////////////////////////////////////////////
// スマホモードとタブレットモードでコードを切り替える
private inline fun <R> 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 <R> phoneOnly(code: (PhoneEnv) -> R): R? {
val pe = phoneEnv
return if (pe != null) code(pe) else null
}
// タブレットモードならラムダを実行する。スマホモードならnullを返す
@Suppress("unused")
private inline fun <R> tabOnly(code: (TabletEnv) -> R): R? {
val te = tabletEnv
return if (te != null) code(te) else null
}
// 新しいカラムをどこに挿入するか
// カラムの次の位置か、現在のページの次の位置か、終端
fun nextPosition(column: Column?): Int =
app_state.columnIndex(column)?.let{ it+1 } ?: defaultInsertPosition
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 fun refreshAfterPost() {
val posted_acct = this.posted_acct
val posted_status_id = this.posted_status_id
if (posted_acct != null && posted_status_id == null) {
// 予約投稿なら予約投稿リストをリロードする
app_state.columnList.forEach { column ->
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) {
app_state.columnList.forEach {
it.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) {
app_state.columnList
.filter{ it.access_info.acct == posted_acct}
.forEach {
it.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: List<Int>): Boolean {
if (new_order.size != app_state.columnCount) return true
for(i in new_order.indices){
if (new_order[i] != i) return true
}
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)
}
val select = data.getIntExtra(ActColumnList.EXTRA_SELECTION, -1)
if (select in 0 until app_state.columnCount) {
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()
app_state.columnList.forEach { it.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)
app_state.column(idx)?.let{
it.fireColumnColor()
it.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)
app_state.column(idx)?.onLanguageFilterChanged()
}
}
}
when (requestCode) {
REQUEST_CODE_ACCOUNT_SETTING -> {
updateColumnStrip()
app_state.columnList.forEach { it.fireShowColumnHeader() }
when (resultCode) {
RESULT_OK -> data?.data?.let { openBrowser(it) }
ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN ->
data?.getLongExtra(ActAccountSetting.EXTRA_DB_ID, -1L)
?.takeIf { it != -1L }?.let { checkAccessToken2(it) }
}
}
REQUEST_CODE_APP_SETTING -> {
Column.reloadDefaultColor(this, pref)
showFooterColor()
updateColumnStrip()
if (resultCode == RESULT_APP_DATA_IMPORT) {
data?.data?.let { importAppData(it) }
}
}
REQUEST_CODE_TEXT -> when (resultCode) {
ActText.RESULT_SEARCH_MSP -> searchFromActivityResult(data, ColumnType.SEARCH_MSP)
ActText.RESULT_SEARCH_TS -> searchFromActivityResult(data, ColumnType.SEARCH_TS)
ActText.RESULT_SEARCH_NOTESTOCK -> searchFromActivityResult(data, ColumnType.SEARCH_NOTESTOCK)
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onBackPressed() {
// メニューが開いていたら閉じる
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START)
return
}
// カラムが0個ならアプリを終了する
if (app_state.columnCount == 0 ) {
finish()
return
}
// カラム設定が開いているならカラム設定を閉じる
if (closeColumnSetting()) {
return
}
fun getClosableColumnList(): List<Column> {
val visibleColumnList = ArrayList<Column>()
phoneTab({ env ->
try {
app_state.column(env.pager.currentItem)?.addTo(visibleColumnList)
} 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(false, R.string.missing_closeable_column)
}
}
1 -> {
closeColumn(closeableColumnList.first())
}
else -> {
showToast(
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
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, handler, 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)
etQuickToot.typeface = timeline_font
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
phoneEnv = PhoneEnv()
} else {
// Tablet mode
tabletEnv = TabletEnv()
}
val tmpPhonePager: MyViewPager = findViewById(R.id.viewPager)
val tmpTabletPager: RecyclerView = findViewById(R.id.rvPager)
phoneTab({ env ->
tmpTabletPager.visibility = View.GONE
env.pager = tmpPhonePager
env.pager_adapter = ColumnPagerAdapter(this)
env.pager.adapter = env.pager_adapter
env.pager.addOnPageChangeListener(this)
resizeAutoCW(sw)
}, { env ->
tmpPhonePager.visibility = View.GONE
env.tablet_pager = tmpTabletPager
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.columnCount - 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)
}
})
showQuickTootVisibility()
}
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.columnCount==0)
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()
app_state.columnList.forEachIndexed { index, column ->
val viewRoot = layoutInflater.inflate(R.layout.lv_column_strip, llColumnStrip, false)
val ivIcon = viewRoot.findViewById<ImageView>(R.id.ivIcon)
val vAcctColor = viewRoot.findViewById<View>(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 = index
viewRoot.setOnClickListener { v ->
val idx = v.tag as Int
if (Pref.bpScrollTopFromColumnStrip(pref) && isVisibleColumn(idx)) {
column.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.columnCount == 0 ) {
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)
}
// ActOAuthCallbackで受け取ったUriを処理する
private fun handleIntentUri(uri: Uri) {
log.d("handleIntentUri ${uri}")
when (uri.scheme) {
"subwaytooter", "misskeyclientproto" -> return try {
handleCustomSchemaUri(uri)
} catch (ex: Throwable) {
log.trace(ex)
showToast(ex, "handleCustomSchemaUri failed.")
}
}
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 my_name = packageName
val resolveInfoList = packageManager.queryIntentActivities(intent, query_flag)
.filter { my_name != it.activityInfo.packageName }
if (resolveInfoList.isEmpty()) {
throw RuntimeException("resolveInfoList is empty.")
}
// このアプリ以外の選択肢を集める
val choice_list = resolveInfoList
.map {
Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
`package` = it.activityInfo.packageName
setClassName(it.activityInfo.packageName, it.activityInfo.name)
}
}.toMutableList()
val chooser = Intent.createChooser(choice_list.removeAt(0), error_message)
// 2つめ以降はEXTRAに渡す
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 handleNotificationClick(uri: Uri, dataIdString: String) {
try {
val account = dataIdString.toLongOrNull()?.let { SavedAccount.loadAccount(this, it) }
if (account == null) {
showToast(true, "handleNotificationClick: missing SavedAccount. id=$dataIdString")
return
}
PollingWorker.queueNotificationClicked(this, uri)
val columnList = app_state.columnList
val column =columnList.firstOrNull {
it.type == ColumnType.NOTIFICATIONS &&
it.access_info == account &&
!it.system_notification_not_related
}?.also {
scrollToColumn(columnList.indexOf(it))
} ?: addColumn(
true,
defaultInsertPosition,
account,
ColumnType.NOTIFICATIONS
)
// 通知を読み直す
if (!column.bInitialLoading) column.startLoading()
} catch (ex: Throwable) {
log.trace(ex)
}
}
private fun handleOAuth2Callback(uri: Uri) {
TootTaskRunner(this@ActMain).run(object : TootTask {
var ta: TootAccount? = null
var sa: SavedAccount? = null
var host: Host? = null
var ti: TootInstance? = null
override suspend 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.isNullOrBlank())
return TootApiResult("missing token in callback URL")
val prefDevice = PrefDevice.prefDevice(this@ActMain)
val instance = Host.parse(
prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null)
?: return TootApiResult("missing instance name.")
)
when (val db_id = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) {
// new registration
-1L -> client.apiHost = instance
// update access token
else -> 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"))
}
}
val (ti, r2) = TootInstance.get(client)
ti ?: return r2
this.ti = ti
this.host = instance
val parser = TootParser(
this@ActMain,
linkHelper = LinkHelper.create(
instance,
misskeyVersion = ti.misskeyVersion
)
)
return client.authentication2Misskey(
Pref.spClientName(this@ActMain),
token,
ti.misskeyVersion
)?.also { this.ta = parser.account(it.jsonObject) }
} 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")
val error_description = uri.getQueryParameter("error_description")
if (error != null || error_description != null)
return TootApiResult(error_description.notBlank() ?: error.notBlank()
?: "?")
// subwaytooter://oauth(\d*)/
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
// &state=host%3Amastodon.juggler.jp
val code = uri.getQueryParameter("code")
val sv = uri.getQueryParameter("state")
if (code.isNullOrBlank())
return TootApiResult("missing code in callback url.")
if (sv.isNullOrBlank())
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.apiHost = host
}
// ignore other parameter
}
}
val instance = client.apiHost
?: return TootApiResult("missing instance in callback url.")
val (ti, r2) = TootInstance.get(client)
ti ?: return r2
this.ti = ti
this.host = instance
val parser = TootParser(
this@ActMain,
linkHelper = LinkHelper.create(instance)
)
return client.authentication2(
Pref.spClientName(this@ActMain),
code
)?.also { this.ta = parser.account(it.jsonObject) }
}
}
override suspend 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, ti, host)
}
})
}
private fun handleCustomSchemaUri(uri: Uri) {
val dataIdString = uri.getQueryParameter("db_id")
if (dataIdString != null) {
// subwaytooter://notification_click/?db_id=(db_id)
handleNotificationClick(uri, dataIdString)
} else {
// OAuth2 認証コールバック
// subwaytooter://oauth(\d*)/?...
handleOAuth2Callback(uri)
}
}
internal fun afterAccountVerify(
result: TootApiResult?,
ta: TootAccount?,
sa: SavedAccount?,
ti: TootInstance?,
host: Host?
): Boolean {
result ?: return false
val jsonObject = result.jsonObject
val token_info = result.tokenInfo
val error = result.error
when {
error != null ->
showToast(true, "${result.error} ${result.requestInfo}".trim())
token_info == null ->
showToast(true, "can't get access token.")
jsonObject == null ->
showToast(true, "can't parse json response.")
// 自分のユーザネームを取れなかった
// …普通はエラーメッセージが設定されてるはずだが
ta == null -> showToast(true, "can't verify user credential.")
// アクセストークン更新時
// インスタンスは同じだと思うが、ユーザ名が異なる可能性がある
sa != null -> if (sa.username != ta.username) {
showToast(true, R.string.user_name_not_match)
} else {
showToast(false, R.string.access_token_updated_for, sa.acct.pretty)
// DBの情報を更新する
sa.updateTokenInfo(token_info)
// 各カラムの持つアカウント情報をリロードする
reloadAccountSetting()
// 自動でリロードする
app_state.columnList
.filter { it.access_info == sa }
.forEach { it.startLoading() }
// 通知の更新が必要かもしれない
PushSubscriptionHelper.clearLastCheck(sa)
PollingWorker.queueUpdateNotification(this@ActMain)
return true
}
host != null -> {
// アカウント追加時
val user = Acct.parse(ta.username, host)
val apDomain = ti?.uri
if (apDomain == null) {
showToast(false, "Can't get ActivityPub domain name.")
return false
}
val row_id = SavedAccount.insert(
acct = user.ascii,
host = host.ascii,
domain = apDomain,
account = jsonObject,
token = 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(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?,
apiHost: Host,
access_token: String,
sa: SavedAccount?
) {
TootTaskRunner(this@ActMain).run(apiHost, object : TootTask {
var ta: TootAccount? = null
var ti: TootInstance? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (instance, instanceResult) = TootInstance.get(client, apiHost)
instance ?: return instanceResult
this.ti = instance
val misskeyVersion = instance.misskeyVersion
val result = client.getUserCredential(access_token, misskeyVersion = misskeyVersion)
this.ta = TootParser(
this@ActMain,
LinkHelper.create(
apiHost,
apDomainArg = instance.uri?.let { Host.parse(it) },
misskeyVersion = misskeyVersion
)
).account(result?.jsonObject)
return result
}
override suspend fun handleResult(result: TootApiResult?) {
if (afterAccountVerify(result, ta, sa, ti, apiHost)) {
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.apiHost, text, sa)
}
override fun onEmptyError() {
showToast(true, R.string.token_not_specified)
}
})
}
private fun reloadAccountSetting() {
val done_list = ArrayList<SavedAccount>()
for (column in app_state.columnList) {
val a = column.access_info
if (done_list.contains(a)) continue
done_list.add(a)
if (!a.isNA) a.reloadSetting(this)
column.fireShowColumnHeader()
}
}
fun reloadAccountSetting(account: SavedAccount) {
val done_list = ArrayList<SavedAccount>()
for (column in app_state.columnList) {
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(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
}
app_state.columnIndex(column)?.let{ page_delete ->
phoneTab({ env ->
val page_showing = env.pager.currentItem
removeColumn(column)
if ( page_showing == page_delete) {
scrollAndLoad(page_showing-1)
}
}, {
removeColumn(column)
scrollAndLoad( page_delete - 1)
})
}
}
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 }
app_state.editColumnList { list ->
for (i in list.indices.reversed()) {
val column = list[i]
if (column.dont_close) continue
list.removeAt(i).dispose()
if (lastColumnIndex >= i) --lastColumnIndex
}
}
phoneTab(
{ env -> env.pager.adapter = env.pager_adapter },
{ env -> resizeColumnWidth(env) }
)
updateColumnStrip()
scrollAndLoad(lastColumnIndex)
}
private fun scrollAndLoad(idx:Int){
val c = app_state.column(idx) ?:return
scrollToColumn(idx)
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) {
// 既に同じカラムがあればそこに移動する
app_state.columnList.forEachIndexed { i, column ->
if (column.isSameSpec(ai, type, params)) {
scrollToColumn(i)
return column
}
}
}
//
val col = Column(app_state, ai, type.id, *params)
val index = addColumn(col, indexArg)
scrollAndLoad(index)
return col
}
fun showColumnMatchAccount(account: SavedAccount) {
app_state.columnList.forEach { column ->
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 colorColumnStripBackground = footer_tab_bg_color.notZero()
?: attrColor(R.attr.colorColumnStripBackground)
svColumnStrip.setBackgroundColor(colorColumnStripBackground)
llQuickTootBar.setBackgroundColor(colorColumnStripBackground)
val colorButtonBg = footer_button_bg_color.notZero()
?: colorColumnStripBackground
val colorButtonFg = footer_button_fg_color.notZero()
?: attrColor(R.attr.colorRippleEffect)
btnMenu.backgroundDrawable =
getAdaptiveRippleDrawableRound(this, colorButtonBg, colorButtonFg)
btnToot.backgroundDrawable =
getAdaptiveRippleDrawableRound(this, colorButtonBg, colorButtonFg)
btnQuickToot.backgroundDrawable =
getAdaptiveRippleDrawableRound(this, colorButtonBg, colorButtonFg)
btnQuickTootMenu.backgroundDrawable =
getAdaptiveRippleDrawableRound(this, colorButtonBg, colorButtonFg)
val csl = ColorStateList.valueOf(
footer_button_fg_color.notZero()
?: attrColor(R.attr.colorVectorDrawable)
)
btnToot.imageTintList = csl
btnMenu.imageTintList = csl
btnQuickToot.imageTintList = csl
btnQuickTootMenu.imageTintList = csl
val c = footer_tab_divider_color.notZero()
?: colorColumnStripBackground
vFooterDivider1.setBackgroundColor(c)
vFooterDivider2.setBackgroundColor(c)
llColumnStrip.indicatorColor = footer_tab_indicator_color.notZero()
?: attrColor(R.attr.colorAccent)
}
/////////////////////////////////////////////////////////////////////////
// タブレット対応で必要になった関数など
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 {
val index = indexArg.clip(0, app_state.columnCount)
phoneOnly { env -> env.pager.adapter = null }
app_state.editColumnList {
it.add(index, column)
}
phoneTab(
{ env -> env.pager.adapter = env.pager_adapter },
{ env -> resizeColumnWidth(env) }
)
updateColumnStrip()
return index
}
private fun removeColumn(column: Column) {
val idx_column = app_state.columnIndex(column) ?: return
phoneOnly { env -> env.pager.adapter = null }
app_state.editColumnList {
it.removeAt(idx_column).dispose()
}
phoneTab(
{ env -> env.pager.adapter = env.pager_adapter },
{ env -> resizeColumnWidth(env) }
)
updateColumnStrip()
}
private fun setOrder(new_order: List<Int>) {
phoneOnly { env -> env.pager.adapter = null }
app_state.editColumnList { list ->
// columns with new order
val tmp_list = new_order.mapNotNull { i -> list.elementAtOrNull(i) }
val used_set = new_order.toSet()
list.forEachIndexed { i, v ->
if (!used_set.contains(i)) v.dispose()
}
list.clear()
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.columnCount
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)
}
)
}
//////////////////////////////////////////////////////////////////////////////////////////////
@Suppress("BlockingMethodInNonBlockingContext")
private fun importAppData(uri: Uri) {
// remove all columns
phoneOnly { env -> env.pager.adapter = null }
app_state.editColumnList(save = false) { list ->
list.forEach { it.dispose() }
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<Column>? = 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(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.editColumnList { list ->
list.clear()
list.addAll(it)
}
phoneTab(
{ env -> env.pager.adapter = env.pager_adapter },
{ env -> resizeColumnWidth(env) }
)
updateColumnStrip()
} finally {
// 通知サービスをリスタート
PollingWorker.queueAppDataImportAfter(this@ActMain)
}
showToast(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 tv = TextView(this).apply {
layoutParams =
LinearLayout.LayoutParams(nAutoCwCellWidth, LinearLayout.LayoutParams.WRAP_CONTENT)
if (!timeline_font_size_sp.isNaN())
textSize = timeline_font_size_sp
val fv = timeline_spacing
if (fv != null) setLineSpacing(0f, fv)
typeface = timeline_font
this.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()
}
private fun searchFromActivityResult(data: Intent?, columnType: ColumnType) =
data?.getStringExtra(Intent.EXTRA_TEXT)?.let {
addColumn(
false,
defaultInsertPosition,
SavedAccount.na,
columnType,
it
)
}
}