more split...
This commit is contained in:
parent
ac2f54c22a
commit
6de96b4852
|
@ -51,7 +51,7 @@
|
|||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# your package
|
||||
# jp.juggler.subwaytooter
|
||||
-keep,includedescriptorclasses class jp.juggler.subwaytooter.**$$serializer { *; }
|
||||
-keepclassmembers class jp.juggler.subwaytooter.** {
|
||||
*** Companion;
|
||||
|
|
|
@ -36,8 +36,8 @@ import jp.juggler.subwaytooter.table.SavedAccount
|
|||
import jp.juggler.subwaytooter.util.*
|
||||
import jp.juggler.subwaytooter.view.MyNetworkImageView
|
||||
import jp.juggler.util.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
|
@ -82,7 +82,7 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
|
|||
data class State(
|
||||
var propName: String = "",
|
||||
|
||||
@kotlinx.serialization.Serializable(with = UriOrNullSerializer::class)
|
||||
@kotlinx.serialization.Serializable(with = UriSerializer::class)
|
||||
var uriCameraImage: Uri? = null,
|
||||
)
|
||||
|
||||
|
@ -254,7 +254,7 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
|
|||
|
||||
if (savedInstanceState != null) {
|
||||
savedInstanceState.getString(ACTIVITY_STATE)
|
||||
?.let { state = Json.decodeFromString(it) }
|
||||
?.let { state = kJson.decodeFromString(it) }
|
||||
}
|
||||
|
||||
App1.setActivityTheme(this)
|
||||
|
@ -278,9 +278,9 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
val encodedState = Json.encodeToString(state)
|
||||
val encodedState = kJson.encodeToString(state)
|
||||
log.d("encodedState=$encodedState")
|
||||
val decodedState: State = Json.decodeFromString(encodedState)
|
||||
val decodedState: State = kJson.decodeFromString(encodedState)
|
||||
log.d("encodedState.uriCameraImage=${decodedState.uriCameraImage}")
|
||||
outState.putString(ACTIVITY_STATE, encodedState)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,74 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import java.util.ArrayList
|
||||
|
||||
// デフォルトの投稿先アカウントを探す。アカウント選択が必要な状況ならnull
|
||||
val ActMain.currentPostTarget: SavedAccount?
|
||||
get() = phoneTab(
|
||||
{ env ->
|
||||
val c = env.pagerAdapter.getColumn(env.pager.currentItem)
|
||||
return when {
|
||||
c == null || c.accessInfo.isPseudo -> null
|
||||
else -> c.accessInfo
|
||||
}
|
||||
},
|
||||
{ env ->
|
||||
|
||||
val dbId = PrefL.lpTabletTootDefaultAccount(App1.pref)
|
||||
if (dbId != -1L) {
|
||||
val a = SavedAccount.loadAccount(this@currentPostTarget, dbId)
|
||||
if (a != null && !a.isPseudo) return a
|
||||
}
|
||||
|
||||
val accounts = ArrayList<SavedAccount>()
|
||||
for (c in env.visibleColumns) {
|
||||
try {
|
||||
val a = c.accessInfo
|
||||
// 画面内に疑似アカウントがあれば常にアカウント選択が必要
|
||||
if (a.isPseudo) {
|
||||
accounts.clear()
|
||||
break
|
||||
}
|
||||
// 既出でなければ追加する
|
||||
if (null == accounts.find { it == a }) accounts.add(a)
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return when (accounts.size) {
|
||||
// 候補が1つだけならアカウント選択は不要
|
||||
1 -> accounts.first()
|
||||
// 候補が2つ以上ならアカウント選択は必要
|
||||
else -> null
|
||||
}
|
||||
})
|
||||
|
||||
fun ActMain.reloadAccountSetting(
|
||||
newAccounts: ArrayList<SavedAccount> = SavedAccount.loadAccountList(this),
|
||||
) {
|
||||
for (column in appState.columnList) {
|
||||
val a = column.accessInfo
|
||||
if (!a.isNA) a.reloadSetting(this, newAccounts.find { it.acct == a.acct })
|
||||
column.fireShowColumnHeader()
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.reloadAccountSetting(account: SavedAccount) {
|
||||
val newData = SavedAccount.loadAccount(this, account.db_id)
|
||||
?: return
|
||||
for (column in appState.columnList) {
|
||||
val a = column.accessInfo
|
||||
if (a.acct != newData.acct) continue
|
||||
if (!a.isNA) a.reloadSetting(this, newData)
|
||||
column.fireShowColumnHeader()
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.showColumnMatchAccount(account: SavedAccount) {
|
||||
appState.columnList.forEach { column ->
|
||||
if (account == column.accessInfo) {
|
||||
column.fireRebindAdapterItems()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.Intent
|
||||
import jp.juggler.subwaytooter.api.entity.Acct
|
||||
import jp.juggler.subwaytooter.api.entity.EntityId
|
||||
import jp.juggler.util.isLiveActivity
|
||||
|
||||
// マルチウィンドウモードでは投稿画面から直接呼ばれる
|
||||
// 通常モードでは activityResultHandler 経由で呼ばれる
|
||||
fun ActMain.onCompleteActPost(data: Intent) {
|
||||
if (!isLiveActivity) return
|
||||
postedAcct = data.getStringExtra(ActPost.EXTRA_POSTED_ACCT)?.let { Acct.parse(it) }
|
||||
if (data.extras?.containsKey(ActPost.EXTRA_POSTED_STATUS_ID) == true) {
|
||||
postedStatusId = EntityId.from(data, ActPost.EXTRA_POSTED_STATUS_ID)
|
||||
postedReplyId = EntityId.from(data, ActPost.EXTRA_POSTED_REPLY_ID)
|
||||
postedRedraftId = EntityId.from(data, ActPost.EXTRA_POSTED_REDRAFT_ID)
|
||||
} else {
|
||||
postedStatusId = null
|
||||
}
|
||||
if (isStartedEx) refreshAfterPost()
|
||||
}
|
||||
|
||||
// 簡易投稿なら直接呼ばれる
|
||||
// ActPost経由なら画面復帰タイミングや onCompleteActPost から呼ばれる
|
||||
fun ActMain.refreshAfterPost() {
|
||||
val postedAcct = this.postedAcct
|
||||
val postedStatusId = this.postedStatusId
|
||||
|
||||
if (postedAcct != null && postedStatusId == null) {
|
||||
// 予約投稿なら予約投稿リストをリロードする
|
||||
appState.columnList.forEach { column ->
|
||||
if (column.type == ColumnType.SCHEDULED_STATUS &&
|
||||
column.accessInfo.acct == postedAcct
|
||||
) {
|
||||
column.startLoading()
|
||||
}
|
||||
}
|
||||
} else if (postedAcct != null && postedStatusId != null) {
|
||||
val postedRedraftId = this.postedRedraftId
|
||||
if (postedRedraftId != null) {
|
||||
val host = postedAcct.host
|
||||
if (host != null) {
|
||||
appState.columnList.forEach {
|
||||
it.onStatusRemoved(host, postedRedraftId)
|
||||
}
|
||||
}
|
||||
this.postedRedraftId = null
|
||||
}
|
||||
|
||||
val refreshAfterToot = PrefI.ipRefreshAfterToot(pref)
|
||||
if (refreshAfterToot != PrefI.RAT_DONT_REFRESH) {
|
||||
appState.columnList
|
||||
.filter { it.accessInfo.acct == postedAcct }
|
||||
.forEach {
|
||||
it.startRefreshForPost(
|
||||
refreshAfterToot,
|
||||
postedStatusId,
|
||||
postedReplyId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.postedAcct = null
|
||||
this.postedStatusId = null
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
val benchmarkLimitDefault = if (BuildConfig.DEBUG) 10L else 100L
|
||||
fun <T : Any?> benchmark(
|
||||
caption: String,
|
||||
limit: Long = benchmarkLimitDefault,
|
||||
block: () -> T,
|
||||
): T {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val rv = block()
|
||||
val duration = SystemClock.elapsedRealtime() - start
|
||||
if (duration >= limit) ActMain.log.w("benchmark: ${duration}ms : $caption")
|
||||
return rv
|
||||
}
|
|
@ -0,0 +1,482 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import jp.juggler.subwaytooter.table.AcctColor
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.*
|
||||
import org.jetbrains.anko.backgroundDrawable
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
// スマホモードなら現在のカラムを、タブレットモードなら-1Lを返す
|
||||
// (カラム一覧画面のデフォルト選択位置に使われる)
|
||||
val ActMain.currentColumn: Int
|
||||
get() = phoneTab(
|
||||
{ it.pager.currentItem },
|
||||
{ -1 }
|
||||
)
|
||||
|
||||
// 新しいカラムをどこに挿入するか
|
||||
// 現在のページの次の位置か、終端
|
||||
val ActMain.defaultInsertPosition: Int
|
||||
get() = phoneTab(
|
||||
{ it.pager.currentItem + 1 },
|
||||
{ Integer.MAX_VALUE }
|
||||
)
|
||||
|
||||
// カラム追加後など、そのカラムにスクロールして初期ロードを行う
|
||||
fun ActMain.scrollAndLoad(idx: Int) {
|
||||
val c = appState.column(idx) ?: return
|
||||
scrollToColumn(idx)
|
||||
if (!c.bFirstInitialized) c.startLoading()
|
||||
}
|
||||
|
||||
fun ActMain.addColumn(column: Column, indexArg: Int): Int {
|
||||
val index = indexArg.clip(0, appState.columnCount)
|
||||
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList {
|
||||
it.add(index, column)
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ env -> resizeColumnWidth(env) }
|
||||
)
|
||||
|
||||
updateColumnStrip()
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
fun ActMain.addColumn(
|
||||
allowColumnDuplication: Boolean,
|
||||
indexArg: Int,
|
||||
ai: SavedAccount,
|
||||
type: ColumnType,
|
||||
vararg params: Any,
|
||||
): Column {
|
||||
if (!allowColumnDuplication) {
|
||||
// 既に同じカラムがあればそこに移動する
|
||||
appState.columnList.forEachIndexed { i, column ->
|
||||
if (ColumnSpec.isSameSpec(column, ai, type, params)) {
|
||||
scrollToColumn(i)
|
||||
return column
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
val col = Column(appState, ai, type.id, *params)
|
||||
val index = addColumn(col, indexArg)
|
||||
scrollAndLoad(index)
|
||||
return col
|
||||
}
|
||||
|
||||
fun ActMain.addColumn(
|
||||
indexArg: Int,
|
||||
ai: SavedAccount,
|
||||
type: ColumnType,
|
||||
vararg params: Any,
|
||||
): Column {
|
||||
return addColumn(
|
||||
PrefB.bpAllowColumnDuplication(pref),
|
||||
indexArg,
|
||||
ai,
|
||||
type,
|
||||
*params
|
||||
)
|
||||
}
|
||||
|
||||
fun ActMain.removeColumn(column: Column) {
|
||||
val idxColumn = appState.columnIndex(column) ?: return
|
||||
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList {
|
||||
it.removeAt(idxColumn).dispose()
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ env -> resizeColumnWidth(env) }
|
||||
)
|
||||
|
||||
updateColumnStrip()
|
||||
}
|
||||
|
||||
fun ActMain.isVisibleColumn(idx: Int) = phoneTab(
|
||||
{ env ->
|
||||
val c = env.pager.currentItem
|
||||
c == idx
|
||||
}, { env ->
|
||||
idx >= 0 && idx in env.visibleColumnsIndices
|
||||
}
|
||||
)
|
||||
|
||||
fun ActMain.updateColumnStrip() {
|
||||
llEmpty.vg(appState.columnCount == 0)
|
||||
|
||||
val iconSize = ActMain.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()
|
||||
appState.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 (PrefB.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.accessInfo)
|
||||
if (AcctColor.hasColorForeground(ac)) {
|
||||
vAcctColor.setBackgroundColor(ac.color_fg)
|
||||
} else {
|
||||
vAcctColor.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
//
|
||||
llColumnStrip.addView(viewRoot)
|
||||
}
|
||||
svColumnStrip.requestLayout()
|
||||
updateColumnStripSelection(-1, -1f)
|
||||
}
|
||||
|
||||
fun ActMain.closeColumn(column: Column, bConfirmed: Boolean = false) {
|
||||
|
||||
if (column.dontClose) {
|
||||
showToast(false, R.string.column_has_dont_close_option)
|
||||
return
|
||||
}
|
||||
|
||||
if (!bConfirmed && !PrefB.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
|
||||
}
|
||||
|
||||
appState.columnIndex(column)?.let { page_delete ->
|
||||
phoneTab({ env ->
|
||||
val pageShowing = env.pager.currentItem
|
||||
|
||||
removeColumn(column)
|
||||
|
||||
if (pageShowing == page_delete) {
|
||||
scrollAndLoad(pageShowing - 1)
|
||||
}
|
||||
}, {
|
||||
removeColumn(column)
|
||||
scrollAndLoad(page_delete - 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.closeColumnAll(
|
||||
oldColumnIndex: 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(oldColumnIndex, true) }
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
var lastColumnIndex = when (oldColumnIndex) {
|
||||
-1 -> phoneTab(
|
||||
{ it.pager.currentItem },
|
||||
{ 0 }
|
||||
)
|
||||
else -> oldColumnIndex
|
||||
}
|
||||
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList { list ->
|
||||
for (i in list.indices.reversed()) {
|
||||
val column = list[i]
|
||||
if (column.dontClose) continue
|
||||
list.removeAt(i).dispose()
|
||||
if (lastColumnIndex >= i) --lastColumnIndex
|
||||
}
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ env -> resizeColumnWidth(env) }
|
||||
)
|
||||
|
||||
updateColumnStrip()
|
||||
|
||||
scrollAndLoad(lastColumnIndex)
|
||||
}
|
||||
|
||||
fun ActMain.closeColumnSetting(): Boolean {
|
||||
phoneTab({ env ->
|
||||
val vh = env.pagerAdapter.getColumnViewHolder(env.pager.currentItem)
|
||||
if (vh?.isColumnSettingShown == true) {
|
||||
vh.showColumnSetting(false)
|
||||
return@closeColumnSetting true
|
||||
}
|
||||
}, { env ->
|
||||
for (i in 0 until env.tabletLayoutManager.childCount) {
|
||||
|
||||
val columnViewHolder = when (val v = env.tabletLayoutManager.getChildAt(i)) {
|
||||
null -> null
|
||||
else -> (env.tabletPager.getChildViewHolder(v) as? TabletColumnViewHolder)?.columnViewHolder
|
||||
}
|
||||
|
||||
if (columnViewHolder?.isColumnSettingShown == true) {
|
||||
columnViewHolder.showColumnSetting(false)
|
||||
return@closeColumnSetting true
|
||||
}
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 新しいカラムをどこに挿入するか
|
||||
// カラムの次の位置か、現在のページの次の位置か、終端
|
||||
fun ActMain.nextPosition(column: Column?): Int =
|
||||
appState.columnIndex(column)?.let { it + 1 } ?: defaultInsertPosition
|
||||
|
||||
fun ActMain.isOrderChanged(newOrder: List<Int>): Boolean {
|
||||
if (newOrder.size != appState.columnCount) return true
|
||||
for (i in newOrder.indices) {
|
||||
if (newOrder[i] != i) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun ActMain.setColumnsOrder(newOrder: List<Int>) {
|
||||
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList { list ->
|
||||
// columns with new order
|
||||
val tmpList = newOrder.mapNotNull { i -> list.elementAtOrNull(i) }
|
||||
val usedSet = newOrder.toSet()
|
||||
list.forEachIndexed { i, v ->
|
||||
if (!usedSet.contains(i)) v.dispose()
|
||||
}
|
||||
list.clear()
|
||||
list.addAll(tmpList)
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ env -> resizeColumnWidth(env) }
|
||||
)
|
||||
|
||||
appState.saveColumnList()
|
||||
updateColumnStrip()
|
||||
}
|
||||
|
||||
fun ActMain.searchFromActivityResult(data: Intent?, columnType: ColumnType) =
|
||||
data?.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
addColumn(
|
||||
false,
|
||||
defaultInsertPosition,
|
||||
SavedAccount.na,
|
||||
columnType,
|
||||
it
|
||||
)
|
||||
}
|
||||
|
||||
fun ActMain.scrollToColumn(index: Int, smoothScroll: Boolean = true) {
|
||||
scrollColumnStrip(index)
|
||||
phoneTab(
|
||||
|
||||
// スマホはスムーススクロール基本ありだがたまにしない
|
||||
{ env ->
|
||||
ActMain.log.d("ipLastColumnPos beforeScroll=${env.pager.currentItem}")
|
||||
env.pager.setCurrentItem(index, smoothScroll)
|
||||
},
|
||||
|
||||
// タブレットでスムーススクロールさせると頻繁にオーバーランするので絶対しない
|
||||
{ env ->
|
||||
ActMain.log.d("ipLastColumnPos beforeScroll=${env.visibleColumnsIndices.first}")
|
||||
env.tabletPager.scrollToPosition(index)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun ActMain.resizeColumnWidth(views: TabletViews) {
|
||||
|
||||
var columnWMinDp = ActMain.COLUMN_WIDTH_MIN_DP
|
||||
val sv = PrefS.spColumnWidth(pref)
|
||||
if (sv.isNotEmpty()) {
|
||||
try {
|
||||
val iv = Integer.parseInt(sv)
|
||||
if (iv >= 100) {
|
||||
columnWMinDp = iv
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActMain.log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
val dm = resources.displayMetrics
|
||||
|
||||
val screenWidth = dm.widthPixels
|
||||
|
||||
val density = dm.density
|
||||
var columnWMin = (0.5f + columnWMinDp * density).toInt()
|
||||
if (columnWMin < 1) columnWMin = 1
|
||||
|
||||
var columnW: Int
|
||||
|
||||
if (screenWidth < columnWMin * 2) {
|
||||
// 最小幅で2つ表示できないのなら1カラム表示
|
||||
nScreenColumn = 1
|
||||
columnW = screenWidth
|
||||
} else {
|
||||
|
||||
// カラム最小幅から計算した表示カラム数
|
||||
nScreenColumn = screenWidth / columnWMin
|
||||
if (nScreenColumn < 1) nScreenColumn = 1
|
||||
|
||||
// データのカラム数より大きくならないようにする
|
||||
// (でも最小は1)
|
||||
val columnCount = appState.columnCount
|
||||
if (columnCount > 0 && columnCount < nScreenColumn) {
|
||||
nScreenColumn = columnCount
|
||||
}
|
||||
|
||||
// 表示カラム数から計算したカラム幅
|
||||
columnW = screenWidth / nScreenColumn
|
||||
|
||||
// 最小カラム幅の1.5倍よりは大きくならないようにする
|
||||
val columnWMax = (0.5f + columnWMin * 1.5f).toInt()
|
||||
if (columnW > columnWMax) {
|
||||
columnW = columnWMax
|
||||
}
|
||||
}
|
||||
|
||||
nColumnWidth = columnW // dividerの幅を含む
|
||||
|
||||
val dividerWidth = (0.5f + 1f * density).toInt()
|
||||
columnW -= dividerWidth
|
||||
views.tabletPagerAdapter.columnWidth = columnW // dividerの幅を含まない
|
||||
// env.tablet_snap_helper.columnWidth = column_w //使われていない
|
||||
|
||||
resizeAutoCW(columnW) // dividerの幅を含まない
|
||||
|
||||
// 並べ直す
|
||||
views.tabletPagerAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun ActMain.scrollColumnStrip(select: Int) {
|
||||
val childCount = llColumnStrip.childCount
|
||||
if (select < 0 || select >= childCount) {
|
||||
return
|
||||
}
|
||||
|
||||
val icon = llColumnStrip.getChildAt(select)
|
||||
|
||||
val svWidth = (llColumnStrip.parent as View).width
|
||||
val llWidth = llColumnStrip.width
|
||||
val iconWidth = icon.width
|
||||
val iconLeft = icon.left
|
||||
|
||||
if (svWidth == 0 || llWidth == 0 || iconWidth == 0) {
|
||||
handler.postDelayed({ scrollColumnStrip(select) }, 20L)
|
||||
}
|
||||
|
||||
val sx = iconLeft + iconWidth / 2 - svWidth / 2
|
||||
svColumnStrip.smoothScrollTo(sx, 0)
|
||||
}
|
||||
|
||||
fun ActMain.updateColumnStripSelection(position: Int, positionOffset: Float) {
|
||||
handler.post(Runnable {
|
||||
if (isFinishing) return@Runnable
|
||||
|
||||
if (appState.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.tabletLayoutManager.findFirstVisibleItemPosition()
|
||||
val ve = env.tabletLayoutManager.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 slideRatio = 0f
|
||||
if (vr.first <= vr.last) {
|
||||
val child = env.tabletLayoutManager.findViewByPosition(vr.first)
|
||||
slideRatio =
|
||||
clipRange(0f, 1f, abs((child?.left ?: 0) / nColumnWidth.toFloat()))
|
||||
}
|
||||
|
||||
llColumnStrip.setVisibleRange(vr.first, vr.last, slideRatio)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.Intent
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RawRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.*
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
fun ActMain.resizeAutoCW(columnW: Int) {
|
||||
val sv = PrefS.spAutoCWLines(pref)
|
||||
nAutoCwLines = sv.optInt() ?: -1
|
||||
if (nAutoCwLines > 0) {
|
||||
val lvPad = (0.5f + 12 * density).toInt()
|
||||
val iconWidth = avatarIconSize
|
||||
val iconEnd = (0.5f + 4 * density).toInt()
|
||||
nAutoCwCellWidth = columnW - lvPad * 2 - iconWidth - iconEnd
|
||||
}
|
||||
// この後各カラムは再描画される
|
||||
}
|
||||
|
||||
fun ActMain.checkAutoCW(status: TootStatus, text: CharSequence) {
|
||||
if (nAutoCwCellWidth <= 0) {
|
||||
// 設定が無効
|
||||
status.auto_cw = null
|
||||
return
|
||||
}
|
||||
|
||||
var autoCw = status.auto_cw
|
||||
if (autoCw != null &&
|
||||
autoCw.refActivity?.get() === this &&
|
||||
autoCw.cellWidth == nAutoCwCellWidth
|
||||
) {
|
||||
// 以前に計算した値がまだ使える
|
||||
return
|
||||
}
|
||||
|
||||
if (autoCw == null) {
|
||||
autoCw = TootStatus.AutoCW()
|
||||
status.auto_cw = autoCw
|
||||
}
|
||||
|
||||
// 計算時の条件(文字フォント、文字サイズ、カラム幅)を覚えておいて、再利用時に同じか確認する
|
||||
autoCw.refActivity = WeakReference(this)
|
||||
autoCw.cellWidth = nAutoCwCellWidth
|
||||
autoCw.decodedSpoilerText = null
|
||||
|
||||
// テキストをレイアウトして行数を測定
|
||||
val tv = TextView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(nAutoCwCellWidth, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
if (!timelineFontSizeSp.isNaN()) {
|
||||
textSize = timelineFontSizeSp
|
||||
}
|
||||
|
||||
val fv = timelineSpacing
|
||||
if (fv != null) setLineSpacing(0f, fv)
|
||||
|
||||
typeface = ActMain.timelineFont
|
||||
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) {
|
||||
autoCw.originalLineCount = l.lineCount
|
||||
val lineCount = autoCw.originalLineCount
|
||||
|
||||
if ((nAutoCwLines > 0 && lineCount > 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('…')
|
||||
autoCw.decodedSpoilerText = sb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.checkPrivacyPolicy() {
|
||||
|
||||
// 既に表示中かもしれない
|
||||
if (dlgPrivacyPolicy?.get()?.isShowing == true) return
|
||||
|
||||
@RawRes val resId = 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(resId)
|
||||
if (bytes.isEmpty()) return
|
||||
|
||||
// 同意ずみなら表示しない
|
||||
val digest = bytes.digestSHA256().encodeBase64Url()
|
||||
if (digest == PrefS.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(PrefS.spAgreedPrivacyPolicyDigest, digest).apply()
|
||||
}
|
||||
.create()
|
||||
dlgPrivacyPolicy = WeakReference(dialog)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
fun ActMain.closeListItemPopup() {
|
||||
try {
|
||||
listItemPopup?.dismiss()
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
listItemPopup = null
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import android.util.JsonReader
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.WorkerThread
|
||||
import jp.juggler.subwaytooter.notification.PollingWorker
|
||||
import jp.juggler.util.launchMain
|
||||
import jp.juggler.util.runOnMainLooper
|
||||
import jp.juggler.util.runWithProgress
|
||||
import jp.juggler.util.showToast
|
||||
import kotlinx.coroutines.delay
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.util.ArrayList
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
@WorkerThread
|
||||
fun ActMain.importAppData(uri: Uri) {
|
||||
launchMain {
|
||||
|
||||
// remove all columns
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList(save = false) { list ->
|
||||
list.forEach { it.dispose() }
|
||||
list.clear()
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ 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@importAppData)
|
||||
while (PollingWorker.mBusyAppDataImportBefore.get()) {
|
||||
delay(1000L)
|
||||
ActMain.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@importAppData,
|
||||
JsonReader(InputStreamReader(zipStream, "UTF-8"))
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (AppDataExporter.restoreBackgroundImage(
|
||||
this@importAppData,
|
||||
newColumnList,
|
||||
zipStream,
|
||||
entryName
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
} finally {
|
||||
zipStream.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActMain.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@importAppData,
|
||||
JsonReader(inStream)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
newColumnList
|
||||
},
|
||||
afterProc = {
|
||||
// cancelled.
|
||||
if (it == null) return@runWithProgress
|
||||
|
||||
try {
|
||||
phoneOnly { env -> env.pager.adapter = null }
|
||||
|
||||
appState.editColumnList { list ->
|
||||
list.clear()
|
||||
list.addAll(it)
|
||||
}
|
||||
|
||||
phoneTab(
|
||||
{ env -> env.pager.adapter = env.pagerAdapter },
|
||||
{ env -> resizeColumnWidth(env) }
|
||||
)
|
||||
updateColumnStrip()
|
||||
} finally {
|
||||
// 通知サービスをリスタート
|
||||
PollingWorker.queueAppDataImportAfter(this@importAppData)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,468 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.action.conversationOtherInstance
|
||||
import jp.juggler.subwaytooter.action.openActPostImpl
|
||||
import jp.juggler.subwaytooter.action.userProfile
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromUrl
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.dialog.pickAccount
|
||||
import jp.juggler.subwaytooter.notification.PollingWorker
|
||||
import jp.juggler.subwaytooter.notification.PushSubscriptionHelper
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.*
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private val log = LogCategory("ActMainIntent")
|
||||
|
||||
// ActOAuthCallbackで受け取ったUriを処理する
|
||||
fun ActMain.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) {
|
||||
// ステータスをアプリ内で開く
|
||||
conversationOtherInstance(
|
||||
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) {
|
||||
userProfile(
|
||||
defaultInsertPosition,
|
||||
null,
|
||||
Acct.parse(user, instance),
|
||||
userUrl = "https://$instance/@$user",
|
||||
originalUrl = url
|
||||
)
|
||||
} else {
|
||||
userProfile(
|
||||
defaultInsertPosition,
|
||||
null,
|
||||
acct = Acct.parse(user, host),
|
||||
userUrl = url,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// intentFilterの都合でこの形式のURLが飛んでくることはないのだが…。
|
||||
m = TootAccount.reAccountUrl2.matcher(url)
|
||||
if (m.find()) {
|
||||
val host = m.groupEx(1)!!
|
||||
val user = m.groupEx(2)!!.decodePercent()
|
||||
|
||||
userProfile(
|
||||
defaultInsertPosition,
|
||||
null,
|
||||
acct = Acct.parse(user, host),
|
||||
userUrl = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// このアプリでは処理できないURLだった
|
||||
// 外部ブラウザを開きなおそうとすると無限ループの恐れがある
|
||||
// アプリケーションチューザーを表示する
|
||||
|
||||
val errorMessage = getString(R.string.cant_handle_uri_of, url)
|
||||
|
||||
try {
|
||||
val queryFlag = 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 myName = packageName
|
||||
val resolveInfoList = packageManager.queryIntentActivities(intent, queryFlag)
|
||||
.filter { myName != it.activityInfo.packageName }
|
||||
|
||||
if (resolveInfoList.isEmpty()) error("resolveInfoList is empty.")
|
||||
|
||||
// このアプリ以外の選択肢を集める
|
||||
val choiceList = 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(choiceList.removeAt(0), errorMessage)
|
||||
// 2つめ以降はEXTRAに渡す
|
||||
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, choiceList.toTypedArray())
|
||||
|
||||
// 指定した選択肢でチューザーを作成して開く
|
||||
startActivity(chooser)
|
||||
return
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setCancelable(true)
|
||||
.setMessage(errorMessage)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun ActMain.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)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.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 = appState.columnList
|
||||
val column = columnList.firstOrNull {
|
||||
it.type == ColumnType.NOTIFICATIONS &&
|
||||
it.accessInfo == account &&
|
||||
!it.systemNotificationNotRelated
|
||||
}?.also {
|
||||
scrollToColumn(columnList.indexOf(it))
|
||||
} ?: addColumn(
|
||||
true,
|
||||
defaultInsertPosition,
|
||||
account,
|
||||
ColumnType.NOTIFICATIONS
|
||||
)
|
||||
|
||||
// 通知を読み直す
|
||||
if (!column.bInitialLoading) column.startLoading()
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.handleOAuth2Callback(uri: Uri) {
|
||||
launchMain {
|
||||
var resultTootAccount: TootAccount? = null
|
||||
var resultSavedAccount: SavedAccount? = null
|
||||
var resultApiHost: Host? = null
|
||||
var resultApDomain: Host? = null
|
||||
runApiTask { client ->
|
||||
|
||||
val uriStr = uri.toString()
|
||||
if (uriStr.startsWith("subwaytooter://misskey/auth_callback") ||
|
||||
uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback")
|
||||
) {
|
||||
|
||||
// Misskey 認証コールバック
|
||||
val token = uri.getQueryParameter("token")?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing token in callback URL")
|
||||
|
||||
val prefDevice = PrefDevice.from(this)
|
||||
|
||||
val hostStr = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null)?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing instance name.")
|
||||
|
||||
val instance = Host.parse(hostStr)
|
||||
|
||||
when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) {
|
||||
|
||||
// new registration
|
||||
-1L -> client.apiHost = instance
|
||||
|
||||
// update access token
|
||||
else -> try {
|
||||
val sa = SavedAccount.loadAccount(this@handleOAuth2Callback, dbId)
|
||||
?: return@runApiTask TootApiResult("missing account db_id=$dbId")
|
||||
resultSavedAccount = sa
|
||||
client.account = sa
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
return@runApiTask TootApiResult(ex.withCaption("invalid state"))
|
||||
}
|
||||
}
|
||||
|
||||
val (ti, r2) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask r2
|
||||
|
||||
resultApiHost = instance
|
||||
resultApDomain = ti.uri?.let { Host.parse(it) }
|
||||
|
||||
val parser = TootParser(
|
||||
this@handleOAuth2Callback,
|
||||
linkHelper = LinkHelper.create(
|
||||
instance,
|
||||
misskeyVersion = ti.misskeyVersion
|
||||
)
|
||||
)
|
||||
|
||||
client.authentication2Misskey(
|
||||
PrefS.spClientName(pref),
|
||||
token,
|
||||
ti.misskeyVersion
|
||||
)?.also {
|
||||
resultTootAccount = 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 errorDescription = uri.getQueryParameter("error_description")
|
||||
if (error != null || errorDescription != null) {
|
||||
return@runApiTask TootApiResult(
|
||||
errorDescription.notBlank() ?: error.notBlank() ?: "?"
|
||||
)
|
||||
}
|
||||
|
||||
// subwaytooter://oauth(\d*)/
|
||||
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
|
||||
// &state=host%3Amastodon.juggler.jp
|
||||
|
||||
val code = uri.getQueryParameter("code")?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing code in callback url.")
|
||||
|
||||
val sv = uri.getQueryParameter("state")?.notBlank()
|
||||
?: return@runApiTask 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@handleOAuth2Callback, dataId)
|
||||
?: return@runApiTask TootApiResult("missing account db_id=$dataId")
|
||||
resultSavedAccount = sa
|
||||
client.account = sa
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
return@runApiTask TootApiResult(ex.withCaption("invalid state"))
|
||||
}
|
||||
|
||||
param.startsWith("host:") -> {
|
||||
val host = Host.parse(param.substring(5))
|
||||
client.apiHost = host
|
||||
}
|
||||
|
||||
// ignore other parameter
|
||||
}
|
||||
}
|
||||
|
||||
val apiHost = client.apiHost
|
||||
?: return@runApiTask TootApiResult("missing instance in callback url.")
|
||||
resultApiHost = apiHost
|
||||
|
||||
val parser = TootParser(
|
||||
this@handleOAuth2Callback,
|
||||
linkHelper = LinkHelper.create(apiHost)
|
||||
)
|
||||
|
||||
val refToken = AtomicReference<String>(null)
|
||||
client.authentication2Mastodon(
|
||||
PrefS.spClientName(pref),
|
||||
code,
|
||||
outAccessToken = refToken
|
||||
)?.also { result ->
|
||||
val ta = parser.account(result.jsonObject)
|
||||
if (ta != null) {
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = refToken.get())
|
||||
ti ?: return@runApiTask ri
|
||||
resultTootAccount = ta
|
||||
resultApDomain = ti.uri?.let { Host.parse(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.let { result ->
|
||||
val apiHost = resultApiHost
|
||||
val apDomain = resultApDomain
|
||||
val ta = resultTootAccount
|
||||
var sa = resultSavedAccount
|
||||
if (ta != null && apiHost?.isValid == true && sa == null) {
|
||||
val acct = Acct.parse(ta.username, apDomain ?: apiHost)
|
||||
// アカウント追加時に、アプリ内に既にあるアカウントと同じものを登録していたかもしれない
|
||||
sa = SavedAccount.loadAccountByAcct(this@handleOAuth2Callback, acct.ascii)
|
||||
}
|
||||
afterAccountVerify(result, ta, sa, apiHost, apDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.afterAccountVerify(
|
||||
result: TootApiResult?,
|
||||
ta: TootAccount?,
|
||||
sa: SavedAccount?,
|
||||
apiHost: Host?,
|
||||
apDomain: Host?,
|
||||
): Boolean {
|
||||
result ?: return false
|
||||
|
||||
val jsonObject = result.jsonObject
|
||||
val tokenInfo = result.tokenInfo
|
||||
val error = result.error
|
||||
|
||||
when {
|
||||
error != null ->
|
||||
showToast(true, "${result.error} ${result.requestInfo}".trim())
|
||||
|
||||
tokenInfo == 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(tokenInfo)
|
||||
|
||||
// 各カラムの持つアカウント情報をリロードする
|
||||
reloadAccountSetting()
|
||||
|
||||
// 自動でリロードする
|
||||
appState.columnList
|
||||
.filter { it.accessInfo == sa }
|
||||
.forEach { it.startLoading() }
|
||||
|
||||
// 通知の更新が必要かもしれない
|
||||
PushSubscriptionHelper.clearLastCheck(sa)
|
||||
PollingWorker.queueUpdateNotification(this@afterAccountVerify)
|
||||
return true
|
||||
}
|
||||
|
||||
apiHost != null -> {
|
||||
// アカウント追加時
|
||||
val user = Acct.parse(ta.username, apDomain ?: apiHost)
|
||||
|
||||
val rowId = SavedAccount.insert(
|
||||
acct = user.ascii,
|
||||
host = apiHost.ascii,
|
||||
domain = (apDomain ?: apiHost).ascii,
|
||||
account = jsonObject,
|
||||
token = tokenInfo,
|
||||
misskeyVersion = TootInstance.parseMisskeyVersion(tokenInfo)
|
||||
)
|
||||
val account = SavedAccount.loadAccount(this@afterAccountVerify, rowId)
|
||||
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@afterAccountVerify)
|
||||
|
||||
// 適当にカラムを追加する
|
||||
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 ActMain.handleSentIntent(intent: Intent) {
|
||||
ActMain.sent_intent2 = intent
|
||||
|
||||
// Galaxy S8+ で STのSSを取った後に出るポップアップからそのまま共有でSTを選ぶと何も起きない問題への対策
|
||||
launchMain {
|
||||
val ai = pickAccount(
|
||||
bAllowPseudo = false,
|
||||
bAuto = true,
|
||||
message = getString(R.string.account_picker_toot),
|
||||
)
|
||||
ActMain.sent_intent2 = null
|
||||
ai?.let { openActPostImpl(it.db_id, sharedIntent = intent) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
import jp.juggler.subwaytooter.dialog.pickAccount
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.PostCompleteCallback
|
||||
import jp.juggler.subwaytooter.util.PostImpl
|
||||
import jp.juggler.util.hideKeyboard
|
||||
import jp.juggler.util.launchMain
|
||||
import org.jetbrains.anko.imageResource
|
||||
|
||||
// 簡易投稿入力のテキスト
|
||||
val ActMain.quickTootText: String
|
||||
get() = etQuickToot.text.toString()
|
||||
|
||||
fun ActMain.showQuickTootVisibility() {
|
||||
btnQuickTootMenu.imageResource =
|
||||
when (val resId = Styler.getVisibilityIconId(false, quickTootVisibility)) {
|
||||
R.drawable.ic_question -> R.drawable.ic_description
|
||||
else -> resId
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.performQuickTootMenu() {
|
||||
dlgQuickTootMenu.toggle()
|
||||
}
|
||||
|
||||
fun ActMain.performQuickPost(account: SavedAccount?) {
|
||||
if (account == null) {
|
||||
val a = if (tabletViews != null && !PrefB.bpQuickTootOmitAccountSelection(pref)) {
|
||||
// タブレットモードでオプションが無効なら
|
||||
// 簡易投稿は常にアカウント選択する
|
||||
null
|
||||
} else {
|
||||
currentPostTarget
|
||||
}
|
||||
|
||||
if (a != null && !a.isPseudo) {
|
||||
performQuickPost(a)
|
||||
} else {
|
||||
// アカウントを選択してやり直し
|
||||
launchMain {
|
||||
pickAccount(
|
||||
bAllowPseudo = false,
|
||||
bAuto = true,
|
||||
message = getString(R.string.account_picker_toot)
|
||||
)?.let { performQuickPost(it) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
etQuickToot.hideKeyboard()
|
||||
|
||||
PostImpl(
|
||||
activity = this,
|
||||
account = account,
|
||||
content = etQuickToot.text.toString().trim { it <= ' ' },
|
||||
spoilerText = null,
|
||||
visibilityArg = when (quickTootVisibility) {
|
||||
TootVisibility.AccountSetting -> account.visibility
|
||||
else -> quickTootVisibility
|
||||
},
|
||||
bNSFW = false,
|
||||
inReplyToId = null,
|
||||
attachmentListArg = null,
|
||||
enqueteItemsArg = null,
|
||||
pollType = null,
|
||||
pollExpireSeconds = 0,
|
||||
pollHideTotals = false,
|
||||
pollMultipleChoice = false,
|
||||
scheduledAt = 0L,
|
||||
scheduledId = null,
|
||||
redraftStatusId = null,
|
||||
emojiMapCustom = App1.custom_emoji_lister.getMap(account),
|
||||
useQuoteToot = false,
|
||||
callback = object : PostCompleteCallback {
|
||||
override fun onScheduledPostComplete(targetAccount: SavedAccount) {}
|
||||
override fun onPostComplete(targetAccount: SavedAccount, status: TootStatus) {
|
||||
etQuickToot.setText("")
|
||||
postedAcct = targetAccount.acct
|
||||
postedStatusId = status.id
|
||||
postedReplyId = status.in_reply_to_id
|
||||
postedRedraftId = null
|
||||
refreshAfterPost()
|
||||
}
|
||||
}
|
||||
).run()
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.span.MyClickableSpan
|
||||
import jp.juggler.subwaytooter.util.CustomShare
|
||||
import jp.juggler.subwaytooter.view.ListDivider
|
||||
import jp.juggler.subwaytooter.view.TabletColumnDivider
|
||||
import jp.juggler.util.attrColor
|
||||
import jp.juggler.util.getAdaptiveRippleDrawableRound
|
||||
import jp.juggler.util.notZero
|
||||
import org.jetbrains.anko.backgroundDrawable
|
||||
import java.util.*
|
||||
|
||||
// onStart時に呼ばれる
|
||||
fun ActMain.reloadTimeZone(){
|
||||
try {
|
||||
var tz = TimeZone.getDefault()
|
||||
val tzId = PrefS.spTimeZone(pref)
|
||||
if (tzId.isNotEmpty()) {
|
||||
tz = TimeZone.getTimeZone(tzId)
|
||||
}
|
||||
TootStatus.date_format.timeZone = tz
|
||||
} catch (ex: Throwable) {
|
||||
ActMain.log.e(ex, "getTimeZone failed.")
|
||||
}
|
||||
}
|
||||
|
||||
// onStart時に呼ばれる
|
||||
// カラーカスタマイズを読み直す
|
||||
fun ActMain.reloadColors(){
|
||||
ListDivider.color = PrefI.ipListDividerColor(pref)
|
||||
TabletColumnDivider.color = PrefI.ipListDividerColor(pref)
|
||||
ItemViewHolder.toot_color_unlisted = PrefI.ipTootColorUnlisted(pref)
|
||||
ItemViewHolder.toot_color_follower = PrefI.ipTootColorFollower(pref)
|
||||
ItemViewHolder.toot_color_direct_user = PrefI.ipTootColorDirectUser(pref)
|
||||
ItemViewHolder.toot_color_direct_me = PrefI.ipTootColorDirectMe(pref)
|
||||
MyClickableSpan.showLinkUnderline = PrefB.bpShowLinkUnderline(pref)
|
||||
MyClickableSpan.defaultLinkColor = PrefI.ipLinkColor(pref).notZero()
|
||||
?: attrColor(R.attr.colorLink)
|
||||
|
||||
CustomShare.reloadCache(this, pref)
|
||||
}
|
||||
|
||||
fun ActMain.showFooterColor() {
|
||||
val footerButtonBgColor = PrefI.ipFooterButtonBgColor(pref)
|
||||
val footerButtonFgColor = PrefI.ipFooterButtonFgColor(pref)
|
||||
val footerTabBgColor = PrefI.ipFooterTabBgColor(pref)
|
||||
val footerTabDividerColor = PrefI.ipFooterTabDividerColor(pref)
|
||||
val footerTabIndicatorColor = PrefI.ipFooterTabIndicatorColor(pref)
|
||||
|
||||
val colorColumnStripBackground = footerTabBgColor.notZero()
|
||||
?: attrColor(R.attr.colorColumnStripBackground)
|
||||
|
||||
svColumnStrip.setBackgroundColor(colorColumnStripBackground)
|
||||
llQuickTootBar.setBackgroundColor(colorColumnStripBackground)
|
||||
|
||||
val colorButtonBg = footerButtonBgColor.notZero()
|
||||
?: colorColumnStripBackground
|
||||
|
||||
val colorButtonFg = footerButtonFgColor.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(
|
||||
footerButtonFgColor.notZero()
|
||||
?: attrColor(R.attr.colorVectorDrawable)
|
||||
)
|
||||
btnToot.imageTintList = csl
|
||||
btnMenu.imageTintList = csl
|
||||
btnQuickToot.imageTintList = csl
|
||||
btnQuickTootMenu.imageTintList = csl
|
||||
|
||||
val c = footerTabDividerColor.notZero()
|
||||
?: colorColumnStripBackground
|
||||
vFooterDivider1.setBackgroundColor(c)
|
||||
vFooterDivider2.setBackgroundColor(c)
|
||||
|
||||
llColumnStrip.indicatorColor = footerTabIndicatorColor.notZero()
|
||||
?: attrColor(R.attr.colorAccent)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
// スマホモードならラムダを実行する。タブレットモードならnullを返す
|
||||
inline fun <R:Any?> ActMain.phoneOnly(code: (PhoneViews) -> R): R? = phoneViews?.let { code(it) }
|
||||
|
||||
// タブレットモードならラムダを実行する。スマホモードならnullを返す
|
||||
inline fun <R:Any?> ActMain.tabOnly(code: (TabletViews) -> R): R? = tabletViews?.let { code(it) }
|
||||
|
||||
// スマホモードとタブレットモードでコードを切り替える
|
||||
inline fun <R:Any?> ActMain.phoneTab(codePhone: (PhoneViews) -> R, codeTablet: (TabletViews) -> R): R {
|
||||
phoneViews?.let { return codePhone(it) }
|
||||
tabletViews?.let { return codeTablet(it) }
|
||||
error("missing phoneViews/tabletViews")
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,103 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
import jp.juggler.subwaytooter.dialog.pickAccount
|
||||
import jp.juggler.subwaytooter.table.AcctColor
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.*
|
||||
import org.jetbrains.anko.textColor
|
||||
|
||||
fun ActPost.selectAccount(a: SavedAccount?) {
|
||||
this.account = a
|
||||
|
||||
completionHelper.setInstance(a)
|
||||
|
||||
if (a == null) {
|
||||
btnAccount.text = getString(R.string.not_selected)
|
||||
btnAccount.setTextColor(attrColor(android.R.attr.textColorPrimary))
|
||||
btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
|
||||
} else {
|
||||
|
||||
// 先読みしてキャッシュに保持しておく
|
||||
App1.custom_emoji_lister.getList(a) {
|
||||
// 何もしない
|
||||
}
|
||||
|
||||
val ac = AcctColor.load(a)
|
||||
btnAccount.text = ac.nickname
|
||||
|
||||
if (AcctColor.hasColorBackground(ac)) {
|
||||
btnAccount.background =
|
||||
getAdaptiveRippleDrawableRound(this, ac.color_bg, ac.color_fg)
|
||||
} else {
|
||||
btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
|
||||
}
|
||||
|
||||
btnAccount.textColor = ac.color_fg.notZero()
|
||||
?: attrColor(android.R.attr.textColorPrimary)
|
||||
}
|
||||
updateTextCount()
|
||||
updateFeaturedTags()
|
||||
}
|
||||
|
||||
fun ActPost.canSwitchAccount(): Boolean {
|
||||
|
||||
if (scheduledStatus != null) {
|
||||
// 予約投稿の再編集ではアカウントを切り替えられない
|
||||
showToast(false, R.string.cant_change_account_when_editing_scheduled_status)
|
||||
return false
|
||||
}
|
||||
|
||||
if (attachmentList.isNotEmpty()) {
|
||||
// 添付ファイルがあったら確認の上添付ファイルを捨てないと切り替えられない
|
||||
showToast(false, R.string.cant_change_account_when_attachment_specified)
|
||||
return false
|
||||
}
|
||||
|
||||
if (states.redraftStatusId != null) {
|
||||
// 添付ファイルがあったら確認の上添付ファイルを捨てないと切り替えられない
|
||||
showToast(false, R.string.cant_change_account_when_redraft)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun ActPost.performAccountChooser() {
|
||||
if (!canSwitchAccount()) return
|
||||
|
||||
if (isMultiWindowPost) {
|
||||
accountList = SavedAccount.loadAccountList(this)
|
||||
SavedAccount.sort(accountList)
|
||||
}
|
||||
|
||||
launchMain {
|
||||
pickAccount(
|
||||
bAllowPseudo = false,
|
||||
bAuto = false,
|
||||
message = getString(R.string.choose_account)
|
||||
)?.let { ai ->
|
||||
// 別タンスのアカウントに変更したならならin_reply_toの変換が必要
|
||||
if (states.inReplyToId != null && ai.apiHost != account?.apiHost) {
|
||||
startReplyConversion(ai)
|
||||
} else {
|
||||
setAccountWithVisibilityConversion(ai)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ActPost.setAccountWithVisibilityConversion(a: SavedAccount) {
|
||||
selectAccount(a)
|
||||
try {
|
||||
if (TootVisibility.isVisibilitySpoilRequired(states.visibility, a.visibility)) {
|
||||
showToast(true, R.string.spoil_visibility_for_account)
|
||||
states.visibility = a.visibility
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
showVisibility()
|
||||
showQuotedRenote()
|
||||
updateTextCount()
|
||||
}
|
|
@ -0,0 +1,308 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.app.Dialog
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.api.ApiTask
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.dialog.ActionsDialog
|
||||
import jp.juggler.subwaytooter.dialog.DlgFocusPoint
|
||||
import jp.juggler.subwaytooter.dialog.DlgTextInput
|
||||
import jp.juggler.subwaytooter.util.AttachmentRequest
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.subwaytooter.view.MyNetworkImageView
|
||||
import jp.juggler.util.*
|
||||
|
||||
private val log = LogCategory("ActPostAttachment")
|
||||
|
||||
// AppStateに保存する
|
||||
fun ActPost.saveAttachmentList() {
|
||||
if (!isMultiWindowPost) appState.attachmentList = this.attachmentList
|
||||
}
|
||||
|
||||
fun ActPost.decodeAttachments(sv: String) {
|
||||
attachmentList.clear()
|
||||
try {
|
||||
sv.decodeJsonArray().objectList().forEach {
|
||||
try {
|
||||
attachmentList.add(PostAttachment(TootAttachment.decodeJson(it)))
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun ActPost.showMediaAttachment() {
|
||||
if (isFinishing) return
|
||||
llAttachment.vg(attachmentList.isNotEmpty())
|
||||
ivMedia.forEachIndexed { i, v -> showMediaAttachmentOne(v, i) }
|
||||
}
|
||||
|
||||
fun ActPost.showMediaAttachmentOne(iv: MyNetworkImageView, idx: Int) {
|
||||
if (idx >= attachmentList.size) {
|
||||
iv.visibility = View.GONE
|
||||
} else {
|
||||
iv.visibility = View.VISIBLE
|
||||
val pa = attachmentList[idx]
|
||||
val a = pa.attachment
|
||||
when {
|
||||
a == null || pa.status != PostAttachment.Status.Ok -> {
|
||||
iv.setDefaultImage(Styler.defaultColorIcon(this, R.drawable.ic_upload))
|
||||
iv.setErrorImage(Styler.defaultColorIcon(this, R.drawable.ic_clip))
|
||||
iv.setImageUrl(pref, Styler.calcIconRound(iv.layoutParams.width), null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val defaultIconId = when (a.type) {
|
||||
TootAttachmentType.Image -> R.drawable.ic_image
|
||||
TootAttachmentType.Video,
|
||||
TootAttachmentType.GIFV,
|
||||
-> R.drawable.ic_videocam
|
||||
TootAttachmentType.Audio -> R.drawable.ic_music_note
|
||||
else -> R.drawable.ic_clip
|
||||
}
|
||||
iv.setDefaultImage(Styler.defaultColorIcon(this, defaultIconId))
|
||||
iv.setErrorImage(Styler.defaultColorIcon(this, defaultIconId))
|
||||
iv.setImageUrl(pref, Styler.calcIconRound(iv.layoutParams.width), a.preview_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.openAttachment() {
|
||||
when {
|
||||
attachmentList.size >= 4 -> showToast(false, R.string.attachment_too_many)
|
||||
account == null -> showToast(false, R.string.account_select_please)
|
||||
else -> attachmentPicker.openPicker()
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.addAttachment(
|
||||
uri: Uri,
|
||||
mimeTypeArg: String? = null,
|
||||
onUploadEnd: () -> Unit = {},
|
||||
) {
|
||||
val account = this.account
|
||||
val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg)
|
||||
val isReply = states.inReplyToId != null
|
||||
val instance = account?.let { TootInstance.getCached(it) }
|
||||
|
||||
when {
|
||||
attachmentList.size >= 4 -> showToast(false, R.string.attachment_too_many)
|
||||
account == null -> showToast(false, R.string.account_select_please)
|
||||
mimeType?.isEmpty() != false -> showToast(false, R.string.mime_type_missing)
|
||||
!attachmentUploader.isAcceptableMimeType(instance, mimeType, isReply) -> Unit // エラーメッセージ出力済み
|
||||
else -> {
|
||||
saveAttachmentList()
|
||||
val pa = PostAttachment(this)
|
||||
attachmentList.add(pa)
|
||||
showMediaAttachment()
|
||||
attachmentUploader.addRequest(
|
||||
AttachmentRequest(
|
||||
account,
|
||||
pa,
|
||||
uri,
|
||||
mimeType,
|
||||
isReply = isReply,
|
||||
onUploadEnd = onUploadEnd
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.onPostAttachmentCompleteImpl(pa: PostAttachment) {
|
||||
// この添付メディアはリストにない
|
||||
if (!attachmentList.contains(pa)) {
|
||||
log.w("onPostAttachmentComplete: not in attachment list.")
|
||||
return
|
||||
}
|
||||
|
||||
when (pa.status) {
|
||||
PostAttachment.Status.Error -> {
|
||||
ActPost.log.w("onPostAttachmentComplete: upload failed.")
|
||||
attachmentList.remove(pa)
|
||||
showMediaAttachment()
|
||||
}
|
||||
|
||||
PostAttachment.Status.Progress -> {
|
||||
// アップロード中…?
|
||||
ActPost.log.w("onPostAttachmentComplete: ?? status=${pa.status}")
|
||||
}
|
||||
|
||||
PostAttachment.Status.Ok -> {
|
||||
when (val a = pa.attachment) {
|
||||
null -> ActPost.log.e("onPostAttachmentComplete: upload complete, but missing attachment entity.")
|
||||
else -> {
|
||||
// アップロード完了
|
||||
ActPost.log.i("onPostAttachmentComplete: upload complete.")
|
||||
|
||||
// 投稿欄の末尾に追記する
|
||||
if (PrefB.bpAppendAttachmentUrlToContent(pref)) {
|
||||
val selStart = etContent.selectionStart
|
||||
val selEnd = etContent.selectionEnd
|
||||
val e = etContent.editableText
|
||||
val len = e.length
|
||||
val lastChar = if (len <= 0) ' ' else e[len - 1]
|
||||
if (!CharacterGroup.isWhitespace(lastChar.code)) {
|
||||
e.append(" ").append(a.text_url)
|
||||
} else {
|
||||
e.append(a.text_url)
|
||||
}
|
||||
etContent.setSelection(selStart, selEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
showMediaAttachment()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添付した画像をタップ
|
||||
fun ActPost.performAttachmentClick(idx: Int) {
|
||||
val pa = try {
|
||||
attachmentList[idx]
|
||||
} catch (ex: Throwable) {
|
||||
showToast(false, ex.withCaption("can't get attachment item[$idx]."))
|
||||
return
|
||||
}
|
||||
|
||||
val a = ActionsDialog()
|
||||
.addAction(getString(R.string.set_description)) {
|
||||
editAttachmentDescription(pa)
|
||||
}
|
||||
|
||||
if (pa.attachment?.canFocus == true) {
|
||||
a.addAction(getString(R.string.set_focus_point)) {
|
||||
openFocusPoint(pa)
|
||||
}
|
||||
}
|
||||
if (account?.isMastodon == true) {
|
||||
when (pa.attachment?.type) {
|
||||
TootAttachmentType.Audio, TootAttachmentType.GIFV, TootAttachmentType.Video ->
|
||||
a.addAction(getString(R.string.custom_thumbnail)) {
|
||||
openCustomThumbnail(pa)
|
||||
}
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.addAction(getString(R.string.delete)) {
|
||||
deleteAttachment(pa)
|
||||
}
|
||||
|
||||
a.show(this, title = getString(R.string.media_attachment))
|
||||
}
|
||||
|
||||
fun ActPost.deleteAttachment(pa: PostAttachment) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.confirm_delete_attachment)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
try {
|
||||
attachmentList.remove(pa)
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
|
||||
showMediaAttachment()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun ActPost.openFocusPoint(pa: PostAttachment) {
|
||||
val attachment = pa.attachment ?: return
|
||||
DlgFocusPoint(this, attachment)
|
||||
.setCallback { x, y -> sendFocusPoint(pa, attachment, x, y) }
|
||||
.show()
|
||||
}
|
||||
|
||||
fun ActPost.sendFocusPoint(pa: PostAttachment, attachment: TootAttachment, x: Float, y: Float) {
|
||||
val account = this.account ?: return
|
||||
launchMain {
|
||||
var resultAttachment: TootAttachment? = null
|
||||
runApiTask(account, progressStyle = ApiTask.PROGRESS_NONE) { client ->
|
||||
try {
|
||||
client.request(
|
||||
"/api/v1/media/${attachment.id}",
|
||||
jsonObject {
|
||||
put("focus", "%.2f,%.2f".format(x, y))
|
||||
}.toPutRequestBuilder()
|
||||
)?.also { result ->
|
||||
resultAttachment =
|
||||
parseItem(::TootAttachment, ServiceType.MASTODON, result.jsonObject)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
TootApiResult(ex.withCaption("set focus point failed."))
|
||||
}
|
||||
}?.let { result ->
|
||||
when (val newAttachment = resultAttachment) {
|
||||
null -> showToast(true, result.error)
|
||||
else -> pa.attachment = newAttachment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.editAttachmentDescription(pa: PostAttachment) {
|
||||
val a = pa.attachment
|
||||
if (a == null) {
|
||||
showToast(true, R.string.attachment_description_cant_edit_while_uploading)
|
||||
return
|
||||
}
|
||||
|
||||
DlgTextInput.show(
|
||||
this,
|
||||
getString(R.string.attachment_description),
|
||||
a.description,
|
||||
callback = object : DlgTextInput.Callback {
|
||||
override fun onEmptyError() {
|
||||
showToast(true, R.string.description_empty)
|
||||
}
|
||||
|
||||
override fun onOK(dialog: Dialog, text: String) {
|
||||
val attachmentId = pa.attachment?.id ?: return
|
||||
val account = this@editAttachmentDescription.account ?: return
|
||||
launchMain {
|
||||
val (result, newAttachment) = attachmentUploader.setAttachmentDescription(account, attachmentId, text)
|
||||
when (newAttachment) {
|
||||
null -> result?.error?.let { showToast(true, it) }
|
||||
else -> {
|
||||
pa.attachment = newAttachment
|
||||
showMediaAttachment()
|
||||
dialog.dismissSafe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun ActPost.openCustomThumbnail(pa: PostAttachment) {
|
||||
paThumbnailTarget = pa
|
||||
attachmentPicker.openCustomThumbnail()
|
||||
}
|
||||
|
||||
fun ActPost.onPickCustomThumbnailImpl(src: GetContentResultEntry) {
|
||||
val account = this.account
|
||||
val pa = paThumbnailTarget
|
||||
when {
|
||||
account == null ->
|
||||
showToast(false, R.string.account_select_please)
|
||||
pa == null || !attachmentList.contains(pa) ->
|
||||
showToast(true, "lost attachment information")
|
||||
else -> launchMain {
|
||||
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
|
||||
result?.error?.let { showToast(true, it) }
|
||||
showMediaAttachment()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.api.ApiTask
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.util.EmojiDecoder
|
||||
import jp.juggler.util.attrColor
|
||||
import jp.juggler.util.launchMain
|
||||
import jp.juggler.util.wrapWeakReference
|
||||
|
||||
// 最大文字数を取得する
|
||||
// 暫定で仮の値を返すことがある
|
||||
// 裏で取得し終わったら updateTextCount() を呼び出す
|
||||
private fun ActPost.getMaxCharCount(): Int {
|
||||
val account = account
|
||||
if (account != null && !account.isPseudo) {
|
||||
// インスタンス情報を確認する
|
||||
val info = TootInstance.getCached(account)
|
||||
if (info == null || info.isExpired) {
|
||||
// 情報がないか古いなら再取得
|
||||
|
||||
// 同時に実行するタスクは1つまで
|
||||
if (jobMaxCharCount?.get()?.isActive != true) {
|
||||
jobMaxCharCount = launchMain {
|
||||
var newInfo: TootInstance? = null
|
||||
runApiTask(account, progressStyle = ApiTask.PROGRESS_NONE) { client ->
|
||||
val (ti, result) = TootInstance.get(client)
|
||||
newInfo = ti
|
||||
result
|
||||
}
|
||||
if (isFinishing || isDestroyed) return@launchMain
|
||||
if (newInfo != null) updateTextCount()
|
||||
}.wrapWeakReference
|
||||
}
|
||||
|
||||
// fall thru
|
||||
}
|
||||
|
||||
info?.max_toot_chars
|
||||
?.takeIf { it > 0 }
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
// アカウント設定で指定した値があるならそれを使う
|
||||
val forceMaxTootChars = account?.max_toot_chars
|
||||
return when {
|
||||
forceMaxTootChars != null && forceMaxTootChars > 0 -> forceMaxTootChars
|
||||
else -> 500
|
||||
}
|
||||
}
|
||||
|
||||
// 残り文字数を計算してビューに設定する
|
||||
fun ActPost.updateTextCount() {
|
||||
var length = 0
|
||||
|
||||
length += TootAccount.countText(
|
||||
EmojiDecoder.decodeShortCode(etContent.text.toString())
|
||||
)
|
||||
|
||||
if (cbContentWarning.isChecked) {
|
||||
length += TootAccount.countText(
|
||||
EmojiDecoder.decodeShortCode(etContentWarning.text.toString())
|
||||
)
|
||||
}
|
||||
|
||||
var max = getMaxCharCount()
|
||||
|
||||
fun checkEnqueteLength() {
|
||||
for (et in etChoices) {
|
||||
length += TootAccount.countText(
|
||||
EmojiDecoder.decodeShortCode(et.text.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (spEnquete.selectedItemPosition) {
|
||||
1 -> checkEnqueteLength()
|
||||
|
||||
2 -> {
|
||||
max -= 150 // フレニコ固有。500-150で350になる
|
||||
checkEnqueteLength()
|
||||
}
|
||||
}
|
||||
|
||||
val remain = max - length
|
||||
|
||||
tvCharCount.text = remain.toString()
|
||||
tvCharCount.setTextColor(
|
||||
attrColor(
|
||||
when {
|
||||
remain < 0 -> R.attr.colorRegexFilterError
|
||||
else -> android.R.attr.textColorPrimary
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
|
@ -0,0 +1,372 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.dialog.ActionsDialog
|
||||
import jp.juggler.subwaytooter.table.PostDraft
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.*
|
||||
import jp.juggler.util.*
|
||||
|
||||
fun ActPost.appendContentText(
|
||||
src: String?,
|
||||
selectBefore: Boolean = false,
|
||||
) {
|
||||
if (src?.isEmpty() != false) return
|
||||
val svEmoji = DecodeOptions(
|
||||
context = this,
|
||||
decodeEmoji = true,
|
||||
mentionDefaultHostDomain = account ?: unknownHostAndDomain
|
||||
).decodeEmoji(src)
|
||||
if (svEmoji.isEmpty()) return
|
||||
|
||||
val editable = etContent.text
|
||||
if (editable == null) {
|
||||
val sb = StringBuilder()
|
||||
if (selectBefore) {
|
||||
val start = 0
|
||||
sb.append(' ')
|
||||
sb.append(svEmoji)
|
||||
etContent.setText(sb)
|
||||
etContent.setSelection(start)
|
||||
} else {
|
||||
sb.append(svEmoji)
|
||||
etContent.setText(sb)
|
||||
etContent.setSelection(sb.length)
|
||||
}
|
||||
} else {
|
||||
if (editable.isNotEmpty() &&
|
||||
!CharacterGroup.isWhitespace(editable[editable.length - 1].code)
|
||||
) {
|
||||
editable.append(' ')
|
||||
}
|
||||
|
||||
if (selectBefore) {
|
||||
val start = editable.length
|
||||
editable.append(' ')
|
||||
editable.append(svEmoji)
|
||||
etContent.text = editable
|
||||
etContent.setSelection(start)
|
||||
} else {
|
||||
editable.append(svEmoji)
|
||||
etContent.text = editable
|
||||
etContent.setSelection(editable.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.appendContentText(src: Intent) {
|
||||
val list = ArrayList<String>()
|
||||
|
||||
var sv: String?
|
||||
sv = src.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
if (sv?.isNotEmpty() == true) list.add(sv)
|
||||
sv = src.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (sv?.isNotEmpty() == true) list.add(sv)
|
||||
|
||||
if (list.isNotEmpty()) {
|
||||
appendContentText(list.joinToString(" "))
|
||||
}
|
||||
}
|
||||
|
||||
// returns true if has content
|
||||
fun ActPost.hasContent(): Boolean {
|
||||
val content = etContent.text.toString()
|
||||
val contentWarning =
|
||||
if (cbContentWarning.isChecked) etContentWarning.text.toString() else ""
|
||||
|
||||
return when {
|
||||
content.isNotBlank() -> true
|
||||
contentWarning.isNotBlank() -> true
|
||||
hasPoll() -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.resetText() {
|
||||
isPostComplete = false
|
||||
states.inReplyToId = null
|
||||
states.inReplyToText = null
|
||||
states.inReplyToImage = null
|
||||
states.inReplyToUrl = null
|
||||
states.mushroomInput = 0
|
||||
states.mushroomStart = 0
|
||||
states.mushroomEnd = 0
|
||||
states.redraftStatusId = null
|
||||
states.timeSchedule = 0L
|
||||
attachmentPicker.reset()
|
||||
scheduledStatus = null
|
||||
attachmentList.clear()
|
||||
cbQuote.isChecked = false
|
||||
etContent.setText("")
|
||||
spEnquete.setSelection(0, false)
|
||||
etChoices.forEach { it.setText("") }
|
||||
accountList = SavedAccount.loadAccountList(this)
|
||||
SavedAccount.sort(accountList)
|
||||
if (accountList.isEmpty()) {
|
||||
showToast(true, R.string.please_add_account)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.afterUpdateText() {
|
||||
// 2017/9/13 VISIBILITY_WEB_SETTING から VISIBILITY_PUBLICに変更した
|
||||
// VISIBILITY_WEB_SETTING だと 1.5未満のタンスでトラブルになるので…
|
||||
states.visibility = states.visibility ?: account?.visibility ?: TootVisibility.Public
|
||||
|
||||
// アカウント未選択なら表示を更新する
|
||||
// 選択済みなら変えない
|
||||
if (account == null) selectAccount(null)
|
||||
|
||||
showContentWarningEnabled()
|
||||
showMediaAttachment()
|
||||
showVisibility()
|
||||
showReplyTo()
|
||||
showPoll()
|
||||
showQuotedRenote()
|
||||
showSchedule()
|
||||
updateTextCount()
|
||||
}
|
||||
|
||||
|
||||
// 初期化時と投稿完了時とリセット確認後に呼ばれる
|
||||
fun ActPost.updateText(
|
||||
intent: Intent,
|
||||
confirmed: Boolean = false,
|
||||
saveDraft: Boolean = true,
|
||||
resetAccount: Boolean = true,
|
||||
) {
|
||||
if (!canSwitchAccount()) return
|
||||
|
||||
if (!confirmed && hasContent()) {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage("編集中のテキストや文脈を下書きに退避して、新しい投稿を編集しますか? ")
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
updateText(intent, confirmed = true)
|
||||
}
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (saveDraft) saveDraft()
|
||||
|
||||
resetText()
|
||||
|
||||
// Android 9 から、明示的にフォーカスを当てる必要がある
|
||||
etContent.requestFocus()
|
||||
|
||||
this.attachmentList.clear()
|
||||
saveAttachmentList()
|
||||
|
||||
if (resetAccount) {
|
||||
states.visibility = null
|
||||
this.account = null
|
||||
val accountDbId = intent.getLongExtra(ActPost.KEY_ACCOUNT_DB_ID, SavedAccount.INVALID_DB_ID)
|
||||
accountList.find { it.db_id == accountDbId }?.let { selectAccount(it) }
|
||||
}
|
||||
|
||||
val sharedIntent = intent.getParcelableExtra<Intent>(ActPost.KEY_SHARED_INTENT)
|
||||
if (sharedIntent != null) {
|
||||
initializeFromSharedIntent(sharedIntent)
|
||||
}
|
||||
|
||||
appendContentText(intent.getStringExtra(ActPost.KEY_INITIAL_TEXT))
|
||||
|
||||
val account = this.account
|
||||
|
||||
if (account != null) {
|
||||
intent.getStringExtra(ActPost.KEY_REPLY_STATUS)
|
||||
?.let { initializeFromReplyStatus(account, it) }
|
||||
}
|
||||
|
||||
appendContentText(account?.default_text, selectBefore = true)
|
||||
cbNSFW.isChecked = account?.default_sensitive ?: false
|
||||
|
||||
if (account != null) {
|
||||
// 再編集
|
||||
intent.getStringExtra(ActPost.KEY_REDRAFT_STATUS)
|
||||
?.let { initializeFromRedraftStatus(account, it) }
|
||||
|
||||
// 予約編集の再編集
|
||||
intent.getStringExtra(ActPost.KEY_SCHEDULED_STATUS)
|
||||
?.let { initializeFromScheduledStatus(account, it) }
|
||||
}
|
||||
|
||||
afterUpdateText()
|
||||
}
|
||||
|
||||
fun ActPost.initializeFromSharedIntent(sharedIntent: Intent) {
|
||||
try {
|
||||
val hasUri = when (sharedIntent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val uri = sharedIntent.data
|
||||
val type = sharedIntent.type
|
||||
if (uri != null) {
|
||||
addAttachment(uri, type)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = sharedIntent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
val type = sharedIntent.type
|
||||
if (uri != null) {
|
||||
addAttachment(uri, type)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
val listUri =
|
||||
sharedIntent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.filterNotNull()
|
||||
if (listUri?.isNotEmpty() == true) {
|
||||
for (uri in listUri) {
|
||||
addAttachment(uri)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!hasUri || !PrefB.bpIgnoreTextInSharedMedia(pref)) {
|
||||
appendContentText(sharedIntent)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.performMore() {
|
||||
val dialog = ActionsDialog()
|
||||
|
||||
dialog.addAction(getString(R.string.open_picker_emoji)) {
|
||||
completionHelper.openEmojiPickerFromMore()
|
||||
}
|
||||
|
||||
dialog.addAction(getString(R.string.clear_text)) {
|
||||
etContent.setText("")
|
||||
etContentWarning.setText("")
|
||||
}
|
||||
|
||||
dialog.addAction(getString(R.string.clear_text_and_media)) {
|
||||
etContent.setText("")
|
||||
etContentWarning.setText("")
|
||||
attachmentList.clear()
|
||||
showMediaAttachment()
|
||||
}
|
||||
|
||||
if (PostDraft.hasDraft()) dialog.addAction(getString(R.string.restore_draft)) {
|
||||
openDraftPicker()
|
||||
}
|
||||
|
||||
dialog.addAction(getString(R.string.recommended_plugin)) {
|
||||
showRecommendedPlugin(null)
|
||||
}
|
||||
|
||||
dialog.show(this, null)
|
||||
}
|
||||
|
||||
fun ActPost.performPost() {
|
||||
// アップロード中は投稿できない
|
||||
if (attachmentList.any { it.status == PostAttachment.Status.Progress }) {
|
||||
showToast(false, R.string.media_attachment_still_uploading)
|
||||
return
|
||||
}
|
||||
|
||||
val account = this.account ?: return
|
||||
var pollType: TootPollsType? = null
|
||||
var pollItems: ArrayList<String>? = null
|
||||
var pollExpireSeconds = 0
|
||||
var pollHideTotals = false
|
||||
var pollMultipleChoice = false
|
||||
when (spEnquete.selectedItemPosition) {
|
||||
1 -> {
|
||||
pollType = TootPollsType.Mastodon
|
||||
pollItems = pollChoiceList()
|
||||
pollExpireSeconds = pollExpireSeconds()
|
||||
pollHideTotals = cbHideTotals.isChecked
|
||||
pollMultipleChoice = cbMultipleChoice.isChecked
|
||||
}
|
||||
2 -> {
|
||||
pollType = TootPollsType.FriendsNico
|
||||
pollItems = pollChoiceList()
|
||||
}
|
||||
}
|
||||
|
||||
PostImpl(
|
||||
activity = this,
|
||||
account = account,
|
||||
content = etContent.text.toString().trim { it <= ' ' },
|
||||
spoilerText = when {
|
||||
!cbContentWarning.isChecked -> null
|
||||
else -> etContentWarning.text.toString().trim { it <= ' ' }
|
||||
},
|
||||
visibilityArg = states.visibility ?: TootVisibility.Public,
|
||||
bNSFW = cbNSFW.isChecked,
|
||||
inReplyToId = states.inReplyToId,
|
||||
attachmentListArg = this.attachmentList,
|
||||
enqueteItemsArg = pollItems,
|
||||
pollType = pollType,
|
||||
pollExpireSeconds = pollExpireSeconds,
|
||||
pollHideTotals = pollHideTotals,
|
||||
pollMultipleChoice = pollMultipleChoice,
|
||||
scheduledAt = states.timeSchedule,
|
||||
scheduledId = scheduledStatus?.id,
|
||||
redraftStatusId = states.redraftStatusId,
|
||||
emojiMapCustom = App1.custom_emoji_lister.getMap(account),
|
||||
useQuoteToot = cbQuote.isChecked,
|
||||
callback = object : PostCompleteCallback {
|
||||
override fun onPostComplete(targetAccount: SavedAccount, status: TootStatus) {
|
||||
val data = Intent()
|
||||
data.putExtra(ActPost.EXTRA_POSTED_ACCT, targetAccount.acct.ascii)
|
||||
status.id.putTo(data, ActPost.EXTRA_POSTED_STATUS_ID)
|
||||
states.redraftStatusId?.putTo(data, ActPost.EXTRA_POSTED_REDRAFT_ID)
|
||||
status.in_reply_to_id?.putTo(data, ActPost.EXTRA_POSTED_REPLY_ID)
|
||||
ActMain.refActMain?.get()?.onCompleteActPost(data)
|
||||
|
||||
if (isMultiWindowPost) {
|
||||
resetText()
|
||||
updateText(Intent(), confirmed = true, saveDraft = false, resetAccount = false)
|
||||
afterUpdateText()
|
||||
} else {
|
||||
// ActMainの復元が必要な場合に備えてintentのdataでも渡す
|
||||
setResult(AppCompatActivity.RESULT_OK, data)
|
||||
isPostComplete = true
|
||||
this@performPost.finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScheduledPostComplete(targetAccount: SavedAccount) {
|
||||
showToast(false, getString(R.string.scheduled_status_sent))
|
||||
val data = Intent()
|
||||
data.putExtra(ActPost.EXTRA_POSTED_ACCT, targetAccount.acct.ascii)
|
||||
|
||||
if (isMultiWindowPost) {
|
||||
resetText()
|
||||
updateText(Intent(), confirmed = true, saveDraft = false, resetAccount = false)
|
||||
afterUpdateText()
|
||||
ActMain.refActMain?.get()?.onCompleteActPost(data)
|
||||
} else {
|
||||
setResult(AppCompatActivity.RESULT_OK, data)
|
||||
isPostComplete = true
|
||||
this@performPost.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
).run()
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RawRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.decodeUTF8
|
||||
import jp.juggler.util.loadRawResource
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
fun ActPost.showRecommendedPlugin(title: String?) {
|
||||
|
||||
@RawRes val resId = when (getString(R.string.language_code)) {
|
||||
"ja" -> R.raw.recommended_plugin_ja
|
||||
"fr" -> R.raw.recommended_plugin_fr
|
||||
else -> R.raw.recommended_plugin_en
|
||||
}
|
||||
|
||||
this.loadRawResource(resId).let { data ->
|
||||
val text = data.decodeUTF8()
|
||||
val viewRoot = layoutInflater.inflate(R.layout.dlg_plugin_missing, null, false)
|
||||
|
||||
val tvText = viewRoot.findViewById<TextView>(R.id.tvText)
|
||||
|
||||
val sv = DecodeOptions(this, linkHelper = LinkHelper.unknown).decodeHTML(text)
|
||||
|
||||
tvText.text = sv
|
||||
tvText.movementMethod = LinkMovementMethod.getInstance()
|
||||
|
||||
val tvTitle = viewRoot.findViewById<TextView>(R.id.tvTitle)
|
||||
if (title?.isEmpty() != false) {
|
||||
tvTitle.visibility = View.GONE
|
||||
} else {
|
||||
tvTitle.text = title
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setView(viewRoot)
|
||||
.setCancelable(true)
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.openMushroom() {
|
||||
try {
|
||||
var text: String? = null
|
||||
when {
|
||||
etContentWarning.hasFocus() -> {
|
||||
states.mushroomInput = 1
|
||||
text = prepareMushroomText(etContentWarning)
|
||||
}
|
||||
|
||||
etContent.hasFocus() -> {
|
||||
states.mushroomInput = 0
|
||||
text = prepareMushroomText(etContent)
|
||||
}
|
||||
|
||||
else -> for (i in 0..3) {
|
||||
if (etChoices[i].hasFocus()) {
|
||||
states.mushroomInput = i + 2
|
||||
text = prepareMushroomText(etChoices[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text == null) {
|
||||
states.mushroomInput = 0
|
||||
text = prepareMushroomText(etContent)
|
||||
}
|
||||
|
||||
val intent = Intent("com.adamrocker.android.simeji.ACTION_INTERCEPT")
|
||||
intent.addCategory("com.adamrocker.android.simeji.REPLACE")
|
||||
intent.putExtra("replace_key", text)
|
||||
|
||||
// Create intent to show chooser
|
||||
val chooser = Intent.createChooser(intent, getString(R.string.select_plugin))
|
||||
|
||||
// Verify the intent will resolve to at least one activity
|
||||
if (intent.resolveActivity(packageManager) == null) {
|
||||
showRecommendedPlugin(getString(R.string.plugin_not_installed))
|
||||
return
|
||||
}
|
||||
|
||||
arMushroom.launch(chooser)
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
showRecommendedPlugin(getString(R.string.plugin_not_installed))
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.prepareMushroomText(et: EditText): String {
|
||||
states.mushroomStart = et.selectionStart
|
||||
states.mushroomEnd = et.selectionEnd
|
||||
return when {
|
||||
states.mushroomStart >= states.mushroomEnd -> ""
|
||||
else -> et.text.toString().substring(states.mushroomStart, states.mushroomEnd)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.applyMushroomText(et: EditText, text: String) {
|
||||
val src = et.text.toString()
|
||||
if (states.mushroomStart > src.length) states.mushroomStart = src.length
|
||||
if (states.mushroomEnd > src.length) states.mushroomEnd = src.length
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append(src.substring(0, states.mushroomStart))
|
||||
// int new_sel_start = sb.length();
|
||||
sb.append(text)
|
||||
val newSelEnd = sb.length
|
||||
sb.append(src.substring(states.mushroomEnd))
|
||||
et.setText(sb)
|
||||
et.setSelection(newSelEnd, newSelEnd)
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.ActPost.Companion.finiteOrZero
|
||||
import jp.juggler.util.notEmpty
|
||||
import jp.juggler.util.vg
|
||||
|
||||
fun ActPost.showPoll() {
|
||||
val i = spEnquete.selectedItemPosition
|
||||
llEnquete.vg(i != 0)
|
||||
llExpire.vg(i == 1)
|
||||
cbHideTotals.vg(i == 1)
|
||||
cbMultipleChoice.vg(i == 1)
|
||||
}
|
||||
|
||||
// 投票が有効で何か入力済みなら真
|
||||
fun ActPost.hasPoll():Boolean{
|
||||
if( spEnquete.selectedItemPosition <= 0) return false
|
||||
return etChoices.any{ it.text.toString().isNotBlank()}
|
||||
}
|
||||
|
||||
fun ActPost.pollChoiceList() = ArrayList<String>().apply {
|
||||
for (et in etChoices) {
|
||||
et.text.toString().trim { it <= ' ' }.notEmpty()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.pollExpireSeconds(): Int {
|
||||
val d = etExpireDays.text.toString().trim().toDoubleOrNull().finiteOrZero()
|
||||
val h = etExpireHours.text.toString().trim().toDoubleOrNull().finiteOrZero()
|
||||
val m = etExpireMinutes.text.toString().trim().toDoubleOrNull().finiteOrZero()
|
||||
return (d * 86400.0 + h * 3600.0 + m * 60.0).toInt()
|
||||
}
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.ActPost.Companion.toPollTypeIndex
|
||||
import jp.juggler.subwaytooter.ActPost.Companion.toPollTypeString
|
||||
import jp.juggler.subwaytooter.api.TootApiCallback
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.dialog.DlgDraftPicker
|
||||
import jp.juggler.subwaytooter.table.PostDraft
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.util.*
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
// DlgDraftPickerから参照される
|
||||
const val DRAFT_CONTENT = "content"
|
||||
const val DRAFT_CONTENT_WARNING = "content_warning"
|
||||
|
||||
private const val DRAFT_CONTENT_WARNING_CHECK = "content_warning_check"
|
||||
private const val DRAFT_NSFW_CHECK = "nsfw_check"
|
||||
private const val DRAFT_VISIBILITY = "visibility"
|
||||
private const val DRAFT_ACCOUNT_DB_ID = "account_db_id"
|
||||
private const val DRAFT_ATTACHMENT_LIST = "attachment_list"
|
||||
private const val DRAFT_REPLY_ID = "reply_id"
|
||||
private const val DRAFT_REPLY_TEXT = "reply_text"
|
||||
private const val DRAFT_REPLY_IMAGE = "reply_image"
|
||||
private const val DRAFT_REPLY_URL = "reply_url"
|
||||
private const val DRAFT_IS_ENQUETE = "is_enquete"
|
||||
private const val DRAFT_POLL_TYPE = "poll_type"
|
||||
private const val DRAFT_POLL_MULTIPLE = "poll_multiple"
|
||||
private const val DRAFT_POLL_HIDE_TOTALS = "poll_hide_totals"
|
||||
private const val DRAFT_POLL_EXPIRE_DAY = "poll_expire_day"
|
||||
private const val DRAFT_POLL_EXPIRE_HOUR = "poll_expire_hour"
|
||||
private const val DRAFT_POLL_EXPIRE_MINUTE = "poll_expire_minute"
|
||||
private const val DRAFT_ENQUETE_ITEMS = "enquete_items"
|
||||
private const val DRAFT_QUOTE = "quotedRenote" // 歴史的な理由で名前がMisskey用になってる
|
||||
|
||||
|
||||
fun ActPost.saveDraft() {
|
||||
val content = etContent.text.toString()
|
||||
val contentWarning =
|
||||
if (cbContentWarning.isChecked) etContentWarning.text.toString() else ""
|
||||
|
||||
val isEnquete = spEnquete.selectedItemPosition > 0
|
||||
|
||||
val strChoice = arrayOf(
|
||||
if (isEnquete) etChoices[0].text.toString() else "",
|
||||
if (isEnquete) etChoices[1].text.toString() else "",
|
||||
if (isEnquete) etChoices[2].text.toString() else "",
|
||||
if (isEnquete) etChoices[3].text.toString() else ""
|
||||
)
|
||||
|
||||
val hasContent = when {
|
||||
content.isNotBlank() -> true
|
||||
contentWarning.isNotBlank() -> true
|
||||
strChoice.any { it.isNotBlank() } -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
ActPost.log.d("saveDraft: dont save empty content")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val tmpAttachmentList = attachmentList
|
||||
.mapNotNull { it.attachment?.encodeJson() }
|
||||
.toJsonArray()
|
||||
|
||||
val json = JsonObject()
|
||||
json[DRAFT_CONTENT] = content
|
||||
json[DRAFT_CONTENT_WARNING] = contentWarning
|
||||
json[DRAFT_CONTENT_WARNING_CHECK] = cbContentWarning.isChecked
|
||||
json[DRAFT_NSFW_CHECK] = cbNSFW.isChecked
|
||||
states.visibility?.let { json.put(DRAFT_VISIBILITY, it.id.toString()) }
|
||||
json[DRAFT_ACCOUNT_DB_ID] = account?.db_id ?: -1L
|
||||
json[DRAFT_ATTACHMENT_LIST] = tmpAttachmentList
|
||||
states.inReplyToId?.putTo(json, DRAFT_REPLY_ID)
|
||||
json[DRAFT_REPLY_TEXT] = states.inReplyToText
|
||||
json[DRAFT_REPLY_IMAGE] = states.inReplyToImage
|
||||
json[DRAFT_REPLY_URL] = states.inReplyToUrl
|
||||
|
||||
json[DRAFT_QUOTE] = cbQuote.isChecked
|
||||
|
||||
// deprecated. but still used in old draft.
|
||||
// json.put(DRAFT_IS_ENQUETE, isEnquete)
|
||||
|
||||
json[DRAFT_POLL_TYPE] = spEnquete.selectedItemPosition.toPollTypeString()
|
||||
|
||||
json[DRAFT_POLL_MULTIPLE] = cbMultipleChoice.isChecked
|
||||
json[DRAFT_POLL_HIDE_TOTALS] = cbHideTotals.isChecked
|
||||
json[DRAFT_POLL_EXPIRE_DAY] = etExpireDays.text.toString()
|
||||
json[DRAFT_POLL_EXPIRE_HOUR] = etExpireHours.text.toString()
|
||||
json[DRAFT_POLL_EXPIRE_MINUTE] = etExpireMinutes.text.toString()
|
||||
|
||||
json[DRAFT_ENQUETE_ITEMS] = strChoice.toJsonArray()
|
||||
|
||||
PostDraft.save(System.currentTimeMillis(), json)
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.openDraftPicker() {
|
||||
DlgDraftPicker().open(this) { draft -> restoreDraft(draft) }
|
||||
}
|
||||
|
||||
fun ActPost.restoreDraft(draft: JsonObject) {
|
||||
launchMain {
|
||||
val listWarning = ArrayList<String>()
|
||||
var targetAccount: SavedAccount? = null
|
||||
runWithProgress(
|
||||
"restore from draft",
|
||||
doInBackground = { progress ->
|
||||
fun isTaskCancelled() = !this.coroutineContext.isActive
|
||||
|
||||
var content = draft.string(DRAFT_CONTENT) ?: ""
|
||||
val accountDbId = draft.long(DRAFT_ACCOUNT_DB_ID) ?: -1L
|
||||
val tmpAttachmentList =
|
||||
draft.jsonArray(DRAFT_ATTACHMENT_LIST)?.objectList()?.toMutableList()
|
||||
|
||||
val account = SavedAccount.loadAccount(this@restoreDraft, accountDbId)
|
||||
if (account == null) {
|
||||
listWarning.add(getString(R.string.account_in_draft_is_lost))
|
||||
try {
|
||||
if (tmpAttachmentList != null) {
|
||||
// 本文からURLを除去する
|
||||
tmpAttachmentList.forEach {
|
||||
val textUrl = TootAttachment.decodeJson(it).text_url
|
||||
if (textUrl?.isNotEmpty() == true) {
|
||||
content = content.replace(textUrl, "")
|
||||
}
|
||||
}
|
||||
tmpAttachmentList.clear()
|
||||
draft[DRAFT_ATTACHMENT_LIST] = tmpAttachmentList.toJsonArray()
|
||||
draft[DRAFT_CONTENT] = content
|
||||
draft.remove(DRAFT_REPLY_ID)
|
||||
draft.remove(DRAFT_REPLY_TEXT)
|
||||
draft.remove(DRAFT_REPLY_IMAGE)
|
||||
draft.remove(DRAFT_REPLY_URL)
|
||||
}
|
||||
} catch (ignored: JsonException) {
|
||||
}
|
||||
|
||||
return@runWithProgress "OK"
|
||||
}
|
||||
|
||||
targetAccount = account
|
||||
|
||||
// アカウントがあるなら基本的にはすべての情報を復元できるはずだが、いくつか確認が必要だ
|
||||
val apiClient = TootApiClient(this@restoreDraft, callback = object : TootApiCallback {
|
||||
|
||||
override val isApiCancelled: Boolean
|
||||
get() = isTaskCancelled()
|
||||
|
||||
override suspend fun publishApiProgress(s: String) {
|
||||
progress.setMessageEx(s)
|
||||
}
|
||||
})
|
||||
|
||||
apiClient.account = account
|
||||
|
||||
states.inReplyToId?.let { inReplyToId ->
|
||||
val result = apiClient.request("/api/v1/statuses/$inReplyToId")
|
||||
if (isTaskCancelled()) return@runWithProgress null
|
||||
val jsonObject = result?.jsonObject
|
||||
if (jsonObject == null) {
|
||||
listWarning.add(getString(R.string.reply_to_in_draft_is_lost))
|
||||
draft.remove(DRAFT_REPLY_ID)
|
||||
draft.remove(DRAFT_REPLY_TEXT)
|
||||
draft.remove(DRAFT_REPLY_IMAGE)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (tmpAttachmentList != null) {
|
||||
// 添付メディアの存在確認
|
||||
var isSomeAttachmentRemoved = false
|
||||
val it = tmpAttachmentList.iterator()
|
||||
while (it.hasNext()) {
|
||||
if (isTaskCancelled()) return@runWithProgress null
|
||||
val ta = TootAttachment.decodeJson(it.next())
|
||||
if (ActPost.checkExist(ta.url)) continue
|
||||
it.remove()
|
||||
isSomeAttachmentRemoved = true
|
||||
// 本文からURLを除去する
|
||||
val textUrl = ta.text_url
|
||||
if (textUrl?.isNotEmpty() == true) {
|
||||
content = content.replace(textUrl, "")
|
||||
}
|
||||
}
|
||||
if (isSomeAttachmentRemoved) {
|
||||
listWarning.add(getString(R.string.attachment_in_draft_is_lost))
|
||||
draft[DRAFT_ATTACHMENT_LIST] = tmpAttachmentList.toJsonArray()
|
||||
draft[DRAFT_CONTENT] = content
|
||||
}
|
||||
}
|
||||
} catch (ex: JsonException) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
|
||||
"OK"
|
||||
},
|
||||
afterProc = { result ->
|
||||
// cancelled.
|
||||
if (result == null) return@runWithProgress
|
||||
|
||||
val content = draft.string(DRAFT_CONTENT) ?: ""
|
||||
val contentWarning = draft.string(DRAFT_CONTENT_WARNING) ?: ""
|
||||
val contentWarningChecked = draft.optBoolean(DRAFT_CONTENT_WARNING_CHECK)
|
||||
val nsfwChecked = draft.optBoolean(DRAFT_NSFW_CHECK)
|
||||
val tmpAttachmentList = draft.jsonArray(DRAFT_ATTACHMENT_LIST)
|
||||
val replyId = EntityId.from(draft, DRAFT_REPLY_ID)
|
||||
val replyText = draft.string(DRAFT_REPLY_TEXT)
|
||||
val replyImage = draft.string(DRAFT_REPLY_IMAGE)
|
||||
val replyUrl = draft.string(DRAFT_REPLY_URL)
|
||||
val draftVisibility = TootVisibility
|
||||
.parseSavedVisibility(draft.string(DRAFT_VISIBILITY))
|
||||
|
||||
val evEmoji = DecodeOptions(
|
||||
this@restoreDraft,
|
||||
decodeEmoji = true
|
||||
).decodeEmoji(content)
|
||||
etContent.setText(evEmoji)
|
||||
etContent.setSelection(evEmoji.length)
|
||||
etContentWarning.setText(contentWarning)
|
||||
etContentWarning.setSelection(contentWarning.length)
|
||||
cbContentWarning.isChecked = contentWarningChecked
|
||||
cbNSFW.isChecked = nsfwChecked
|
||||
if (draftVisibility != null) states.visibility = draftVisibility
|
||||
|
||||
cbQuote.isChecked = draft.optBoolean(DRAFT_QUOTE)
|
||||
|
||||
val sv = draft.string(DRAFT_POLL_TYPE)
|
||||
if (sv != null) {
|
||||
spEnquete.setSelection(sv.toPollTypeIndex())
|
||||
} else {
|
||||
// old draft
|
||||
val bv = draft.optBoolean(DRAFT_IS_ENQUETE, false)
|
||||
spEnquete.setSelection(if (bv) 2 else 0)
|
||||
}
|
||||
|
||||
cbMultipleChoice.isChecked = draft.optBoolean(DRAFT_POLL_MULTIPLE)
|
||||
cbHideTotals.isChecked = draft.optBoolean(DRAFT_POLL_HIDE_TOTALS)
|
||||
etExpireDays.setText(draft.optString(DRAFT_POLL_EXPIRE_DAY, "1"))
|
||||
etExpireHours.setText(draft.optString(DRAFT_POLL_EXPIRE_HOUR, ""))
|
||||
etExpireMinutes.setText(draft.optString(DRAFT_POLL_EXPIRE_MINUTE, ""))
|
||||
|
||||
val array = draft.jsonArray(DRAFT_ENQUETE_ITEMS)
|
||||
if (array != null) {
|
||||
var srcIndex = 0
|
||||
for (et in etChoices) {
|
||||
if (srcIndex < array.size) {
|
||||
et.setText(array.optString(srcIndex))
|
||||
++srcIndex
|
||||
} else {
|
||||
et.setText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAccount != null) selectAccount(targetAccount)
|
||||
|
||||
if (tmpAttachmentList?.isNotEmpty() == true) {
|
||||
attachmentList.clear()
|
||||
tmpAttachmentList.forEach {
|
||||
if (it !is JsonObject) return@forEach
|
||||
val pa = PostAttachment(TootAttachment.decodeJson(it))
|
||||
attachmentList.add(pa)
|
||||
}
|
||||
}
|
||||
|
||||
if (replyId != null) {
|
||||
states.inReplyToId = replyId
|
||||
states.inReplyToText = replyText
|
||||
states.inReplyToImage = replyImage
|
||||
states.inReplyToUrl = replyUrl
|
||||
}
|
||||
|
||||
showContentWarningEnabled()
|
||||
showMediaAttachment()
|
||||
showVisibility()
|
||||
updateTextCount()
|
||||
showReplyTo()
|
||||
showPoll()
|
||||
showQuotedRenote()
|
||||
|
||||
if (listWarning.isNotEmpty()) {
|
||||
val sb = StringBuilder()
|
||||
for (s in listWarning) {
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append(s)
|
||||
}
|
||||
AlertDialog.Builder(this@restoreDraft)
|
||||
.setMessage(sb)
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String) {
|
||||
try {
|
||||
val baseStatus =
|
||||
TootParser(this, account).status(jsonText.decodeJsonObject())
|
||||
?: error("initializeFromRedraftStatus: parse failed.")
|
||||
|
||||
states.redraftStatusId = baseStatus.id
|
||||
|
||||
states.visibility = baseStatus.visibility
|
||||
|
||||
val srcAttachments = baseStatus.media_attachments
|
||||
if (srcAttachments?.isNotEmpty() == true) {
|
||||
saveAttachmentList()
|
||||
this.attachmentList.clear()
|
||||
try {
|
||||
for (src in srcAttachments) {
|
||||
if (src is TootAttachment) {
|
||||
src.redraft = true
|
||||
val pa = PostAttachment(src)
|
||||
pa.status = PostAttachment.Status.Ok
|
||||
this.attachmentList.add(pa)
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
cbNSFW.isChecked = baseStatus.sensitive == true
|
||||
|
||||
// 再編集の場合はdefault_textは反映されない
|
||||
|
||||
val decodeOptions = DecodeOptions(
|
||||
this,
|
||||
mentionFullAcct = true,
|
||||
mentions = baseStatus.mentions,
|
||||
mentionDefaultHostDomain = account
|
||||
)
|
||||
|
||||
var text: CharSequence = if (account.isMisskey) {
|
||||
baseStatus.content ?: ""
|
||||
} else {
|
||||
decodeOptions.decodeHTML(baseStatus.content)
|
||||
}
|
||||
etContent.setText(text)
|
||||
etContent.setSelection(text.length)
|
||||
|
||||
text = decodeOptions.decodeEmoji(baseStatus.spoiler_text)
|
||||
etContentWarning.setText(text)
|
||||
etContentWarning.setSelection(text.length)
|
||||
cbContentWarning.isChecked = text.isNotEmpty()
|
||||
|
||||
val srcEnquete = baseStatus.enquete
|
||||
val srcItems = srcEnquete?.items
|
||||
when {
|
||||
srcItems == null -> {
|
||||
//
|
||||
}
|
||||
|
||||
srcEnquete.pollType == TootPollsType.FriendsNico &&
|
||||
srcEnquete.type != TootPolls.TYPE_ENQUETE -> {
|
||||
// フレニコAPIのアンケート結果は再編集の対象外
|
||||
}
|
||||
|
||||
else -> {
|
||||
spEnquete.setSelection(
|
||||
if (srcEnquete.pollType == TootPollsType.FriendsNico) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
)
|
||||
text = decodeOptions.decodeHTML(srcEnquete.question)
|
||||
etContent.text = text
|
||||
etContent.setSelection(text.length)
|
||||
|
||||
var srcIndex = 0
|
||||
for (et in etChoices) {
|
||||
if (srcIndex < srcItems.size) {
|
||||
val choice = srcItems[srcIndex]
|
||||
when {
|
||||
srcIndex == srcItems.size - 1 && choice.text == "\uD83E\uDD14" -> {
|
||||
// :thinking_face: は再現しない
|
||||
}
|
||||
|
||||
else -> {
|
||||
et.setText(decodeOptions.decodeEmoji(choice.text))
|
||||
++srcIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
et.setText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.Acct
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
import jp.juggler.subwaytooter.api.entity.unknownHostAndDomain
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.api.syncStatus
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.util.decodeJsonObject
|
||||
import jp.juggler.util.launchMain
|
||||
import jp.juggler.util.showToast
|
||||
|
||||
fun ActPost.showQuotedRenote() {
|
||||
cbQuote.visibility = if (states.inReplyToId != null) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
fun ActPost.showReplyTo() {
|
||||
if (states.inReplyToId == null) {
|
||||
llReply.visibility = View.GONE
|
||||
} else {
|
||||
llReply.visibility = View.VISIBLE
|
||||
tvReplyTo.text = DecodeOptions(
|
||||
this,
|
||||
linkHelper = account,
|
||||
short = true,
|
||||
decodeEmoji = true,
|
||||
mentionDefaultHostDomain = account ?: unknownHostAndDomain
|
||||
).decodeHTML(states.inReplyToText)
|
||||
ivReply.setImageUrl(pref, Styler.calcIconRound(ivReply.layoutParams), states.inReplyToImage)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.removeReply() {
|
||||
states.inReplyToId = null
|
||||
states.inReplyToText = null
|
||||
states.inReplyToImage = null
|
||||
states.inReplyToUrl = null
|
||||
showReplyTo()
|
||||
showQuotedRenote()
|
||||
}
|
||||
|
||||
fun ActPost.initializeFromReplyStatus(account: SavedAccount, jsonText: String) {
|
||||
try {
|
||||
val replyStatus =
|
||||
TootParser(this, account).status(jsonText.decodeJsonObject())
|
||||
?: error("initializeFromReplyStatus: parse failed.")
|
||||
|
||||
val isQuote = intent.getBooleanExtra(ActPost.KEY_QUOTE, false)
|
||||
if (isQuote) {
|
||||
cbQuote.isChecked = true
|
||||
|
||||
// 引用リノートはCWやメンションを引き継がない
|
||||
} else {
|
||||
|
||||
// CW をリプライ元に合わせる
|
||||
if (replyStatus.spoiler_text.isNotEmpty()) {
|
||||
cbContentWarning.isChecked = true
|
||||
etContentWarning.setText(replyStatus.spoiler_text)
|
||||
}
|
||||
|
||||
// 新しいメンションリスト
|
||||
val mentionList = ArrayList<Acct>()
|
||||
|
||||
// 自己レス以外なら元レスへのメンションを追加
|
||||
// 最初に追加する https://github.com/tateisu/SubwayTooter/issues/94
|
||||
if (!account.isMe(replyStatus.account)) {
|
||||
mentionList.add(account.getFullAcct(replyStatus.account))
|
||||
}
|
||||
|
||||
// 元レスに含まれていたメンションを複製
|
||||
replyStatus.mentions?.forEach { mention ->
|
||||
|
||||
val whoAcct = mention.acct
|
||||
|
||||
// 空データなら追加しない
|
||||
if (!whoAcct.isValid) return@forEach
|
||||
|
||||
// 自分なら追加しない
|
||||
if (account.isMe(whoAcct)) return@forEach
|
||||
|
||||
// 既出でないなら追加する
|
||||
val acct = account.getFullAcct(whoAcct)
|
||||
if (!mentionList.contains(acct)) mentionList.add(acct)
|
||||
}
|
||||
|
||||
if (mentionList.isNotEmpty()) {
|
||||
appendContentText(
|
||||
StringBuilder().apply {
|
||||
for (acct in mentionList) {
|
||||
if (isNotEmpty()) append(' ')
|
||||
append("@${acct.ascii}")
|
||||
}
|
||||
append(' ')
|
||||
}.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// リプライ表示をつける
|
||||
states.inReplyToText = replyStatus.content
|
||||
states.inReplyToImage = replyStatus.account.avatar_static
|
||||
states.inReplyToUrl = replyStatus.url
|
||||
|
||||
// 公開範囲
|
||||
try {
|
||||
// 比較する前にデフォルトの公開範囲を計算する
|
||||
|
||||
states.visibility = states.visibility
|
||||
?: account.visibility
|
||||
// ?: TootVisibility.Public
|
||||
// VISIBILITY_WEB_SETTING だと 1.5未満のタンスでトラブルになる
|
||||
|
||||
if (states.visibility == TootVisibility.Unknown) {
|
||||
states.visibility = TootVisibility.PrivateFollowers
|
||||
}
|
||||
|
||||
val sample = when (val base = replyStatus.visibility) {
|
||||
TootVisibility.Unknown -> TootVisibility.PrivateFollowers
|
||||
else -> base
|
||||
}
|
||||
|
||||
if (TootVisibility.WebSetting == states.visibility) {
|
||||
// 「Web設定に合わせる」だった場合は無条件にリプライ元の公開範囲に変更する
|
||||
states.visibility = sample
|
||||
} else if (TootVisibility.isVisibilitySpoilRequired(
|
||||
states.visibility, sample
|
||||
)
|
||||
) {
|
||||
// デフォルトの方が公開範囲が大きい場合、リプライ元に合わせて公開範囲を狭める
|
||||
states.visibility = sample
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.startReplyConversion(accessInfo: SavedAccount) {
|
||||
|
||||
val inReplyToUrl = states.inReplyToUrl
|
||||
|
||||
if (inReplyToUrl == null) {
|
||||
// 下書きが古い形式の場合、URLがないので別タンスへの移動ができない
|
||||
AlertDialog.Builder(this@startReplyConversion)
|
||||
.setMessage(R.string.account_change_failed_old_draft_has_no_in_reply_to_url)
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
launchMain {
|
||||
var resultStatus: TootStatus? = null
|
||||
runApiTask(
|
||||
accessInfo,
|
||||
progressPrefix = getString(R.string.progress_synchronize_toot)
|
||||
) { client ->
|
||||
val pair = client.syncStatus(accessInfo, inReplyToUrl)
|
||||
resultStatus = pair.second
|
||||
pair.first
|
||||
}?.let { result ->
|
||||
when (val targetStatus = resultStatus) {
|
||||
null -> showToast(
|
||||
true,
|
||||
getString(R.string.in_reply_to_id_conversion_failed) + "\n" + result.error
|
||||
)
|
||||
else -> {
|
||||
states.inReplyToId = targetStatus.id
|
||||
setAccountWithVisibilityConversion(accessInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.TootAttachment
|
||||
import jp.juggler.subwaytooter.api.entity.TootScheduled
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.api.entity.parseItem
|
||||
import jp.juggler.subwaytooter.dialog.DlgDateTime
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.util.cast
|
||||
import jp.juggler.util.decodeJsonObject
|
||||
import jp.juggler.util.notEmpty
|
||||
|
||||
fun ActPost.showSchedule() {
|
||||
tvSchedule.text = when (states.timeSchedule) {
|
||||
0L -> getString(R.string.unspecified)
|
||||
else -> TootStatus.formatTime(this, states.timeSchedule, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.performSchedule() {
|
||||
DlgDateTime(this).open(states.timeSchedule) { t ->
|
||||
states.timeSchedule = t
|
||||
showSchedule()
|
||||
}
|
||||
}
|
||||
|
||||
fun ActPost.resetSchedule() {
|
||||
states.timeSchedule = 0L
|
||||
showSchedule()
|
||||
}
|
||||
|
||||
fun ActPost.initializeFromScheduledStatus(account: SavedAccount, jsonText: String) {
|
||||
try {
|
||||
val item = parseItem(
|
||||
::TootScheduled,
|
||||
TootParser(this, account),
|
||||
jsonText.decodeJsonObject(),
|
||||
ActPost.log
|
||||
) ?: error("initializeFromScheduledStatus: parse failed.")
|
||||
|
||||
scheduledStatus = item
|
||||
|
||||
states.timeSchedule = item.timeScheduledAt
|
||||
states.visibility = item.visibility
|
||||
cbNSFW.isChecked = item.sensitive
|
||||
|
||||
etContent.setText(item.text)
|
||||
|
||||
val cw = item.spoilerText
|
||||
etContentWarning.setText(cw ?: "")
|
||||
cbContentWarning.isChecked = cw?.isNotEmpty() == true
|
||||
|
||||
// 2019/1/7 どうも添付データを古い投稿から引き継げないようだ…。
|
||||
// 2019/1/22 https://github.com/tootsuite/mastodon/pull/9894 で直った。
|
||||
item.mediaAttachments
|
||||
?.mapNotNull { src ->
|
||||
src.cast<TootAttachment>()
|
||||
?.apply { redraft = true }
|
||||
?.let { PostAttachment(it) }
|
||||
}
|
||||
?.notEmpty()
|
||||
?.let {
|
||||
saveAttachmentList()
|
||||
this.attachmentList.clear()
|
||||
this.attachmentList.addAll(it)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.view.View
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
|
||||
fun ActPost.showContentWarningEnabled() {
|
||||
etContentWarning.visibility = if (cbContentWarning.isChecked) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.os.Bundle
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.util.AttachmentPicker
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.util.LogCategory
|
||||
import jp.juggler.util.decodeJsonObject
|
||||
import jp.juggler.util.toJsonArray
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
private val log = LogCategory("ActPostStates")
|
||||
|
||||
@Serializable
|
||||
data class ActPostStates(
|
||||
////////////
|
||||
// states requires special handling
|
||||
var accountDbId: Long? = null,
|
||||
var pickerState: String? = null,
|
||||
var attachmentListEncoded: String? = null,
|
||||
var scheduledStatusEncoded: String? = null,
|
||||
////////////
|
||||
|
||||
var visibility: TootVisibility? = null,
|
||||
|
||||
@Serializable(with = EntityIdSerializer::class)
|
||||
var redraftStatusId: EntityId? = null,
|
||||
|
||||
var mushroomInput: Int = 0,
|
||||
var mushroomStart: Int = 0,
|
||||
var mushroomEnd: Int = 0,
|
||||
|
||||
var timeSchedule: Long = 0L,
|
||||
|
||||
@Serializable(with = EntityIdSerializer::class)
|
||||
var inReplyToId: EntityId? = null,
|
||||
var inReplyToText: String? = null,
|
||||
var inReplyToImage: String? = null,
|
||||
var inReplyToUrl: String? = null,
|
||||
)
|
||||
|
||||
// 画面状態の保存
|
||||
fun ActPost.saveState(outState: Bundle) {
|
||||
states.accountDbId = account?.db_id
|
||||
states.pickerState = attachmentPicker.encodeState()
|
||||
|
||||
states.scheduledStatusEncoded = scheduledStatus?.encodeSimple()?.toString()
|
||||
|
||||
// アップロード完了したものだけ保持する
|
||||
states.attachmentListEncoded = attachmentList
|
||||
.filter { it.status == PostAttachment.Status.Ok }
|
||||
.mapNotNull { it.attachment?.encodeJson() }
|
||||
.toJsonArray()
|
||||
.toString()
|
||||
|
||||
val encoded = kJson.encodeToString(states)
|
||||
log.d("onSaveInstanceState: $encoded")
|
||||
outState.putString(ActPost.STATE_ALL, encoded)
|
||||
|
||||
// test decoding
|
||||
kJson.decodeFromString<AttachmentPicker.States>(encoded)
|
||||
}
|
||||
|
||||
// 画面状態の復元
|
||||
fun ActPost.restoreState(savedInstanceState: Bundle) {
|
||||
|
||||
resetText() // also load account list
|
||||
|
||||
savedInstanceState.getString(ActPost.STATE_ALL)?.let { jsonText ->
|
||||
states = kJson.decodeFromString(jsonText)
|
||||
states.pickerState?.let { attachmentPicker.restoreState(it) }
|
||||
this.account = null // いちど選択を外してから再選択させる
|
||||
accountList.find { it.db_id == states.accountDbId }?.let { selectAccount(it) }
|
||||
|
||||
account?.let { a ->
|
||||
states.scheduledStatusEncoded?.let { jsonText ->
|
||||
scheduledStatus = parseItem(
|
||||
::TootScheduled,
|
||||
TootParser(this, a),
|
||||
jsonText.decodeJsonObject(),
|
||||
log
|
||||
)
|
||||
}
|
||||
}
|
||||
val stateAttachmentList = appState.attachmentList
|
||||
if (!isMultiWindowPost && stateAttachmentList != null) {
|
||||
// static なデータが残ってるならそれを使う
|
||||
this.attachmentList = stateAttachmentList
|
||||
// コールバックを新しい画面に差し替える
|
||||
for (pa in attachmentList) {
|
||||
pa.callback = this
|
||||
}
|
||||
} else {
|
||||
// state から復元する
|
||||
states.attachmentListEncoded?.let {
|
||||
saveAttachmentList()
|
||||
decodeAttachments(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdateText()
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.api.entity.InstanceCapability
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
|
||||
fun ActPost.showVisibility() {
|
||||
val iconId = Styler.getVisibilityIconId(account?.isMisskey == true, states.visibility ?: TootVisibility.Public)
|
||||
btnVisibility.setImageResource(iconId)
|
||||
}
|
||||
|
||||
fun ActPost.performVisibility() {
|
||||
val ti = TootInstance.getCached(account)
|
||||
|
||||
val list = when {
|
||||
account?.isMisskey == true ->
|
||||
arrayOf(
|
||||
// TootVisibility.WebSetting,
|
||||
TootVisibility.Public,
|
||||
TootVisibility.UnlistedHome,
|
||||
TootVisibility.PrivateFollowers,
|
||||
TootVisibility.LocalPublic,
|
||||
TootVisibility.LocalHome,
|
||||
TootVisibility.LocalFollowers,
|
||||
TootVisibility.DirectSpecified,
|
||||
TootVisibility.DirectPrivate
|
||||
)
|
||||
|
||||
InstanceCapability.visibilityMutual(ti) ->
|
||||
arrayOf(
|
||||
TootVisibility.WebSetting,
|
||||
TootVisibility.Public,
|
||||
TootVisibility.UnlistedHome,
|
||||
TootVisibility.PrivateFollowers,
|
||||
TootVisibility.Limited,
|
||||
TootVisibility.Mutual,
|
||||
TootVisibility.DirectSpecified
|
||||
)
|
||||
|
||||
InstanceCapability.visibilityLimited(ti) ->
|
||||
arrayOf(
|
||||
TootVisibility.WebSetting,
|
||||
TootVisibility.Public,
|
||||
TootVisibility.UnlistedHome,
|
||||
TootVisibility.PrivateFollowers,
|
||||
TootVisibility.Limited,
|
||||
TootVisibility.DirectSpecified
|
||||
)
|
||||
|
||||
else ->
|
||||
arrayOf(
|
||||
TootVisibility.WebSetting,
|
||||
TootVisibility.Public,
|
||||
TootVisibility.UnlistedHome,
|
||||
TootVisibility.PrivateFollowers,
|
||||
TootVisibility.DirectSpecified
|
||||
)
|
||||
}
|
||||
val captionList = list
|
||||
.map { Styler.getVisibilityCaption(this, account?.isMisskey == true, it) }
|
||||
.toTypedArray()
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.choose_visibility)
|
||||
.setItems(captionList) { _, which ->
|
||||
if (which in list.indices) {
|
||||
states.visibility = list[which]
|
||||
showVisibility()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
|
@ -610,3 +610,5 @@ class App1 : Application() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val kJson = kotlinx.serialization.json.Json{ ignoreUnknownKeys = true }
|
|
@ -0,0 +1,60 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.os.SystemClock
|
||||
import jp.juggler.subwaytooter.api.ApiTask
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.TootTag
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.util.jsonObject
|
||||
import jp.juggler.util.launchMain
|
||||
import jp.juggler.util.toPostRequestBuilder
|
||||
import jp.juggler.util.wrapWeakReference
|
||||
|
||||
class FeaturedTagCache(val list: List<TootTag>, val time: Long)
|
||||
|
||||
fun ActPost.updateFeaturedTags() {
|
||||
val account = account
|
||||
if (account == null || account.isPseudo) {
|
||||
return
|
||||
}
|
||||
|
||||
val cache = featuredTagCache[account.acct.ascii]
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (cache != null && now - cache.time <= 300000L) return
|
||||
|
||||
// 同時に実行するタスクは1つまで
|
||||
if (jobFeaturedTag?.get()?.isActive != true) {
|
||||
jobFeaturedTag = launchMain {
|
||||
runApiTask(
|
||||
account,
|
||||
progressStyle = ApiTask.PROGRESS_NONE,
|
||||
) { client ->
|
||||
if (account.isMisskey) {
|
||||
client.request(
|
||||
"/api/hashtags/trend",
|
||||
jsonObject { }
|
||||
.toPostRequestBuilder()
|
||||
)?.also { result ->
|
||||
val list = TootTag.parseList(
|
||||
TootParser(this@runApiTask, account),
|
||||
result.jsonArray
|
||||
)
|
||||
featuredTagCache[account.acct.ascii] =
|
||||
FeaturedTagCache(list, SystemClock.elapsedRealtime())
|
||||
}
|
||||
} else {
|
||||
client.request("/api/v1/featured_tags")?.also { result ->
|
||||
val list = TootTag.parseList(
|
||||
TootParser(this@runApiTask, account),
|
||||
result.jsonArray
|
||||
)
|
||||
featuredTagCache[account.acct.ascii] =
|
||||
FeaturedTagCache(list, SystemClock.elapsedRealtime())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isFinishing || isDestroyed) return@launchMain
|
||||
updateFeaturedTags()
|
||||
}.wrapWeakReference
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.view.MyViewPager
|
||||
|
||||
class PhoneViews(val actMain: ActMain) {
|
||||
internal lateinit var pager: MyViewPager
|
||||
internal lateinit var pagerAdapter: ColumnPagerAdapter
|
||||
|
||||
fun initUI(viewPager: MyViewPager) {
|
||||
this.pager = viewPager
|
||||
this.pagerAdapter = ColumnPagerAdapter(actMain)
|
||||
this.pager.adapter = this.pagerAdapter
|
||||
this.pager.addOnPageChangeListener(actMain)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import jp.juggler.subwaytooter.view.GravitySnapHelper
|
||||
import jp.juggler.subwaytooter.view.TabletColumnDivider
|
||||
import jp.juggler.util.clipRange
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
class TabletViews(
|
||||
val actMain: ActMain,
|
||||
) {
|
||||
internal lateinit var tabletPager: RecyclerView
|
||||
internal lateinit var tabletPagerAdapter: TabletColumnPagerAdapter
|
||||
internal lateinit var tabletLayoutManager: LinearLayoutManager
|
||||
private lateinit var tabletSnapHelper: GravitySnapHelper
|
||||
|
||||
val visibleColumnsIndices: IntRange
|
||||
get() {
|
||||
var vs = tabletLayoutManager.findFirstVisibleItemPosition()
|
||||
var ve = tabletLayoutManager.findLastVisibleItemPosition()
|
||||
if (vs == RecyclerView.NO_POSITION || ve == RecyclerView.NO_POSITION) {
|
||||
return IntRange(-1, -2) // empty and less than zero
|
||||
}
|
||||
|
||||
val child = tabletLayoutManager.findViewByPosition(vs)
|
||||
val slideRatio =
|
||||
clipRange(0f, 1f, abs((child?.left ?: 0) / actMain.nColumnWidth.toFloat()))
|
||||
if (slideRatio >= 0.95f) {
|
||||
++vs
|
||||
++ve
|
||||
}
|
||||
return IntRange(vs, min(ve, vs + actMain.nScreenColumn - 1))
|
||||
}
|
||||
|
||||
val visibleColumns: List<Column>
|
||||
get() {
|
||||
val list = actMain.appState.columnList
|
||||
return visibleColumnsIndices.mapNotNull { list.elementAtOrNull(it) }
|
||||
}
|
||||
|
||||
fun initUI(tmpTabletPager: RecyclerView) {
|
||||
this.tabletPager = tmpTabletPager
|
||||
this.tabletPagerAdapter = TabletColumnPagerAdapter(actMain)
|
||||
this.tabletLayoutManager =
|
||||
LinearLayoutManager(
|
||||
actMain,
|
||||
LinearLayoutManager.HORIZONTAL,
|
||||
false
|
||||
)
|
||||
|
||||
if (this.tabletPager.itemDecorationCount == 0) {
|
||||
this.tabletPager.addItemDecoration(TabletColumnDivider(actMain))
|
||||
}
|
||||
|
||||
this.tabletPager.adapter = this.tabletPagerAdapter
|
||||
this.tabletPager.layoutManager = this.tabletLayoutManager
|
||||
this.tabletPager.addOnScrollListener(object :
|
||||
RecyclerView.OnScrollListener() {
|
||||
|
||||
override fun onScrollStateChanged(
|
||||
recyclerView: RecyclerView,
|
||||
newState: Int,
|
||||
) {
|
||||
super.onScrollStateChanged(recyclerView, newState)
|
||||
|
||||
val vs = tabletLayoutManager.findFirstVisibleItemPosition()
|
||||
val ve = tabletLayoutManager.findLastVisibleItemPosition()
|
||||
// 端に近い方に合わせる
|
||||
val distance_left = abs(vs)
|
||||
val distance_right = abs(actMain.appState.columnCount - 1 - ve)
|
||||
if (distance_left < distance_right) {
|
||||
actMain.scrollColumnStrip(vs)
|
||||
} else {
|
||||
actMain.scrollColumnStrip(ve)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrolled(
|
||||
recyclerView: RecyclerView,
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
actMain.updateColumnStripSelection(-1, -1f)
|
||||
}
|
||||
})
|
||||
|
||||
this.tabletPager.itemAnimator = null
|
||||
// val animator = this.tablet_pager.itemAnimator
|
||||
// if( animator is DefaultItemAnimator){
|
||||
// animator.supportsChangeAnimations = false
|
||||
// }
|
||||
|
||||
this.tabletSnapHelper = GravitySnapHelper(Gravity.START)
|
||||
this.tabletSnapHelper.attachToRecyclerView(this.tabletPager)
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import jp.juggler.subwaytooter.ActColumnList
|
|||
import jp.juggler.subwaytooter.ActMain
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.entity.TootApplication
|
||||
import jp.juggler.subwaytooter.currentColumn
|
||||
import jp.juggler.subwaytooter.table.MutedApp
|
||||
import jp.juggler.util.showToast
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import jp.juggler.subwaytooter.api.*
|
|||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.dialog.DlgConfirm
|
||||
import jp.juggler.subwaytooter.onListMemberUpdated
|
||||
import jp.juggler.subwaytooter.showColumnMatchAccount
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.*
|
||||
import okhttp3.Request
|
||||
|
|
|
@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.action
|
|||
import jp.juggler.subwaytooter.ActMain
|
||||
import jp.juggler.subwaytooter.ColumnType
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.addColumn
|
||||
import jp.juggler.subwaytooter.api.entity.Acct
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.TootTag
|
||||
|
|
|
@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.action
|
|||
import jp.juggler.subwaytooter.ActMain
|
||||
import jp.juggler.subwaytooter.ColumnType
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.addColumn
|
||||
import jp.juggler.subwaytooter.api.entity.EntityId
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
|
|
|
@ -3,10 +3,15 @@ package jp.juggler.subwaytooter.api.entity
|
|||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import jp.juggler.util.JsonObject
|
||||
import jp.juggler.util.getStringOrNull
|
||||
import jp.juggler.util.notZero
|
||||
import jp.juggler.util.*
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
class EntityId(val x: String) : Comparable<EntityId> {
|
||||
|
||||
|
@ -98,3 +103,15 @@ fun EntityId?.putMayNull(cv: ContentValues, key: String) {
|
|||
this.putTo(cv, key)
|
||||
}
|
||||
}
|
||||
|
||||
object EntityIdSerializer : KSerializer<EntityId> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("EntityId", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: EntityId) =
|
||||
encoder.encodeString(value.toString() )
|
||||
|
||||
override fun deserialize(decoder: Decoder): EntityId =
|
||||
EntityId(decoder.decodeString())
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api.entity
|
|||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.util.JsonObject
|
||||
import jp.juggler.util.LogCategory
|
||||
import jp.juggler.util.jsonObject
|
||||
|
||||
class TootScheduled(parser: TootParser, val src: JsonObject) : TimelineItem() {
|
||||
|
||||
|
@ -42,4 +43,18 @@ class TootScheduled(parser: TootParser, val src: JsonObject) : TimelineItem() {
|
|||
}
|
||||
|
||||
fun hasMedia() = mediaAttachments?.isNotEmpty() == true
|
||||
|
||||
// 投稿画面の復元時に、IDだけでもないと困る
|
||||
fun encodeSimple() = jsonObject {
|
||||
put("id",id.toString())
|
||||
put("scheduled_at",scheduledAt)
|
||||
// SKIP: put("media_attachments",mediaAttachments?.map{ it.})
|
||||
put("params", jsonObject {
|
||||
put("text",text)
|
||||
put("visibility",visibility.strMastodon)
|
||||
put("spoiler_text",spoilerText)
|
||||
put("in_reply_to_id",inReplyToId)
|
||||
put("sensitive",sensitive)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import android.widget.ListView
|
|||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import jp.juggler.subwaytooter.ActPost
|
||||
import jp.juggler.subwaytooter.DRAFT_CONTENT
|
||||
import jp.juggler.subwaytooter.DRAFT_CONTENT_WARNING
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
import jp.juggler.subwaytooter.table.PostDraft
|
||||
|
@ -153,8 +155,8 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl
|
|||
|
||||
val json = draft.json
|
||||
if (json != null) {
|
||||
val cw = json.string(ActPost.DRAFT_CONTENT_WARNING)
|
||||
val c = json.string(ActPost.DRAFT_CONTENT)
|
||||
val cw = json.string(DRAFT_CONTENT_WARNING)
|
||||
val c = json.string(DRAFT_CONTENT)
|
||||
val sb = StringBuilder()
|
||||
if (cw?.trim { it <= ' ' }?.isNotEmpty() == true) {
|
||||
sb.append(cw)
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.dialog.ActionsDialog
|
||||
import jp.juggler.subwaytooter.kJson
|
||||
import jp.juggler.util.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.util.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class AttachmentPicker(
|
||||
val activity: AppCompatActivity,
|
||||
val callback: Callback,
|
||||
) {
|
||||
companion object {
|
||||
private val log = LogCategory("AttachmentPicker")
|
||||
private val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
private const val PERMISSION_REQUEST_CODE = 1
|
||||
}
|
||||
|
||||
// callback after media selected
|
||||
interface Callback {
|
||||
fun onPickAttachment(uri: Uri, mimeType: String? = null)
|
||||
fun onPickCustomThumbnail(src: GetContentResultEntry)
|
||||
}
|
||||
|
||||
// actions after permission granted
|
||||
enum class AfterPermission { Attachment, CustomThumbnail, }
|
||||
|
||||
@Serializable
|
||||
data class States(
|
||||
|
||||
@Serializable(with = UriSerializer::class)
|
||||
var uriCameraImage: Uri? = null,
|
||||
|
||||
var afterPermission: AfterPermission = AfterPermission.Attachment,
|
||||
)
|
||||
|
||||
private var states = States()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// activity result handlers
|
||||
|
||||
private val arAttachmentChooser = activity.activityResultHandler { ar ->
|
||||
if (ar?.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
ar.data?.handleGetContentResult(contentResolver)?.pickAll()
|
||||
}
|
||||
}
|
||||
|
||||
private val arCamera = activity.activityResultHandler { ar ->
|
||||
if (ar?.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
// 画像のURL
|
||||
when (val uri = ar.data?.data ?: states.uriCameraImage) {
|
||||
null -> showToast(false, "missing image uri")
|
||||
else -> callback.onPickAttachment(uri)
|
||||
}
|
||||
} else {
|
||||
// 失敗したら DBからデータを削除
|
||||
states.uriCameraImage?.let { uri ->
|
||||
contentResolver.delete(uri, null, null)
|
||||
states.uriCameraImage = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val arCapture = activity.activityResultHandler { ar ->
|
||||
if (ar?.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
ar.data?.data?.let { callback.onPickAttachment(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val arCustomThumbnail = activity.activityResultHandler { ar ->
|
||||
if (ar?.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
ar.data
|
||||
?.handleGetContentResult(contentResolver)
|
||||
?.firstOrNull()
|
||||
?.let { callback.onPickCustomThumbnail(it) }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// must register all ARHs before onStart
|
||||
arAttachmentChooser.register(activity, log)
|
||||
arCamera.register(activity, log)
|
||||
arCapture.register(activity, log)
|
||||
arCustomThumbnail.register(activity, log)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// states
|
||||
|
||||
fun reset() {
|
||||
states.uriCameraImage = null
|
||||
}
|
||||
|
||||
fun encodeState(): String {
|
||||
val encoded = kJson.encodeToString(states)
|
||||
val decoded = kJson.decodeFromString<States>(encoded)
|
||||
log.d("encodeState: ${decoded.uriCameraImage},${decoded.afterPermission},$encoded")
|
||||
return encoded
|
||||
}
|
||||
|
||||
fun restoreState(encoded: String) {
|
||||
states = kJson.decodeFromString(encoded)
|
||||
log.d("restoreState: ${states.uriCameraImage},${states.afterPermission},$encoded")
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// permission check
|
||||
// (current implementation does not auto restart actions after got permission
|
||||
|
||||
// returns true if permission granted, false if not granted, (may request permissions)
|
||||
private fun checkPermission(afterPermission: AfterPermission): Boolean {
|
||||
states.afterPermission = afterPermission
|
||||
if (permissions.all {
|
||||
ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED
|
||||
}) return true
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
ActivityCompat.requestPermissions(activity, permissions, PERMISSION_REQUEST_CODE)
|
||||
} else {
|
||||
activity.showToast(true, R.string.missing_permission_to_access_media)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
|
||||
if (requestCode != PERMISSION_REQUEST_CODE) return
|
||||
|
||||
if ((permissions.indices).any { grantResults.elementAtOrNull(it) != PackageManager.PERMISSION_GRANTED }) {
|
||||
activity.showToast(true, R.string.missing_permission_to_access_media)
|
||||
return
|
||||
}
|
||||
|
||||
when (states.afterPermission) {
|
||||
AfterPermission.Attachment -> openPicker()
|
||||
AfterPermission.CustomThumbnail -> openCustomThumbnail()
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
|
||||
fun openPicker() {
|
||||
if (!checkPermission(AfterPermission.Attachment)) return
|
||||
|
||||
// permissionCheck = ContextCompat.checkSelfPermission( this, Manifest.permission.CAMERA );
|
||||
// if( permissionCheck != PackageManager.PERMISSION_GRANTED ){
|
||||
// preparePermission();
|
||||
// return;
|
||||
// }
|
||||
|
||||
with(activity) {
|
||||
val a = ActionsDialog()
|
||||
a.addAction(getString(R.string.pick_images)) {
|
||||
openAttachmentChooser(R.string.pick_images, "image/*", "video/*")
|
||||
}
|
||||
a.addAction(getString(R.string.pick_videos)) {
|
||||
openAttachmentChooser(R.string.pick_videos, "video/*")
|
||||
}
|
||||
a.addAction(getString(R.string.pick_audios)) {
|
||||
openAttachmentChooser(R.string.pick_audios, "audio/*")
|
||||
}
|
||||
a.addAction(getString(R.string.image_capture)) {
|
||||
performCamera()
|
||||
}
|
||||
a.addAction(getString(R.string.video_capture)) {
|
||||
performCapture(
|
||||
MediaStore.ACTION_VIDEO_CAPTURE,
|
||||
"can't open video capture app."
|
||||
)
|
||||
}
|
||||
a.addAction(getString(R.string.voice_capture)) {
|
||||
performCapture(
|
||||
MediaStore.Audio.Media.RECORD_SOUND_ACTION,
|
||||
"can't open voice capture app."
|
||||
)
|
||||
}
|
||||
a.show(this, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAttachmentChooser(titleId: Int, vararg mimeTypes: String) {
|
||||
// SAFのIntentで開く
|
||||
try {
|
||||
val intent = intentGetContent(true, activity.getString(titleId), mimeTypes)
|
||||
arAttachmentChooser.launch(intent)
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
activity.showToast(ex, "ACTION_GET_CONTENT failed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun performCamera() {
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.TITLE, "${System.currentTimeMillis()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
}
|
||||
|
||||
val newUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
.also { states.uriCameraImage = it }
|
||||
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||
putExtra(MediaStore.EXTRA_OUTPUT, newUri)
|
||||
}
|
||||
|
||||
arCamera.launch(intent)
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
activity.showToast(ex, "opening camera app failed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun performCapture(action: String, errorCaption: String) {
|
||||
try {
|
||||
arCapture.launch(Intent(action))
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
activity.showToast(ex, errorCaption)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ArrayList<GetContentResultEntry>.pickAll() =
|
||||
forEach { callback.onPickAttachment(it.uri, it.mimeType) }
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Mastodon's custom thumbnail
|
||||
|
||||
fun openCustomThumbnail() {
|
||||
if (!checkPermission(AfterPermission.CustomThumbnail)) return
|
||||
|
||||
// SAFのIntentで開く
|
||||
try {
|
||||
arCustomThumbnail.launch(
|
||||
intentGetContent(false, activity.getString(R.string.pick_images), arrayOf("image/*"))
|
||||
)
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
activity.showToast(ex, "ACTION_GET_CONTENT failed.")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,793 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.WorkerThread
|
||||
import jp.juggler.subwaytooter.ActPost
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.TootApiCallback
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.*
|
||||
import kotlinx.coroutines.delay
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class AttachmentRequest(
|
||||
val account: SavedAccount,
|
||||
val pa: PostAttachment,
|
||||
val uri: Uri,
|
||||
val mimeType: String,
|
||||
val isReply: Boolean,
|
||||
val onUploadEnd: () -> Unit,
|
||||
)
|
||||
|
||||
class AttachmentUploader(
|
||||
contextArg: Context,
|
||||
private val handler: Handler,
|
||||
) {
|
||||
companion object {
|
||||
val log = LogCategory("AttachmentUploader")
|
||||
|
||||
internal const val MIME_TYPE_JPEG = "image/jpeg"
|
||||
internal const val MIME_TYPE_PNG = "image/png"
|
||||
|
||||
val acceptableMimeTypes = HashSet<String>().apply {
|
||||
//
|
||||
add("image/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
|
||||
add("video/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
|
||||
add("audio/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
|
||||
//
|
||||
add("image/jpeg")
|
||||
add("image/png")
|
||||
add("image/gif")
|
||||
add("video/webm")
|
||||
add("video/mp4")
|
||||
add("video/quicktime")
|
||||
//
|
||||
add("audio/webm")
|
||||
add("audio/ogg")
|
||||
add("audio/mpeg")
|
||||
add("audio/mp3")
|
||||
add("audio/wav")
|
||||
add("audio/wave")
|
||||
add("audio/x-wav")
|
||||
add("audio/x-pn-wav")
|
||||
add("audio/flac")
|
||||
add("audio/x-flac")
|
||||
|
||||
// https://github.com/tootsuite/mastodon/pull/11342
|
||||
add("audio/aac")
|
||||
add("audio/m4a")
|
||||
add("audio/3gpp")
|
||||
}
|
||||
|
||||
val acceptableMimeTypesPixelfed = HashSet<String>().apply {
|
||||
//
|
||||
add("image/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
|
||||
add("video/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
|
||||
//
|
||||
add("image/jpeg")
|
||||
add("image/png")
|
||||
add("image/gif")
|
||||
add("video/mp4")
|
||||
add("video/m4v")
|
||||
}
|
||||
|
||||
private val imageHeaderList = arrayOf(
|
||||
Pair(
|
||||
"image/jpeg",
|
||||
intArrayOf(0xff, 0xd8, 0xff).toByteArray()
|
||||
),
|
||||
Pair(
|
||||
"image/png",
|
||||
intArrayOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A).toByteArray()
|
||||
),
|
||||
Pair(
|
||||
"image/gif",
|
||||
charArrayOf('G', 'I', 'F').toLowerByteArray()
|
||||
),
|
||||
Pair(
|
||||
"audio/wav",
|
||||
charArrayOf('R', 'I', 'F', 'F').toLowerByteArray()
|
||||
),
|
||||
Pair(
|
||||
"audio/ogg",
|
||||
charArrayOf('O', 'g', 'g', 'S').toLowerByteArray()
|
||||
),
|
||||
Pair(
|
||||
"audio/flac",
|
||||
charArrayOf('f', 'L', 'a', 'C').toLowerByteArray()
|
||||
)
|
||||
)
|
||||
|
||||
private val sig3gp = arrayOf(
|
||||
"3ge6",
|
||||
"3ge7",
|
||||
"3gg6",
|
||||
"3gp1",
|
||||
"3gp2",
|
||||
"3gp3",
|
||||
"3gp4",
|
||||
"3gp5",
|
||||
"3gp6",
|
||||
"3gp7",
|
||||
"3gr6",
|
||||
"3gr7",
|
||||
"3gs6",
|
||||
"3gs7",
|
||||
"kddi"
|
||||
).map { it.toCharArray().toLowerByteArray() }
|
||||
|
||||
private val sigM4a = arrayOf(
|
||||
"M4A ",
|
||||
"M4B ",
|
||||
"M4P "
|
||||
).map { it.toCharArray().toLowerByteArray() }
|
||||
|
||||
private val sigFtyp = "ftyp".toCharArray().toLowerByteArray()
|
||||
|
||||
private fun matchSig(
|
||||
data: ByteArray,
|
||||
dataOffset: Int,
|
||||
sig: ByteArray,
|
||||
sigSize: Int = sig.size,
|
||||
): Boolean {
|
||||
for (i in 0 until sigSize) {
|
||||
if (data[dataOffset + i] != sig[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val context: Context = contextArg.applicationContext
|
||||
private val attachmentQueue = ConcurrentLinkedQueue<AttachmentRequest>()
|
||||
private var attachmentWorker: AttachmentWorker? = null
|
||||
private var lastAttachmentAdd = 0L
|
||||
private var lastAttachmentComplete = 0L
|
||||
|
||||
fun onActivityDestroy() {
|
||||
attachmentWorker?.cancel()
|
||||
}
|
||||
|
||||
fun addRequest(
|
||||
request: AttachmentRequest,
|
||||
) {
|
||||
// アップロード開始トースト(連発しない)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttachmentAdd >= 5000L) {
|
||||
context.showToast(false, R.string.attachment_uploading)
|
||||
}
|
||||
lastAttachmentAdd = now
|
||||
|
||||
// マストドンは添付メディアをID順に表示するため
|
||||
// 画像が複数ある場合は一つずつ処理する必要がある
|
||||
// 投稿画面ごとに1スレッドだけ作成してバックグラウンド処理を行う
|
||||
attachmentQueue.add(request)
|
||||
val oldWorker = attachmentWorker
|
||||
if (oldWorker == null || !oldWorker.isAlive || oldWorker.isCancelled.get()) {
|
||||
oldWorker?.cancel()
|
||||
attachmentWorker = AttachmentWorker()
|
||||
} else {
|
||||
oldWorker.notifyEx()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleResult(request: AttachmentRequest, result: TootApiResult?) {
|
||||
val pa = request.pa
|
||||
pa.status = when (pa.attachment) {
|
||||
null -> {
|
||||
if (result != null) {
|
||||
context.showToast(
|
||||
true,
|
||||
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
|
||||
)
|
||||
}
|
||||
PostAttachment.Status.Error
|
||||
}
|
||||
else -> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttachmentComplete >= 5000L) {
|
||||
context.showToast(false, R.string.attachment_uploaded)
|
||||
}
|
||||
lastAttachmentComplete = now
|
||||
|
||||
PostAttachment.Status.Ok
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿中に画面回転があった場合、新しい画面のコールバックを呼び出す必要がある
|
||||
pa.callback?.onPostAttachmentComplete(pa)
|
||||
}
|
||||
|
||||
inner class AttachmentWorker : WorkerBase() {
|
||||
|
||||
internal val isCancelled = AtomicBoolean(false)
|
||||
|
||||
override fun cancel() {
|
||||
isCancelled.set(true)
|
||||
notifyEx()
|
||||
}
|
||||
|
||||
override suspend fun run() {
|
||||
try {
|
||||
while (!isCancelled.get()) {
|
||||
val request = attachmentQueue.poll()
|
||||
if (request == null) {
|
||||
waitEx(86400000L)
|
||||
continue
|
||||
}
|
||||
val result = request.upload()
|
||||
handler.post { handleResult(request, result) }
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex)
|
||||
log.e(ex, "AttachmentWorker")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun AttachmentRequest.upload(): TootApiResult? {
|
||||
if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.")
|
||||
|
||||
try {
|
||||
|
||||
val client = TootApiClient(context, callback = object : TootApiCallback {
|
||||
override val isApiCancelled: Boolean
|
||||
get() = isCancelled.get()
|
||||
})
|
||||
|
||||
client.account = account
|
||||
|
||||
val (ti, tiResult) = TootInstance.get(client)
|
||||
ti ?: return tiResult
|
||||
|
||||
if (ti.instanceType == InstanceType.Pixelfed) {
|
||||
if (isReply) {
|
||||
return TootApiResult(context.getString(R.string.pixelfed_does_not_allow_reply_with_media))
|
||||
}
|
||||
if (!acceptableMimeTypesPixelfed.contains(mimeType)) {
|
||||
return TootApiResult(context.getString(R.string.mime_type_not_acceptable, mimeType))
|
||||
}
|
||||
}
|
||||
// 設定からリサイズ指定を読む
|
||||
val resizeConfig = account.getResizeConfig()
|
||||
|
||||
val opener = createOpener(uri, mimeType, resizeConfig)
|
||||
|
||||
val mediaSizeMax = when {
|
||||
mimeType.startsWith("video") || mimeType.startsWith("audio") ->
|
||||
account.getMovieMaxBytes(ti)
|
||||
|
||||
else ->
|
||||
account.getImageMaxBytes(ti)
|
||||
}
|
||||
|
||||
val contentLength = getStreamSize(true, opener.open())
|
||||
if (contentLength > mediaSizeMax) {
|
||||
return TootApiResult(
|
||||
context.getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
|
||||
)
|
||||
}
|
||||
|
||||
fun fixDocumentName(s: String): String {
|
||||
val sLength = s.length
|
||||
val m = """([^\x20-\x7f])""".asciiPattern().matcher(s)
|
||||
m.reset()
|
||||
val sb = StringBuilder(sLength)
|
||||
var lastEnd = 0
|
||||
while (m.find()) {
|
||||
sb.append(s.substring(lastEnd, m.start()))
|
||||
val escaped = m.groupEx(1)!!.encodeUTF8().encodeHex()
|
||||
sb.append(escaped)
|
||||
lastEnd = m.end()
|
||||
}
|
||||
if (lastEnd < sLength) sb.append(s.substring(lastEnd, sLength))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri))
|
||||
|
||||
return if (account.isMisskey) {
|
||||
val multipartBuilder = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
|
||||
val apiKey = account.token_info?.string(TootApiClient.KEY_API_KEY_MISSKEY)
|
||||
if (apiKey?.isNotEmpty() == true) {
|
||||
multipartBuilder.addFormDataPart("i", apiKey)
|
||||
}
|
||||
|
||||
multipartBuilder.addFormDataPart(
|
||||
"file",
|
||||
fileName,
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType {
|
||||
return opener.mimeType.toMediaType()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun contentLength(): Long {
|
||||
return contentLength
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
opener.open().use { inData ->
|
||||
val tmp = ByteArray(4096)
|
||||
while (true) {
|
||||
val r = inData.read(tmp, 0, tmp.size)
|
||||
if (r <= 0) break
|
||||
sink.write(tmp, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val result = client.request(
|
||||
"/api/drive/files/create",
|
||||
multipartBuilder.build().toPost()
|
||||
)
|
||||
|
||||
opener.deleteTempFile()
|
||||
onUploadEnd()
|
||||
|
||||
val jsonObject = result?.jsonObject
|
||||
if (jsonObject != null) {
|
||||
val a = parseItem(::TootAttachment, ServiceType.MISSKEY, jsonObject)
|
||||
if (a == null) {
|
||||
result.error = "TootAttachment.parse failed"
|
||||
} else {
|
||||
pa.attachment = a
|
||||
}
|
||||
}
|
||||
result
|
||||
} else {
|
||||
suspend fun postMedia(path: String) = client.request(
|
||||
path,
|
||||
MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
fileName,
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType {
|
||||
return opener.mimeType.toMediaType()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun contentLength(): Long {
|
||||
return contentLength
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
opener.open().use { inData ->
|
||||
val tmp = ByteArray(4096)
|
||||
while (true) {
|
||||
val r = inData.read(tmp, 0, tmp.size)
|
||||
if (r <= 0) break
|
||||
sink.write(tmp, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.build().toPost()
|
||||
)
|
||||
|
||||
suspend fun postV1() = postMedia("/api/v1/media")
|
||||
|
||||
suspend fun postV2(): TootApiResult? {
|
||||
// 3.1.3未満は v1 APIを使う
|
||||
if (!ti.versionGE(TootInstance.VERSION_3_1_3)) {
|
||||
return postV1()
|
||||
}
|
||||
|
||||
// v2 APIを試す
|
||||
val result = postMedia("/api/v2/media")
|
||||
val code = result?.response?.code // complete,or 4xx error
|
||||
when {
|
||||
// 404ならv1 APIにフォールバック
|
||||
code == 404 -> return postV1()
|
||||
// 202 accepted 以外はポーリングしない
|
||||
code != 202 -> return result
|
||||
}
|
||||
|
||||
// ポーリングして処理完了を待つ
|
||||
val id = parseItem(::TootAttachment, ServiceType.MASTODON, result?.jsonObject)
|
||||
?.id
|
||||
?: return TootApiResult("/api/v2/media did not return the media ID.")
|
||||
|
||||
var lastResponse = SystemClock.elapsedRealtime()
|
||||
loop@ while (true) {
|
||||
|
||||
delay(1000L)
|
||||
val r2 = client.request("/api/v1/media/$id")
|
||||
?: return null // cancelled
|
||||
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
when (r2.response?.code) {
|
||||
// complete,or 4xx error
|
||||
200, in 400 until 500 -> return r2
|
||||
|
||||
// continue to wait
|
||||
206 -> lastResponse = now
|
||||
|
||||
// too many temporary error without 206 response.
|
||||
else -> if (now - lastResponse >= 120000L) {
|
||||
return TootApiResult("timeout.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val result = postV2()
|
||||
opener.deleteTempFile()
|
||||
onUploadEnd()
|
||||
|
||||
val jsonObject = result?.jsonObject
|
||||
if (jsonObject != null) {
|
||||
when (val a = parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject)) {
|
||||
null -> result.error = "TootAttachment.parse failed"
|
||||
else -> pa.attachment = a
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
return TootApiResult(ex.withCaption("read failed."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface InputStreamOpener {
|
||||
|
||||
val mimeType: String
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun open(): InputStream
|
||||
|
||||
fun deleteTempFile()
|
||||
}
|
||||
|
||||
private fun createOpener(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
resizeConfig: ResizeConfig,
|
||||
): InputStreamOpener {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
// 画像の種別
|
||||
val isJpeg = MIME_TYPE_JPEG == mimeType
|
||||
val isPng = MIME_TYPE_PNG == mimeType
|
||||
if (!isJpeg && !isPng) {
|
||||
ActPost.log.d("createOpener: source is not jpeg or png")
|
||||
break
|
||||
}
|
||||
|
||||
val bitmap = createResizedBitmap(
|
||||
context,
|
||||
uri,
|
||||
resizeConfig,
|
||||
skipIfNoNeedToResizeAndRotate = true
|
||||
)
|
||||
if (bitmap != null) {
|
||||
try {
|
||||
val cacheDir = context.externalCacheDir
|
||||
if (cacheDir == null) {
|
||||
context.showToast(false, "getExternalCacheDir returns null.")
|
||||
break
|
||||
}
|
||||
|
||||
cacheDir.mkdir()
|
||||
|
||||
val tempFile = File(cacheDir, "tmp." + Thread.currentThread().id)
|
||||
FileOutputStream(tempFile).use { os ->
|
||||
if (isJpeg) {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
|
||||
} else {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os)
|
||||
}
|
||||
}
|
||||
|
||||
return object : InputStreamOpener {
|
||||
|
||||
override val mimeType: String
|
||||
get() = mimeType
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun open(): InputStream {
|
||||
return FileInputStream(tempFile)
|
||||
}
|
||||
|
||||
override fun deleteTempFile() {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ActPost.log.trace(ex)
|
||||
context.showToast(ex, "Resizing image failed.")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return object : InputStreamOpener {
|
||||
|
||||
override val mimeType: String
|
||||
get() = mimeType
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun open(): InputStream {
|
||||
return context.contentResolver.openInputStream(uri) ?: error("openInputStream returns null")
|
||||
}
|
||||
|
||||
override fun deleteTempFile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMimeType(uri: Uri, mimeTypeArg: String?): String? {
|
||||
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
|
||||
// クレームで消耗するのを減らすためにファイルヘッダを確認する
|
||||
if (mimeTypeArg == null || mimeTypeArg.startsWith("image/")) {
|
||||
val sv = findMimeTypeByFileHeader(context.contentResolver, uri)
|
||||
if (sv != null) return sv
|
||||
}
|
||||
|
||||
// 既に引数で与えられてる
|
||||
if (mimeTypeArg?.isNotEmpty() == true) {
|
||||
return mimeTypeArg
|
||||
}
|
||||
|
||||
// ContentResolverに尋ねる
|
||||
var sv = context.contentResolver.getType(uri)
|
||||
if (sv?.isNotEmpty() == true) return sv
|
||||
|
||||
// gboardのステッカーではUriのクエリパラメータにmimeType引数がある
|
||||
sv = uri.getQueryParameter("mimeType")
|
||||
if (sv?.isNotEmpty() == true) return sv
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findMimeTypeByFileHeader(
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri,
|
||||
): String? {
|
||||
try {
|
||||
contentResolver.openInputStream(uri)?.use { inStream ->
|
||||
val data = ByteArray(65536)
|
||||
val nRead = inStream.read(data, 0, data.size)
|
||||
for (pair in imageHeaderList) {
|
||||
val type = pair.first
|
||||
val header = pair.second
|
||||
if (nRead >= header.size && data.startWith(header)) return type
|
||||
}
|
||||
|
||||
// scan frame header
|
||||
for (i in 0 until nRead - 8) {
|
||||
|
||||
if (!matchSig(data, i, sigFtyp)) continue
|
||||
|
||||
// 3gpp check
|
||||
for (s in sig3gp) {
|
||||
if (matchSig(data, i + 4, s)) return "audio/3gpp"
|
||||
}
|
||||
|
||||
// m4a check
|
||||
for (s in sigM4a) {
|
||||
if (matchSig(data, i + 4, s)) return "audio/m4a"
|
||||
}
|
||||
}
|
||||
|
||||
// scan frame header
|
||||
loop@ for (i in 0 until nRead - 2) {
|
||||
|
||||
// mpeg frame header
|
||||
val b0 = data[i].toInt() and 255
|
||||
if (b0 != 255) continue
|
||||
val b1 = data[i + 1].toInt() and 255
|
||||
if ((b1 and 0b11100000) != 0b11100000) continue
|
||||
|
||||
val mpegVersionId = ((b1 shr 3) and 3)
|
||||
// 00 mpeg 2.5
|
||||
// 01 not used
|
||||
// 10 (mp3) mpeg 2 / (AAC) mpeg-4
|
||||
// 11 (mp3) mpeg 1 / (AAC) mpeg-2
|
||||
|
||||
@Suppress("MoveVariableDeclarationIntoWhen")
|
||||
val mpegLayerId = ((b1 shr 1) and 3)
|
||||
// 00 (mp3)not used / (AAC) always 0
|
||||
// 01 (mp3)layer III
|
||||
// 10 (mp3)layer II
|
||||
// 11 (mp3)layer I
|
||||
|
||||
when (mpegLayerId) {
|
||||
0 -> when (mpegVersionId) {
|
||||
2, 3 -> return "audio/aac"
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
1 -> when (mpegVersionId) {
|
||||
0, 2, 3 -> return "audio/mp3"
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "findMimeTypeByFileHeader failed.")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
|
||||
suspend fun uploadCustomThumbnail(
|
||||
account: SavedAccount,
|
||||
src: GetContentResultEntry,
|
||||
pa: PostAttachment,
|
||||
): TootApiResult? = try {
|
||||
context.runApiTask(account) { client ->
|
||||
val mimeType = getMimeType(src.uri, src.mimeType)
|
||||
if (mimeType?.isEmpty() != false) {
|
||||
return@runApiTask TootApiResult(context.getString(R.string.mime_type_missing))
|
||||
}
|
||||
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask ri
|
||||
|
||||
val resizeConfig = ResizeConfig(ResizeType.SquarePixel, 400)
|
||||
|
||||
val opener = createOpener(src.uri, mimeType, resizeConfig)
|
||||
|
||||
val mediaSizeMax = 1000000
|
||||
|
||||
val contentLength = getStreamSize(true, opener.open())
|
||||
if (contentLength > mediaSizeMax) {
|
||||
return@runApiTask TootApiResult(
|
||||
getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
|
||||
)
|
||||
}
|
||||
|
||||
fun fixDocumentName(s: String): String {
|
||||
val sLength = s.length
|
||||
val m = """([^\x20-\x7f])""".asciiPattern().matcher(s)
|
||||
m.reset()
|
||||
val sb = StringBuilder(sLength)
|
||||
var lastEnd = 0
|
||||
while (m.find()) {
|
||||
sb.append(s.substring(lastEnd, m.start()))
|
||||
val escaped = m.groupEx(1)!!.encodeUTF8().encodeHex()
|
||||
sb.append(escaped)
|
||||
lastEnd = m.end()
|
||||
}
|
||||
if (lastEnd < sLength) sb.append(s.substring(lastEnd, sLength))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
val fileName = fixDocumentName(getDocumentName(context.contentResolver, src.uri))
|
||||
|
||||
if (account.isMisskey) {
|
||||
opener.deleteTempFile()
|
||||
TootApiResult("custom thumbnail is not supported on misskey account.")
|
||||
} else {
|
||||
val result = client.request(
|
||||
"/api/v1/media/${pa.attachment?.id}",
|
||||
MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"thumbnail",
|
||||
fileName,
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType {
|
||||
return opener.mimeType.toMediaType()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun contentLength(): Long {
|
||||
return contentLength
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
opener.open().use { inData ->
|
||||
val tmp = ByteArray(4096)
|
||||
while (true) {
|
||||
val r = inData.read(tmp, 0, tmp.size)
|
||||
if (r <= 0) break
|
||||
sink.write(tmp, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.build().toPut()
|
||||
)
|
||||
opener.deleteTempFile()
|
||||
|
||||
val jsonObject = result?.jsonObject
|
||||
if (jsonObject != null) {
|
||||
val a = parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject)
|
||||
if (a == null) {
|
||||
result.error = "TootAttachment.parse failed"
|
||||
} else {
|
||||
pa.attachment = a
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
TootApiResult(ex.withCaption("uploadCustomThumbnail failed."))
|
||||
}
|
||||
|
||||
suspend fun setAttachmentDescription(
|
||||
account: SavedAccount,
|
||||
attachmentId: EntityId,
|
||||
description: String,
|
||||
): Pair<TootApiResult?, TootAttachment?> {
|
||||
var resultAttachment: TootAttachment? = null
|
||||
val result = try {
|
||||
context.runApiTask(account) { client ->
|
||||
client.request(
|
||||
"/api/v1/media/$attachmentId",
|
||||
jsonObject {
|
||||
put("description", description)
|
||||
}
|
||||
.toPutRequestBuilder()
|
||||
)?.also { result ->
|
||||
resultAttachment = parseItem(::TootAttachment, ServiceType.MASTODON, result.jsonObject)
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.trace(ex, "setAttachmentDescription failed.")
|
||||
TootApiResult(ex.withCaption("setAttachmentDescription failed."))
|
||||
}
|
||||
return Pair(result, resultAttachment)
|
||||
}
|
||||
|
||||
fun isAcceptableMimeType(instance: TootInstance?, mimeType: String, isReply: Boolean): Boolean {
|
||||
if (instance?.instanceType == InstanceType.Pixelfed) {
|
||||
if (isReply) {
|
||||
context.showToast(true, R.string.pixelfed_does_not_allow_reply_with_media)
|
||||
return false
|
||||
}
|
||||
if (!acceptableMimeTypesPixelfed.contains(mimeType)) {
|
||||
context.showToast(true, R.string.mime_type_not_acceptable, mimeType)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!acceptableMimeTypes.contains(mimeType)) {
|
||||
context.showToast(true, R.string.mime_type_not_acceptable, mimeType)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -3,29 +3,27 @@ package jp.juggler.subwaytooter.util
|
|||
import jp.juggler.subwaytooter.api.entity.TootAttachment
|
||||
|
||||
class PostAttachment : Comparable<PostAttachment> {
|
||||
|
||||
companion object {
|
||||
const val STATUS_UPLOADING = 1
|
||||
const val STATUS_UPLOADED = 2
|
||||
const val STATUS_UPLOAD_FAILED = 3
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onPostAttachmentComplete(pa: PostAttachment)
|
||||
}
|
||||
|
||||
var status: Int
|
||||
var attachment: TootAttachment? = null
|
||||
enum class Status(val id: Int) {
|
||||
Progress(1),
|
||||
Ok(2),
|
||||
Error(3),
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var attachment: TootAttachment? = null
|
||||
var callback: Callback? = null
|
||||
|
||||
constructor(callback: Callback) {
|
||||
this.status = STATUS_UPLOADING
|
||||
this.status = Status.Progress
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
constructor(a: TootAttachment) {
|
||||
this.status = STATUS_UPLOADED
|
||||
this.status = Status.Ok
|
||||
this.attachment = a
|
||||
}
|
||||
|
||||
|
|
|
@ -8,14 +8,13 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
|||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
object UriOrNullSerializer : KSerializer<Uri?> {
|
||||
|
||||
object UriSerializer : KSerializer<Uri> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("UriOrNull", PrimitiveKind.STRING)
|
||||
PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Uri?) =
|
||||
encoder.encodeString(value?.toString() ?: "")
|
||||
override fun serialize(encoder: Encoder, value: Uri) =
|
||||
encoder.encodeString(value.toString())
|
||||
|
||||
override fun deserialize(decoder: Decoder): Uri? =
|
||||
decoder.decodeString().mayUri()
|
||||
override fun deserialize(decoder: Decoder): Uri =
|
||||
Uri.parse(decoder.decodeString())
|
||||
}
|
Loading…
Reference in New Issue