more split...

This commit is contained in:
tateisu 2021-06-23 13:14:25 +09:00
parent ac2f54c22a
commit 6de96b4852
41 changed files with 5104 additions and 4785 deletions

View File

@ -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;

View File

@ -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

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}
})
}

View File

@ -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
}

View File

@ -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)
}
)
}
}

View File

@ -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) }
}
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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

View File

@ -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()
}

View File

@ -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()
}
}
}

View File

@ -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
}
)
)
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -610,3 +610,5 @@ class App1 : Application() {
}
}
}
val kJson = kotlinx.serialization.json.Json{ ignoreUnknownKeys = true }

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())
}

View File

@ -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)
})
}
}

View File

@ -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)

View File

@ -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.")
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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())
}