アカウント設定に投稿する動画を再圧縮する設定を追加

This commit is contained in:
tateisu 2022-01-05 18:39:48 +09:00
parent b0ddddfe49
commit 57f026518a
26 changed files with 1115 additions and 400 deletions

View File

@ -296,6 +296,9 @@ dependencies {
implementation "io.insert-koin:koin-androidx-workmanager:$koin_version"
// implementation "io.insert-koin:koin-androidx-navigation:$koin_version"
// implementation "io.insert-koin:koin-androidx-compose:$koin_version"
// video transcoder https://github.com/natario1/Transcoder
implementation "com.otaliastudios:transcoder:0.10.4"
}
repositories {

View File

@ -76,6 +76,29 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
Intent(activity, ActAccountSetting::class.java).apply {
putExtra(KEY_ACCOUNT_DB_ID, ai.db_id)
}
fun simpleTextWatcher(block: () -> Unit) = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
block()
}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) {
}
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) {
}
}
}
@kotlinx.serialization.Serializable
@ -268,6 +291,18 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
spMovieTranscodeMode.adapter = ArrayAdapter(
this@ActAccountSetting,
android.R.layout.simple_spinner_item,
arrayOf(
getString(R.string.auto),
getString(R.string.no),
getString(R.string.always),
)
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
pushPolicyItems = listOf(
PushPolicyItem(null, getString(R.string.unspecified)),
PushPolicyItem("all", getString(R.string.all)),
@ -312,52 +347,26 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
NetworkEmojiInvalidator(handler, it)
}
etDefaultText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
saveUIToData()
}
val watcher1 = simpleTextWatcher { saveUIToData() }
arrayOf(
etDefaultText,
etMediaSizeMax,
etMovieSizeMax,
etMovieBitrate,
etMovieFrameRate,
etMovieSquarePixels,
).forEach {
it.addTextChangedListener(watcher1)
}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) {
}
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) {
}
})
etMaxTootChars.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) {
}
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) {
}
override fun afterTextChanged(s: Editable?) {
etMaxTootChars.addTextChangedListener(
simpleTextWatcher{
val num = etMaxTootChars.parseInt()
if (num != null && num >= 0) {
saveUIToData()
}
}
})
)
arrayOf(
btnOpenBrowser,
@ -409,6 +418,7 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
arrayOf(
spResizeImage,
spPushPolicy,
spMovieTranscodeMode,
).forEach { it.onItemSelectedListener = this@ActAccountSetting }
}
}
@ -456,6 +466,40 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
etDefaultText.setText(a.default_text)
etMaxTootChars.setText(a.max_toot_chars.toString())
val ti = TootInstance.getCached(a)
if (ti == null) {
etMediaSizeMax.setText(a.image_max_megabytes ?: "")
etMovieSizeMax.setText(a.movie_max_megabytes ?: "")
} else {
etMediaSizeMax.setText(
a.image_max_megabytes
?: a.getImageMaxBytes(ti).div(1000000).toString()
)
etMovieSizeMax.setText(
a.movie_max_megabytes
?: a.getMovieMaxBytes(ti).div(1000000).toString()
)
}
val currentResizeConfig = a.getResizeConfig()
var index =
imageResizeItems.indexOfFirst { it.config.spec == currentResizeConfig.spec }
log.d("ResizeItem current ${currentResizeConfig.spec} index=$index ")
if (index == -1) index =
imageResizeItems.indexOfFirst { it.config.spec == SavedAccount.defaultResizeConfig.spec }
spResizeImage.setSelection(index, false)
val currentPushPolicy = a.push_policy
index = pushPolicyItems.indexOfFirst { it.id == currentPushPolicy }
if (index == -1) index = 0
spPushPolicy.setSelection(index, false)
spMovieTranscodeMode.setSelection(max(0, a.movieTranscodeMode), false)
etMovieFrameRate.setText(a.movieTranscodeFramerate)
etMovieBitrate.setText(a.movieTranscodeBitrate)
etMovieSquarePixels.setText(a.movieTranscodeSquarePixels)
// アカウントからUIへのデータロードはここまで
loadingBusy = false
val enabled = !a.isPseudo
@ -497,34 +541,6 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
btnNotificationStyleEdit,
btnNotificationStyleEditReply,
).forEach { it.isEnabledAlpha = enabledNewNotification }
val ti = TootInstance.getCached(a)
if (ti == null) {
etMediaSizeMax.setText(a.image_max_megabytes ?: "")
etMovieSizeMax.setText(a.movie_max_megabytes ?: "")
} else {
etMediaSizeMax.setText(
a.image_max_megabytes
?: a.getImageMaxBytes(ti).div(1000000).toString()
)
etMovieSizeMax.setText(
a.movie_max_megabytes
?: a.getMovieMaxBytes(ti).div(1000000).toString()
)
}
val currentResizeConfig = a.getResizeConfig()
var index =
imageResizeItems.indexOfFirst { it.config.spec == currentResizeConfig.spec }
log.d("ResizeItem current ${currentResizeConfig.spec} index=$index ")
if (index == -1) index =
imageResizeItems.indexOfFirst { it.config.spec == SavedAccount.defaultResizeConfig.spec }
spResizeImage.setSelection(index, false)
val currentPushPolicy = a.push_policy
index = pushPolicyItems.indexOfFirst { it.id == currentPushPolicy }
if (index == -1) index = 0
spPushPolicy.setSelection(index, false)
}
showVisibility()
@ -595,8 +611,12 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
account.push_policy =
pushPolicyItems.elementAtOrNull(spPushPolicy.selectedItemPosition)?.id
}
account.movieTranscodeMode = spMovieTranscodeMode.selectedItemPosition
account.movieTranscodeBitrate = etMovieBitrate.text.toString()
account.movieTranscodeFramerate = etMovieFrameRate.text.toString()
account.movieTranscodeSquarePixels = etMovieSquarePixels.text.toString()
}
account.saveSetting()
}
@ -1439,7 +1459,7 @@ class ActAccountSetting : AppCompatActivity(), View.OnClickListener,
val bitmap = createResizedBitmap(this, uriArg, resizeTo)
if (bitmap != null) {
try {
val cacheDir = externalCacheDir?.apply{ mkdirs() }
val cacheDir = externalCacheDir?.apply { mkdirs() }
if (cacheDir == null) {
showToast(false, "getExternalCacheDir returns null.")
break

View File

@ -18,6 +18,7 @@ import jp.juggler.subwaytooter.action.saveWindowSize
import jp.juggler.subwaytooter.actpost.*
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.databinding.ActPostBinding
import jp.juggler.subwaytooter.dialog.*
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.span.*
@ -27,7 +28,11 @@ import jp.juggler.subwaytooter.view.MyEditText
import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import java.lang.ref.WeakReference
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
class ActPost : AppCompatActivity(),
@ -88,43 +93,11 @@ class ActPost : AppCompatActivity(),
}
}
lateinit var btnAccount: Button
lateinit var btnVisibility: ImageButton
private lateinit var btnAttachment: ImageButton
private lateinit var btnPost: ImageButton
lateinit var llAttachment: View
val views by lazy { ActPostBinding.inflate(layoutInflater) }
lateinit var ivMedia: List<MyNetworkImageView>
lateinit var cbNSFW: CheckBox
lateinit var cbContentWarning: CheckBox
lateinit var etContentWarning: MyEditText
lateinit var etContent: MyEditText
lateinit var cbQuote: CheckBox
lateinit var spPollType: Spinner
lateinit var llEnquete: View
lateinit var etChoices: List<MyEditText>
lateinit var cbMultipleChoice: CheckBox
lateinit var cbHideTotals: CheckBox
lateinit var llExpire: LinearLayout
lateinit var etExpireDays: EditText
lateinit var etExpireHours: EditText
lateinit var etExpireMinutes: EditText
lateinit var tvCharCount: TextView
lateinit var handler: Handler
private lateinit var formRoot: ActPostRootLinearLayout
lateinit var llReply: View
lateinit var tvReplyTo: TextView
lateinit var ivReply: MyNetworkImageView
lateinit var scrollView: ScrollView
lateinit var tvSchedule: TextView
private lateinit var ibSchedule: ImageButton
private lateinit var ibScheduleReset: ImageButton
lateinit var pref: SharedPreferences
lateinit var appState: AppState
lateinit var attachmentUploader: AttachmentUploader
@ -133,6 +106,8 @@ class ActPost : AppCompatActivity(),
var density: Float = 0f
private lateinit var progressChannel : Channel<Unit>
///////////////////////////////////////////////////
// SavedAccount.acctAscii => FeaturedTagCache
@ -165,8 +140,8 @@ class ActPost : AppCompatActivity(),
ar.data?.getStringExtra("replace_key")
?.let { text ->
when (states.mushroomInput) {
0 -> applyMushroomText(etContent, text)
1 -> applyMushroomText(etContentWarning, text)
0 -> applyMushroomText(views.etContent, text)
1 -> applyMushroomText(views.etContentWarning, text)
else -> for (i in 0..3) {
if (states.mushroomInput == i + 2) {
applyMushroomText(etChoices[i], text)
@ -191,6 +166,22 @@ class ActPost : AppCompatActivity(),
density = resources.displayMetrics.density
arMushroom.register(this, log)
progressChannel = Channel(capacity = Channel.CONFLATED )
launchMain {
try {
while (true) {
progressChannel.receive()
showMedisAttachmentProgress()
delay(1000L)
}
}catch(ex:Throwable){
when(ex){
is CancellationException, is ClosedReceiveChannelException -> Unit
else -> log.trace(ex)
}
}
}
initUI()
when (savedInstanceState) {
@ -200,6 +191,11 @@ class ActPost : AppCompatActivity(),
}
override fun onDestroy() {
try {
progressChannel.close()
}catch(ex:Throwable){
log.e(ex)
}
completionHelper.onDestroy()
attachmentUploader.onActivityDestroy()
super.onDestroy()
@ -269,7 +265,7 @@ class ActPost : AppCompatActivity(),
return when {
super.onKeyShortcut(keyCode, event) -> true
event?.isCtrlPressed == true && keyCode == KeyEvent.KEYCODE_T -> {
btnPost.performClick()
views.btnPost.performClick()
true
}
else -> false
@ -283,7 +279,7 @@ class ActPost : AppCompatActivity(),
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
grantResults: IntArray,
) {
attachmentPicker.onRequestPermissionsResult(requestCode, permissions, grantResults)
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
@ -293,6 +289,10 @@ class ActPost : AppCompatActivity(),
addAttachment(uri, mimeType)
}
override fun onPostAttachmentProgress() {
launchIO{ progressChannel.send(Unit) }
}
override fun onPostAttachmentComplete(pa: PostAttachment) {
onPostAttachmentCompleteImpl(pa)
}
@ -302,7 +302,7 @@ class ActPost : AppCompatActivity(),
}
fun initUI() {
setContentView(R.layout.act_post)
setContentView(views.root)
App1.initEdgeToEdge(this)
if (PrefB.bpPostButtonBarTop(pref)) {
@ -317,31 +317,19 @@ class ActPost : AppCompatActivity(),
Styler.fixHorizontalMargin(findViewById(R.id.llFooterBar))
}
formRoot = findViewById(R.id.viewRoot)
scrollView = findViewById(R.id.scrollView)
btnAccount = findViewById(R.id.btnAccount)
btnVisibility = findViewById(R.id.btnVisibility)
btnAttachment = findViewById(R.id.btnAttachment)
btnPost = findViewById(R.id.btnPost)
llAttachment = findViewById(R.id.llAttachment)
cbNSFW = findViewById(R.id.cbNSFW)
cbContentWarning = findViewById(R.id.cbContentWarning)
etContentWarning = findViewById(R.id.etContentWarning)
etContent = findViewById(R.id.etContent)
formRoot.callbackOnSizeChanged = { _, _, _, _ ->
views.root.callbackOnSizeChanged = { _, _, _, _ ->
if (Build.VERSION.SDK_INT >= 24 && isMultiWindowPost) saveWindowSize()
// ビューのw,hはシステムバーその他を含まないので使わない
}
// https://github.com/tateisu/SubwayTooter/issues/123
// 早い段階で指定する必要がある
etContent.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
etContent.imeOptions = EditorInfo.IME_ACTION_NONE
views.etContent.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
views.etContent.imeOptions = EditorInfo.IME_ACTION_NONE
cbQuote = findViewById(R.id.cbQuote)
spPollType = findViewById<Spinner>(R.id.spEnquete).apply {
views.spPollType.apply {
this.adapter = ArrayAdapter(
this@ActPost,
android.R.layout.simple_spinner_item,
@ -364,7 +352,7 @@ class ActPost : AppCompatActivity(),
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
id: Long,
) {
showPoll()
updateTextCount()
@ -372,58 +360,44 @@ class ActPost : AppCompatActivity(),
}
}
llEnquete = findViewById(R.id.llEnquete)
llExpire = findViewById(R.id.llExpire)
cbHideTotals = findViewById(R.id.cbHideTotals)
cbMultipleChoice = findViewById(R.id.cbMultipleChoice)
etExpireDays = findViewById(R.id.etExpireDays)
etExpireHours = findViewById(R.id.etExpireHours)
etExpireMinutes = findViewById(R.id.etExpireMinutes)
ivMedia = listOf(
findViewById(R.id.ivMedia1),
findViewById(R.id.ivMedia2),
findViewById(R.id.ivMedia3),
findViewById(R.id.ivMedia4)
views.ivMedia1,
views.ivMedia2,
views.ivMedia3,
views.ivMedia4,
)
etChoices = listOf(
findViewById(R.id.etChoice1),
findViewById(R.id.etChoice2),
findViewById(R.id.etChoice3),
findViewById(R.id.etChoice4)
views.etChoice1,
views.etChoice2,
views.etChoice3,
views.etChoice4,
)
tvCharCount = findViewById(R.id.tvCharCount)
llReply = findViewById(R.id.llReply)
tvReplyTo = findViewById(R.id.tvReplyTo)
ivReply = findViewById(R.id.ivReply)
tvSchedule = findViewById(R.id.tvSchedule)
ibSchedule = findViewById(R.id.ibSchedule)
ibScheduleReset = findViewById(R.id.ibScheduleReset)
arrayOf(
ibSchedule,
ibScheduleReset,
btnAccount,
btnVisibility,
btnAttachment,
btnPost,
findViewById(R.id.btnRemoveReply),
findViewById(R.id.btnFeaturedTag),
findViewById(R.id.btnPlugin),
findViewById(R.id.btnEmojiPicker),
findViewById(R.id.btnMore),
views.ibSchedule,
views.ibScheduleReset,
views.btnAccount,
views.btnVisibility,
views.btnAttachment,
views.btnPost,
views.btnRemoveReply,
views.btnFeaturedTag,
views.btnPlugin,
views.btnEmojiPicker,
views.btnMore,
).forEach { it.setOnClickListener(this) }
ivMedia.forEach { it.setOnClickListener(this) }
cbContentWarning.setOnCheckedChangeListener { _, _ -> showContentWarningEnabled() }
views.cbContentWarning.setOnCheckedChangeListener { _, _ -> showContentWarningEnabled() }
completionHelper = CompletionHelper(this, pref, appState.handler)
completionHelper.attachEditText(
formRoot,
etContent,
views.root,
views.etContent,
false,
object : CompletionHelper.Callback2 {
override fun onTextUpdate() {
@ -441,7 +415,7 @@ class ActPost : AppCompatActivity(),
}
}
etContentWarning.addTextChangedListener(textWatcher)
views.etContentWarning.addTextChangedListener(textWatcher)
for (et in etChoices) {
et.addTextChangedListener(textWatcher)
@ -450,9 +424,9 @@ class ActPost : AppCompatActivity(),
val scrollListener: ViewTreeObserver.OnScrollChangedListener =
ViewTreeObserver.OnScrollChangedListener { completionHelper.onScrollChanged() }
scrollView.viewTreeObserver.addOnScrollChangedListener(scrollListener)
views.scrollView.viewTreeObserver.addOnScrollChangedListener(scrollListener)
etContent.contentMineTypeArray = AttachmentUploader.acceptableMimeTypes.toTypedArray()
etContent.contentCallback = { addAttachment(it) }
views.etContent.contentMineTypeArray = AttachmentUploader.acceptableMimeTypes.toTypedArray()
views.etContent.contentCallback = { addAttachment(it) }
}
}

View File

@ -18,9 +18,9 @@ fun ActPost.selectAccount(a: SavedAccount?) {
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)
views.btnAccount.text = getString(R.string.not_selected)
views.btnAccount.setTextColor(attrColor(android.R.attr.textColorPrimary))
views.btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
} else {
// 先読みしてキャッシュに保持しておく
@ -29,16 +29,16 @@ fun ActPost.selectAccount(a: SavedAccount?) {
}
val ac = AcctColor.load(a)
btnAccount.text = ac.nickname
views.btnAccount.text = ac.nickname
if (AcctColor.hasColorBackground(ac)) {
btnAccount.background =
views.btnAccount.background =
getAdaptiveRippleDrawableRound(this, ac.color_bg, ac.color_fg)
} else {
btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
views.btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
}
btnAccount.textColor = ac.color_fg.notZero()
views.btnAccount.textColor = ac.color_fg.notZero()
?: attrColor(android.R.attr.textColorPrimary)
}
updateTextCount()

View File

@ -44,10 +44,19 @@ fun ActPost.decodeAttachments(sv: String) {
fun ActPost.showMediaAttachment() {
if (isFinishing) return
llAttachment.vg(attachmentList.isNotEmpty())
views.llAttachment.vg(attachmentList.isNotEmpty())
ivMedia.forEachIndexed { i, v -> showMediaAttachmentOne(v, i) }
}
fun ActPost.showMedisAttachmentProgress() {
val mergedProgress = attachmentList
.mapNotNull { it.progress.notEmpty() }
.joinToString("\n")
views.tvAttachmentProgress
.vg(mergedProgress.isNotEmpty())
?.text = mergedProgress
}
fun ActPost.showMediaAttachmentOne(iv: MyNetworkImageView, idx: Int) {
if (idx >= attachmentList.size) {
iv.visibility = View.GONE
@ -118,7 +127,7 @@ fun ActPost.addAttachment(
uri,
mimeType,
isReply = isReply,
// onUploadEnd = onUploadEnd
// onUploadEnd = onUploadEnd
)
)
}
@ -153,9 +162,9 @@ fun ActPost.onPostAttachmentCompleteImpl(pa: PostAttachment) {
// 投稿欄の末尾に追記する
if (PrefB.bpAppendAttachmentUrlToContent(pref)) {
val selStart = etContent.selectionStart
val selEnd = etContent.selectionEnd
val e = etContent.editableText
val selStart = views.etContent.selectionStart
val selEnd = views.etContent.selectionEnd
val e = views.etContent.editableText
val len = e.length
val lastChar = if (len <= 0) ' ' else e[len - 1]
if (!CharacterGroup.isWhitespace(lastChar.code)) {
@ -163,7 +172,7 @@ fun ActPost.onPostAttachmentCompleteImpl(pa: PostAttachment) {
} else {
e.append(a.text_url)
}
etContent.setSelection(selStart, selEnd)
views.etContent.setSelection(selStart, selEnd)
}
}
}
@ -215,6 +224,9 @@ fun ActPost.deleteAttachment(pa: PostAttachment) {
.setTitle(R.string.confirm_delete_attachment)
.setPositiveButton(R.string.ok) { _, _ ->
try {
pa.isCancelled = true
pa.status= PostAttachment.Status.Error
pa.job.cancel()
attachmentList.remove(pa)
} catch (ignored: Throwable) {
}

View File

@ -57,12 +57,12 @@ fun ActPost.updateTextCount() {
var length = 0
length += TootAccount.countText(
EmojiDecoder.decodeShortCode(etContent.text.toString())
EmojiDecoder.decodeShortCode(views.etContent.text.toString())
)
if (cbContentWarning.isChecked) {
if (views.cbContentWarning.isChecked) {
length += TootAccount.countText(
EmojiDecoder.decodeShortCode(etContentWarning.text.toString())
EmojiDecoder.decodeShortCode(views.etContentWarning.text.toString())
)
}
@ -76,7 +76,7 @@ fun ActPost.updateTextCount() {
}
}
when (spPollType.selectedItemPosition) {
when (views.spPollType.selectedItemPosition) {
1 -> checkEnqueteLength()
2 -> {
@ -87,8 +87,8 @@ fun ActPost.updateTextCount() {
val remain = max - length
tvCharCount.text = remain.toString()
tvCharCount.setTextColor(
views.tvCharCount.text = remain.toString()
views.tvCharCount.setTextColor(
attrColor(
when {
remain < 0 -> R.attr.colorRegexFilterError

View File

@ -71,11 +71,11 @@ private suspend fun checkExist(url: String?): Boolean {
}
fun ActPost.saveDraft() {
val content = etContent.text.toString()
val content = views.etContent.text.toString()
val contentWarning =
if (cbContentWarning.isChecked) etContentWarning.text.toString() else ""
if (views.cbContentWarning.isChecked) views.etContentWarning.text.toString() else ""
val isEnquete = spPollType.selectedItemPosition > 0
val isEnquete = views.spPollType.selectedItemPosition > 0
val strChoice = arrayOf(
if (isEnquete) etChoices[0].text.toString() else "",
@ -104,20 +104,20 @@ fun ActPost.saveDraft() {
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
json[DRAFT_CONTENT_WARNING_CHECK] = views.cbContentWarning.isChecked
json[DRAFT_NSFW_CHECK] = views.cbNSFW.isChecked
json[DRAFT_ACCOUNT_DB_ID] = account?.db_id ?: -1L
json[DRAFT_ATTACHMENT_LIST] = tmpAttachmentList
json[DRAFT_REPLY_TEXT] = states.inReplyToText
json[DRAFT_REPLY_IMAGE] = states.inReplyToImage
json[DRAFT_REPLY_URL] = states.inReplyToUrl
json[DRAFT_QUOTE] = cbQuote.isChecked
json[DRAFT_POLL_TYPE] = spPollType.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_QUOTE] = views.cbQuote.isChecked
json[DRAFT_POLL_TYPE] = views.spPollType.selectedItemPosition.toPollTypeString()
json[DRAFT_POLL_MULTIPLE] = views.cbMultipleChoice.isChecked
json[DRAFT_POLL_HIDE_TOTALS] = views.cbHideTotals.isChecked
json[DRAFT_POLL_EXPIRE_DAY] = views.etExpireDays.text.toString()
json[DRAFT_POLL_EXPIRE_HOUR] = views.etExpireHours.text.toString()
json[DRAFT_POLL_EXPIRE_MINUTE] = views.etExpireMinutes.text.toString()
json[DRAFT_ENQUETE_ITEMS] = strChoice.toJsonArray()
states.visibility?.id?.toString()?.let { json.put(DRAFT_VISIBILITY, it) }
@ -242,30 +242,30 @@ fun ActPost.restoreDraft(draft: JsonObject) {
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
views.etContent.setText(evEmoji)
views.etContent.setSelection(evEmoji.length)
views.etContentWarning.setText(contentWarning)
views.etContentWarning.setSelection(contentWarning.length)
views.cbContentWarning.isChecked = contentWarningChecked
views.cbNSFW.isChecked = nsfwChecked
if (draftVisibility != null) states.visibility = draftVisibility
cbQuote.isChecked = draft.optBoolean(DRAFT_QUOTE)
views.cbQuote.isChecked = draft.optBoolean(DRAFT_QUOTE)
val sv = draft.string(DRAFT_POLL_TYPE)
if (sv != null) {
spPollType.setSelection(sv.toPollTypeIndex())
views.spPollType.setSelection(sv.toPollTypeIndex())
} else {
// old draft
val bv = draft.optBoolean(DRAFT_IS_ENQUETE, false)
spPollType.setSelection(if (bv) 2 else 0)
views.spPollType.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, ""))
views.cbMultipleChoice.isChecked = draft.optBoolean(DRAFT_POLL_MULTIPLE)
views.cbHideTotals.isChecked = draft.optBoolean(DRAFT_POLL_HIDE_TOTALS)
views.etExpireDays.setText(draft.optString(DRAFT_POLL_EXPIRE_DAY, "1"))
views.etExpireHours.setText(draft.optString(DRAFT_POLL_EXPIRE_HOUR, ""))
views.etExpireMinutes.setText(draft.optString(DRAFT_POLL_EXPIRE_MINUTE, ""))
val array = draft.jsonArray(DRAFT_ENQUETE_ITEMS)
if (array != null) {
@ -350,7 +350,7 @@ fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String)
}
}
cbNSFW.isChecked = baseStatus.sensitive == true
views.cbNSFW.isChecked = baseStatus.sensitive == true
// 再編集の場合はdefault_textは反映されない
@ -366,13 +366,13 @@ fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String)
} else {
decodeOptions.decodeHTML(baseStatus.content)
}
etContent.setText(text)
etContent.setSelection(text.length)
views.etContent.setText(text)
views.etContent.setSelection(text.length)
text = decodeOptions.decodeEmoji(baseStatus.spoiler_text)
etContentWarning.setText(text)
etContentWarning.setSelection(text.length)
cbContentWarning.isChecked = text.isNotEmpty()
views.etContentWarning.setText(text)
views.etContentWarning.setSelection(text.length)
views.cbContentWarning.isChecked = text.isNotEmpty()
val srcEnquete = baseStatus.enquete
val srcItems = srcEnquete?.items
@ -387,7 +387,7 @@ fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String)
}
else -> {
spPollType.setSelection(
views.spPollType.setSelection(
if (srcEnquete.pollType == TootPollsType.FriendsNico) {
2
} else {
@ -395,8 +395,8 @@ fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String)
}
)
text = decodeOptions.decodeHTML(srcEnquete.question)
etContent.text = text
etContent.setSelection(text.length)
views.etContent.text = text
views.etContent.setSelection(text.length)
var srcIndex = 0
for (et in etChoices) {

View File

@ -2,7 +2,6 @@ package jp.juggler.subwaytooter.actpost
import android.content.Intent
import android.net.Uri
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.*
@ -29,19 +28,19 @@ fun ActPost.appendContentText(
).decodeEmoji(src)
if (svEmoji.isEmpty()) return
val editable = etContent.text
val editable = views.etContent.text
if (editable == null) {
val sb = StringBuilder()
if (selectBefore) {
val start = 0
sb.append(' ')
sb.append(svEmoji)
etContent.setText(sb)
etContent.setSelection(start)
views.etContent.setText(sb)
views.etContent.setSelection(start)
} else {
sb.append(svEmoji)
etContent.setText(sb)
etContent.setSelection(sb.length)
views.etContent.setText(sb)
views.etContent.setSelection(sb.length)
}
} else {
if (editable.isNotEmpty() &&
@ -54,12 +53,12 @@ fun ActPost.appendContentText(
val start = editable.length
editable.append(' ')
editable.append(svEmoji)
etContent.text = editable
etContent.setSelection(start)
views.etContent.text = editable
views.etContent.setSelection(start)
} else {
editable.append(svEmoji)
etContent.text = editable
etContent.setSelection(editable.length)
views.etContent.text = editable
views.etContent.setSelection(editable.length)
}
}
}
@ -80,9 +79,9 @@ fun ActPost.appendContentText(src: Intent) {
// returns true if has content
fun ActPost.hasContent(): Boolean {
val content = etContent.text.toString()
val content = views.etContent.text.toString()
val contentWarning =
if (cbContentWarning.isChecked) etContentWarning.text.toString() else ""
if (views.cbContentWarning.isChecked) views.etContentWarning.text.toString() else ""
return when {
content.isNotBlank() -> true
@ -103,9 +102,9 @@ fun ActPost.resetText() {
attachmentPicker.reset()
scheduledStatus = null
attachmentList.clear()
cbQuote.isChecked = false
etContent.setText("")
spPollType.setSelection(0, false)
views.cbQuote.isChecked = false
views.etContent.setText("")
views.spPollType.setSelection(0, false)
etChoices.forEach { it.setText("") }
accountList = SavedAccount.loadAccountList(this)
SavedAccount.sort(accountList)
@ -161,7 +160,7 @@ fun ActPost.updateText(
resetText()
// Android 9 から、明示的にフォーカスを当てる必要がある
etContent.requestFocus()
views.etContent.requestFocus()
this.attachmentList.clear()
saveAttachmentList()
@ -188,7 +187,7 @@ fun ActPost.updateText(
}
appendContentText(account?.default_text, selectBefore = true)
cbNSFW.isChecked = account?.default_sensitive ?: false
views.cbNSFW.isChecked = account?.default_sensitive ?: false
if (account != null) {
// 再編集
@ -261,13 +260,13 @@ fun ActPost.performMore() {
}
dialog.addAction(getString(R.string.clear_text)) {
etContent.setText("")
etContentWarning.setText("")
views.etContent.setText("")
views.etContentWarning.setText("")
}
dialog.addAction(getString(R.string.clear_text_and_media)) {
etContent.setText("")
etContentWarning.setText("")
views.etContent.setText("")
views.etContentWarning.setText("")
attachmentList.clear()
showMediaAttachment()
}
@ -296,13 +295,13 @@ fun ActPost.performPost() {
var pollExpireSeconds = 0
var pollHideTotals = false
var pollMultipleChoice = false
when (spPollType.selectedItemPosition) {
when (views.spPollType.selectedItemPosition) {
1 -> {
pollType = TootPollsType.Mastodon
pollItems = pollChoiceList()
pollExpireSeconds = pollExpireSeconds()
pollHideTotals = cbHideTotals.isChecked
pollMultipleChoice = cbMultipleChoice.isChecked
pollHideTotals = views.cbHideTotals.isChecked
pollMultipleChoice = views.cbMultipleChoice.isChecked
}
2 -> {
pollType = TootPollsType.FriendsNico
@ -313,13 +312,13 @@ fun ActPost.performPost() {
PostImpl(
activity = this,
account = account,
content = etContent.text.toString().trim { it <= ' ' },
content = views.etContent.text.toString().trim { it <= ' ' },
spoilerText = when {
!cbContentWarning.isChecked -> null
else -> etContentWarning.text.toString().trim { it <= ' ' }
!views.cbContentWarning.isChecked -> null
else -> views.etContentWarning.text.toString().trim { it <= ' ' }
},
visibilityArg = states.visibility ?: TootVisibility.Public,
bNSFW = cbNSFW.isChecked,
bNSFW = views.cbNSFW.isChecked,
inReplyToId = states.inReplyToId,
attachmentListArg = this.attachmentList,
enqueteItemsArg = pollItems,
@ -331,7 +330,7 @@ fun ActPost.performPost() {
scheduledId = scheduledStatus?.id,
redraftStatusId = states.redraftStatusId,
emojiMapCustom = App1.custom_emoji_lister.getMap(account),
useQuoteToot = cbQuote.isChecked,
useQuoteToot = views.cbQuote.isChecked,
callback = object : PostCompleteCallback {
override fun onPostComplete(targetAccount: SavedAccount, status: TootStatus) {
val data = Intent()
@ -374,5 +373,5 @@ fun ActPost.performPost() {
}
fun ActPost.showContentWarningEnabled() {
etContentWarning.visibility = if (cbContentWarning.isChecked) View.VISIBLE else View.GONE
views.etContentWarning.vg(views.cbContentWarning.isChecked)
}

View File

@ -63,14 +63,14 @@ fun ActPost.openMushroom() {
try {
var text: String? = null
when {
etContentWarning.hasFocus() -> {
views.etContentWarning.hasFocus() -> {
states.mushroomInput = 1
text = prepareMushroomText(etContentWarning)
text = prepareMushroomText(views.etContentWarning)
}
etContent.hasFocus() -> {
views.etContent.hasFocus() -> {
states.mushroomInput = 0
text = prepareMushroomText(etContent)
text = prepareMushroomText(views.etContent)
}
else -> for (i in 0..3) {
@ -82,7 +82,7 @@ fun ActPost.openMushroom() {
}
if (text == null) {
states.mushroomInput = 0
text = prepareMushroomText(etContent)
text = prepareMushroomText(views.etContent)
}
val intent = Intent("com.adamrocker.android.simeji.ACTION_INTERCEPT")

View File

@ -7,16 +7,16 @@ import jp.juggler.util.vg
private fun Double?.finiteOrZero(): Double = if (this?.isFinite() == true) this else 0.0
fun ActPost.showPoll() {
val i = spPollType.selectedItemPosition
llEnquete.vg(i != 0)
llExpire.vg(i == 1)
cbHideTotals.vg(i == 1)
cbMultipleChoice.vg(i == 1)
val i = views.spPollType.selectedItemPosition
views.llEnquete.vg(i != 0)
views.llExpire.vg(i == 1)
views.cbHideTotals.vg(i == 1)
views.cbMultipleChoice.vg(i == 1)
}
// 投票が有効で何か入力済みなら真
fun ActPost.hasPoll(): Boolean {
if (spPollType.selectedItemPosition <= 0) return false
if (views.spPollType.selectedItemPosition <= 0) return false
return etChoices.any { it.text.toString().isNotBlank() }
}
@ -27,8 +27,8 @@ fun ActPost.pollChoiceList() = ArrayList<String>().apply {
}
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()
val d = views.etExpireDays.text.toString().trim().toDoubleOrNull().finiteOrZero()
val h = views.etExpireHours.text.toString().trim().toDoubleOrNull().finiteOrZero()
val m = views.etExpireMinutes.text.toString().trim().toDoubleOrNull().finiteOrZero()
return (d * 86400.0 + h * 3600.0 + m * 60.0).toInt()
}

View File

@ -1,6 +1,5 @@
package jp.juggler.subwaytooter.actpost
import android.view.View
import androidx.appcompat.app.AlertDialog
import jp.juggler.subwaytooter.ActPost
import jp.juggler.subwaytooter.R
@ -14,10 +13,7 @@ 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.LogCategory
import jp.juggler.util.decodeJsonObject
import jp.juggler.util.launchMain
import jp.juggler.util.showToast
import jp.juggler.util.*
private val log = LogCategory("ActPostReply")
@ -29,22 +25,20 @@ fun ActPost.resetReply() {
}
fun ActPost.showQuotedRenote() {
cbQuote.visibility = if (states.inReplyToId != null) View.VISIBLE else View.GONE
views.cbQuote.vg(states.inReplyToId != null)
}
fun ActPost.showReplyTo() {
if (states.inReplyToId == null) {
llReply.visibility = View.GONE
} else {
llReply.visibility = View.VISIBLE
tvReplyTo.text = DecodeOptions(
views.llReply.vg(states.inReplyToId != null)?.let {
views.tvReplyTo.text = DecodeOptions(
this,
linkHelper = account,
short = true,
decodeEmoji = true,
mentionDefaultHostDomain = account ?: unknownHostAndDomain
).decodeHTML(states.inReplyToText)
ivReply.setImageUrl(Styler.calcIconRound(ivReply.layoutParams), states.inReplyToImage)
views.ivReply.setImageUrl(Styler.calcIconRound(views.ivReply.layoutParams),
states.inReplyToImage)
}
}
@ -65,15 +59,15 @@ fun ActPost.initializeFromReplyStatus(account: SavedAccount, jsonText: String) {
val isQuote = intent.getBooleanExtra(ActPost.KEY_QUOTE, false)
if (isQuote) {
cbQuote.isChecked = true
views.cbQuote.isChecked = true
// 引用リートはCWやメンションを引き継がない
} else {
// CW をリプライ元に合わせる
if (replyStatus.spoiler_text.isNotEmpty()) {
cbContentWarning.isChecked = true
etContentWarning.setText(replyStatus.spoiler_text)
views.cbContentWarning.isChecked = true
views.etContentWarning.setText(replyStatus.spoiler_text)
}
// 新しいメンションリスト

View File

@ -18,7 +18,7 @@ import jp.juggler.util.notEmpty
private val log = LogCategory("ActPostSchedule")
fun ActPost.showSchedule() {
tvSchedule.text = when (states.timeSchedule) {
views.tvSchedule.text = when (states.timeSchedule) {
0L -> getString(R.string.unspecified)
else -> TootStatus.formatTime(this, states.timeSchedule, true)
}
@ -49,13 +49,13 @@ fun ActPost.initializeFromScheduledStatus(account: SavedAccount, jsonText: Strin
states.timeSchedule = item.timeScheduledAt
states.visibility = item.visibility
cbNSFW.isChecked = item.sensitive
views.cbNSFW.isChecked = item.sensitive
etContent.setText(item.text)
views.etContent.setText(item.text)
val cw = item.spoilerText
etContentWarning.setText(cw ?: "")
cbContentWarning.isChecked = cw?.isNotEmpty() == true
views.etContentWarning.setText(cw ?: "")
views.cbContentWarning.isChecked = cw?.isNotEmpty() == true
// 2019/1/7 どうも添付データを古い投稿から引き継げないようだ…。
// 2019/1/22 https://github.com/tootsuite/mastodon/pull/9894 で直った。

View File

@ -9,8 +9,9 @@ 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)
val iconId = Styler.getVisibilityIconId(account?.isMisskey == true,
states.visibility ?: TootVisibility.Public)
views.btnVisibility.setImageResource(iconId)
}
fun ActPost.openVisibilityPicker() {

View File

@ -59,8 +59,9 @@ import jp.juggler.util.LogCategory
// 2021/5/11 59=>60 SavedAccountテーブルに項目追加
// 2021/5/23 60=>61 SavedAccountテーブルに項目追加
// 2021/11/21 61=>62 SavedAccountテーブルに項目追加
// 2022/1/5 62=>63 SavedAccountテーブルに項目追加
const val DB_VERSION = 62
const val DB_VERSION = 63
const val DB_NAME = "app_db"
val TABLE_LIST = arrayOf(

View File

@ -85,6 +85,26 @@ class SavedAccount(
var push_policy: String? = null
private val extraJson = JsonObject()
var movieTranscodeMode: Int by JsonProperty(
extraJson,
"movieTranscodeMode",
0)
var movieTranscodeBitrate: String by JsonProperty(
extraJson,
"movieTranscodeBitrate",
"2000000")
var movieTranscodeFramerate: String by JsonProperty(
extraJson,
"movieTranscodeFramerate",
"30")
var movieTranscodeSquarePixels: String by JsonProperty(
extraJson,
"movieTranscodeSquarePixels",
"2304000")
init {
val tmpAcct = Acct.parse(acctArg)
this.username = tmpAcct.username
@ -173,6 +193,14 @@ class SavedAccount(
image_max_megabytes = cursor.getStringOrNull(COL_IMAGE_MAX_MEGABYTES)
movie_max_megabytes = cursor.getStringOrNull(COL_MOVIE_MAX_MEGABYTES)
push_policy = cursor.getStringOrNull(COL_PUSH_POLICY)
try {
cursor.getStringOrNull(COL_EXTRA_JSON)
?.decodeJsonObject()
?.entries
?.forEach { extraJson[it.key] = it.value }
} catch (ex: Throwable) {
log.trace(ex)
}
}
val isNA: Boolean
@ -242,6 +270,7 @@ class SavedAccount(
put(COL_IMAGE_MAX_MEGABYTES, image_max_megabytes)
put(COL_MOVIE_MAX_MEGABYTES, movie_max_megabytes)
put(COL_PUSH_POLICY, push_policy)
put(COL_EXTRA_JSON, extraJson.toString())
// 以下のデータはUIからは更新しない
// notification_tag
@ -310,6 +339,11 @@ class SavedAccount(
this.image_max_megabytes = b.image_max_megabytes
this.movie_max_megabytes = b.movie_max_megabytes
this.push_policy = b.push_policy
this.movieTranscodeMode = b.movieTranscodeMode
this.movieTranscodeBitrate = b.movieTranscodeBitrate
this.movieTranscodeFramerate = b.movieTranscodeFramerate
this.movieTranscodeSquarePixels = b.movieTranscodeSquarePixels
}
fun getFullAcct(who: TootAccount?) = getFullAcct(who?.acct)
@ -458,6 +492,8 @@ class SavedAccount(
private val COL_PUSH_POLICY = ColumnMeta(columnList, 60, "push_policy", "text default null")
private val COL_EXTRA_JSON = ColumnMeta(columnList, 63, "extra_json", "text default null")
/////////////////////////////////
// login information
const val INVALID_DB_ID = -1L
@ -930,4 +966,20 @@ class SavedAccount(
this.image_max_megabytes?.toIntOrNull()
?: if (ti.instanceType == InstanceType.Pixelfed) 15 else 8
)
fun getMovieResizeConfig(): MovieResizeConfig = MovieResizeConfig(
mode = when (movieTranscodeMode) {
MovieResizeConfig.MODE_NO,
MovieResizeConfig.MODE_AUTO,
MovieResizeConfig.NODE_ALWAYS,
-> movieTranscodeMode
else -> MovieResizeConfig.MODE_AUTO
},
limitBitrate = movieTranscodeBitrate.toLongOrNull()
?.takeIf { it >= 100_000L } ?: 2_000_000L,
limitFrameRate = movieTranscodeFramerate.toIntOrNull()
?.takeIf { it >= 1 } ?: 30,
limitPixelMatrix = movieTranscodeSquarePixels.toIntOrNull()
?.takeIf { it > 0 } ?: 2304000,
)
}

View File

@ -30,6 +30,7 @@ import java.io.*
import java.util.*
import java.util.concurrent.CancellationException
import kotlin.coroutines.coroutineContext
import kotlin.math.min
class AttachmentRequest(
val account: SavedAccount,
@ -37,7 +38,6 @@ class AttachmentRequest(
val uri: Uri,
val mimeType: String,
val isReply: Boolean,
val onUploadEnd: () -> Unit = {},
)
class AttachmentUploader(
@ -92,7 +92,7 @@ class AttachmentUploader(
add("video/m4v")
}
private val imageHeaderList = arrayOf(
private val imageHeaderList = listOf(
Pair(
"image/jpeg",
intArrayOf(0xff, 0xd8, 0xff).toByteArray()
@ -103,21 +103,29 @@ class AttachmentUploader(
),
Pair(
"image/gif",
charArrayOf('G', 'I', 'F').toLowerByteArray()
"GIF".toByteArray(Charsets.UTF_8)
),
Pair(
"audio/wav",
charArrayOf('R', 'I', 'F', 'F').toLowerByteArray()
"RIFF".toByteArray(Charsets.UTF_8),
),
Pair(
"audio/ogg",
charArrayOf('O', 'g', 'g', 'S').toLowerByteArray()
"OggS".toByteArray(Charsets.UTF_8),
),
Pair(
"audio/flac",
charArrayOf('f', 'L', 'a', 'C').toLowerByteArray()
)
)
"fLaC".toByteArray(Charsets.UTF_8),
),
Pair(
"image/bmp",
"BM".toByteArray(Charsets.UTF_8),
),
Pair(
"image/webp",
"RIFF****WEBP".toByteArray(Charsets.UTF_8),
),
).sortedByDescending { it.second.size }
private val sig3gp = arrayOf(
"3ge6",
@ -156,6 +164,25 @@ class AttachmentUploader(
}
return true
}
private const val wild = '?'.code.toByte()
private fun ByteArray.startWithWildcard(
key: ByteArray,
thisOffset: Int = 0,
keyOffset: Int = 0,
length: Int = key.size - keyOffset,
): Boolean {
if (thisOffset + length > this.size || keyOffset + length > key.size) {
return false
}
for (i in 0 until length) {
val cThis = this[i + thisOffset]
val cKey = key[i + keyOffset]
if (cKey != wild && cKey != cThis) return false
}
return true
}
}
private val context = contextArg.applicationContext!!
@ -173,12 +200,37 @@ class AttachmentUploader(
channel = it
launchIO {
while (true) {
try {
handleRequest(it.receive())
val request = try {
it.receive()
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break
else -> context.showToast(ex)
else -> {
context.showToast(ex)
continue
}
}
}
val result = try {
if (request.pa.isCancelled) continue
withContext(request.pa.job + Dispatchers.IO) {
request.upload()
}
} catch (ex: Throwable) {
TootApiResult(ex.withCaption("upload failed."))
}
try {
request.pa.progress = ""
withContext(Dispatchers.Main) {
handleResult(request, result)
}
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break
else -> {
context.showToast(ex)
continue
}
}
}
}
@ -199,6 +251,9 @@ class AttachmentUploader(
}
fun addRequest(request: AttachmentRequest) {
request.pa.progress = context.getString(R.string.attachment_handling_start)
// アップロード開始トースト(連発しない)
val now = System.currentTimeMillis()
if (now - lastAttachmentAdd >= 5000L) {
@ -212,23 +267,18 @@ class AttachmentUploader(
launchIO { prepareChannel().send(request) }
}
private suspend fun handleRequest(request: AttachmentRequest) {
val result = request.upload()
withContext(Dispatchers.Main) {
handleResult(request, result)
}
}
@WorkerThread
private suspend fun AttachmentRequest.upload(): TootApiResult? {
if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.")
try {
if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.")
val client = TootApiClient(context, callback = object : TootApiCallback {
override suspend fun isApiCancelled() = !coroutineContext.isActive
})
client.account = account
client.currentCallCallback = {}
val (ti, tiResult) = TootInstance.get(client)
ti ?: return tiResult
@ -246,18 +296,44 @@ class AttachmentUploader(
)
}
}
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
val imageResizeConfig = mediaConfig?.int("image_matrix_limit")
?.takeIf { it > 0 }
?.let { ResizeConfig(ResizeType.SquarePixel, it) }
?: account.getResizeConfig()
// 設定からリサイズ指定を読む
val resizeConfig = account.getResizeConfig()
val movieResizeConfig = account.getMovieResizeConfig()
val opener = createOpener(uri, mimeType, resizeConfig)
mediaConfig?.int("video_frame_rate_limit")
?.takeIf { it >= 1f }
?.let {
movieResizeConfig.limitFrameRate = min(movieResizeConfig.limitFrameRate, it)
}
mediaConfig?.int("video_matrix_limit")
?.takeIf { it > 1 }
?.let {
movieResizeConfig.limitPixelMatrix = min(movieResizeConfig.limitPixelMatrix, it)
}
// 入力データの変換など
val opener = createOpener(
uri,
mimeType,
imageResizeConfig = imageResizeConfig,
movieResizeConfig = movieResizeConfig,
pa,
)
val mediaSizeMax = when {
mimeType.startsWith("video") || mimeType.startsWith("audio") ->
account.getMovieMaxBytes(ti)
mediaConfig?.int("video_size_limit")
?.takeIf { it > 0 }
?: account.getMovieMaxBytes(ti)
else ->
account.getImageMaxBytes(ti)
else -> mediaConfig?.int("image_size_limit")
?.takeIf { it > 0 }
?: account.getImageMaxBytes(ti)
}
val contentLength = getStreamSize(true, opener.open())
@ -284,6 +360,20 @@ class AttachmentUploader(
}
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri))
pa.progress = context.getString(R.string.attachment_handling_uploading, 0)
var nWrite = 0
fun writeProgress(delta: Int) {
nWrite += delta
if (contentLength > 0) {
val percent = (100f * nWrite.toFloat() / contentLength.toFloat()).toInt()
if (percent < 100) {
pa.progress =
context.getString(R.string.attachment_handling_uploading, percent)
} else {
pa.progress = context.getString(R.string.attachment_handling_waiting)
}
}
}
return if (account.isMisskey) {
val multipartBuilder = MultipartBody.Builder()
@ -314,6 +404,7 @@ class AttachmentUploader(
while (true) {
val r = inData.read(tmp, 0, tmp.size)
if (r <= 0) break
writeProgress(r)
sink.write(tmp, 0, r)
}
}
@ -325,9 +416,7 @@ class AttachmentUploader(
"/api/drive/files/create",
multipartBuilder.build().toPost()
)
opener.deleteTempFile()
onUploadEnd()
val jsonObject = result?.jsonObject
if (jsonObject != null) {
@ -364,6 +453,7 @@ class AttachmentUploader(
while (true) {
val r = inData.read(tmp, 0, tmp.size)
if (r <= 0) break
writeProgress(r)
sink.write(tmp, 0, r)
}
}
@ -390,6 +480,7 @@ class AttachmentUploader(
// 202 accepted 以外はポーリングしない
code != 202 -> return result
}
pa.progress = context.getString(R.string.attachment_handling_waiting2)
// ポーリングして処理完了を待つ
val id =
@ -422,7 +513,6 @@ class AttachmentUploader(
val result = postV2()
opener.deleteTempFile()
onUploadEnd()
val jsonObject = result?.jsonObject
if (jsonObject != null) {
@ -444,10 +534,14 @@ class AttachmentUploader(
pa.status = when (pa.attachment) {
null -> {
if (result != null) {
context.showToast(
true,
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
)
when {
// キャンセルはトーストを出さない
result.error?.contains("cancel", ignoreCase = true) == true -> Unit
else -> context.showToast(
true,
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
)
}
}
PostAttachment.Status.Error
}
@ -475,77 +569,51 @@ class AttachmentUploader(
fun deleteTempFile()
}
private fun createOpener(
private suspend fun createOpener(
uri: Uri,
mimeType: String,
resizeConfig: ResizeConfig,
imageResizeConfig: ResizeConfig,
movieResizeConfig: MovieResizeConfig? = null,
postAttachment: PostAttachment? = null,
): InputStreamOpener {
while (true) {
if (mimeType == MIME_TYPE_JPEG || mimeType == MIME_TYPE_PNG) {
// 静止画(リサイズできなくてもOK)
try {
// 画像の種別
val isJpeg = MIME_TYPE_JPEG == mimeType
val isPng = MIME_TYPE_PNG == mimeType
if (!isJpeg && !isPng) {
log.d("createOpener: source is not jpeg or png")
break
}
val bitmap = createResizedBitmap(
context,
return createResizedImageOpener(
uri,
resizeConfig,
skipIfNoNeedToResizeAndRotate = true
mimeType,
imageResizeConfig,
postAttachment,
)
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) {
log.trace(ex)
context.showToast(ex, "Resizing image failed.")
log.w(ex, "createResizedImageOpener failed. fall back to original image.")
}
} else if (mimeType.startsWith("image/")) {
// 静止画(変換必須)
// 例外を投げるかもしれない
return createResizedImageOpener(
uri,
mimeType,
imageResizeConfig,
postAttachment,
forcePng = true
)
} else {
// 動画画(リサイズできなくてもOK)
try {
return createResizedMovieOpener(
uri,
mimeType,
movieResizeConfig,
postAttachment,
)
} catch (ex: Throwable) {
log.w(ex, "createResizedMovieOpener failed. fall back to original movie.")
}
break
}
return object : InputStreamOpener {
override val mimeType: String
get() = mimeType
override val mimeType = mimeType
@Throws(IOException::class)
override fun open(): InputStream {
@ -558,6 +626,101 @@ class AttachmentUploader(
}
}
private fun createResizedImageOpener(
uri: Uri,
mimeType: String,
imageResizeConfig: ResizeConfig,
postAttachment: PostAttachment? = null,
forcePng: Boolean = false,
): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val outputMimeType = if (forcePng || mimeType == MIME_TYPE_PNG) {
MIME_TYPE_PNG
} else {
MIME_TYPE_JPEG
}
val tempFile = File(cacheDir, "tmp." + Thread.currentThread().id)
val bitmap = createResizedBitmap(
context,
uri,
imageResizeConfig,
skipIfNoNeedToResizeAndRotate = !forcePng
) ?: error("createResizedBitmap returns null.")
postAttachment?.progress = context.getString(R.string.attachment_handling_compress)
try {
FileOutputStream(tempFile).use { os ->
if (outputMimeType == MIME_TYPE_PNG) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
}
}
return createTempFileOpener(outputMimeType, tempFile)
} finally {
bitmap.recycle()
}
}
private suspend fun createResizedMovieOpener(
uri: Uri,
mimeType: String,
movieResizeConfig: MovieResizeConfig? = null,
postAttachment: PostAttachment? = null,
): InputStreamOpener {
movieResizeConfig ?: error("missing movieResizeConfig.")
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val tempFile = File(cacheDir, "movie." + Thread.currentThread().id + ".tmp")
val outFile = File(cacheDir, "movie." + Thread.currentThread().id + ".mp4")
// 入力ファイルをコピーする
(context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null.")).use { inStream ->
FileOutputStream(tempFile).use { inStream.copyTo(it) }
}
var resultFile: File? = null
try {
val result = transcodeVideo(
tempFile,
outFile,
movieResizeConfig,
) {
val percent = (it * 100f).toInt()
postAttachment?.progress =
context.getString(R.string.attachment_handling_compress_ratio, percent)
}
resultFile = result
return createTempFileOpener(
when (result) {
tempFile -> mimeType
else -> "video/mp4"
},
result
)
} finally {
if (outFile != resultFile) outFile.delete()
if (tempFile != resultFile) tempFile.delete()
}
}
private fun createTempFileOpener(mimeType: String, file: File) =
object : InputStreamOpener {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}
fun getMimeType(uri: Uri, mimeTypeArg: String?): String? {
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
// クレームで消耗するのを減らすためにファイルヘッダを確認する
@ -593,7 +756,7 @@ class AttachmentUploader(
for (pair in imageHeaderList) {
val type = pair.first
val header = pair.second
if (nRead >= header.size && data.startWith(header)) return type
if (nRead >= header.size && data.startWithWildcard(header)) return type
}
// scan frame header
@ -783,7 +946,11 @@ class AttachmentUploader(
return Pair(result, resultAttachment)
}
fun isAcceptableMimeType(instance: TootInstance?, mimeType: String, isReply: Boolean): Boolean {
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)

View File

@ -1,10 +1,12 @@
package jp.juggler.subwaytooter.util
import jp.juggler.subwaytooter.api.entity.TootAttachment
import kotlinx.coroutines.Job
class PostAttachment : Comparable<PostAttachment> {
interface Callback {
fun onPostAttachmentComplete(pa: PostAttachment)
fun onPostAttachmentProgress()
}
enum class Status(val id: Int) {
@ -13,9 +15,18 @@ class PostAttachment : Comparable<PostAttachment> {
Error(3),
}
var isCancelled = false
val job = Job()
var status: Status
var attachment: TootAttachment? = null
var callback: Callback? = null
var progress =""
set(value){
if( field!=value){
field = value
callback?.onPostAttachmentProgress()
}
}
constructor(callback: Callback) {
this.status = Status.Progress

View File

@ -0,0 +1,25 @@
package jp.juggler.util
import kotlin.reflect.KProperty
class JsonProperty<ValueType>(
val src: JsonObject,
val key: String,
val defVal: ValueType,
)
operator fun JsonProperty<String>.getValue(thisRef: Any?, property: KProperty<*>): String {
return src.string(key) ?: defVal
}
operator fun JsonProperty<String>.setValue(thisRef: Any?, property: KProperty<*>, value: String) {
src[key] = value
}
operator fun JsonProperty<Int>.getValue(thisRef: Any?, property: KProperty<*>): Int {
return src.int(key) ?: defVal
}
operator fun JsonProperty<Int>.setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
src[key] = value
}

View File

@ -0,0 +1,151 @@
package jp.juggler.util
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.common.Size
import com.otaliastudios.transcoder.resize.Resizer
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import jp.juggler.util.VideoInfo.Companion.videoInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import java.io.File
import java.io.FileInputStream
import kotlin.coroutines.resumeWithException
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
private val log = LogCategory("MovieUtils")
data class MovieResizeConfig(
var mode: Int = 0,
var limitFrameRate: Int = 30,
var limitBitrate: Long = 2_000_000L,
var limitPixelMatrix: Int = 2304000,
) {
companion object {
const val MODE_AUTO = 0
const val MODE_NO = 1
const val NODE_ALWAYS = 2
}
}
class AtMostSquarePixelResizer(private val limit: Int) : Resizer {
override fun getOutputSize(inputSize: Size): Size {
val inSquarePixel = abs(inputSize.major) * abs(inputSize.minor)
if (inSquarePixel <= limit || inputSize.major <= 0 || inputSize.minor <= 0) {
return inputSize
}
val aspect = inputSize.major.toFloat() / inputSize.minor.toFloat()
return Size(
max(1, (sqrt(limit.toFloat() * aspect) + 0.5f).toInt()),
max(1, (sqrt(limit.toFloat() / aspect) + 0.5f).toInt()),
)
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun transcodeVideo(
inFile: File,
outFile: File,
resizeConfig: MovieResizeConfig,
onProgress: (Float) -> Unit,
): File = try {
withContext(Dispatchers.IO) {
when (resizeConfig.mode) {
MovieResizeConfig.MODE_NO ->
return@withContext inFile
MovieResizeConfig.MODE_AUTO -> {
val info = inFile.videoInfo
if (info.size.w * info.size.h <= resizeConfig.limitPixelMatrix &&
(info.actualBps ?: 0) <= resizeConfig.limitBitrate &&
(info.frameRatio?.toInt() ?: 0) <= resizeConfig.limitFrameRate
) {
log.i("transcodeVideo skip.")
return@withContext inFile
}
}
}
val resultFile = FileInputStream(inFile).use { inStream ->
// 進捗コールバックの発生頻度が多すぎるので間引く
val progressChannel = Channel<Float>(capacity = Channel.CONFLATED)
val progressSender = launch(Dispatchers.Main) {
try {
while (true) {
onProgress(progressChannel.receive())
delay(1000L)
}
} catch (ex: Throwable) {
when (ex) {
is ClosedReceiveChannelException -> log.i("progress closed.")
is CancellationException -> log.i("progress cancelled.")
else -> log.w(ex)
}
}
}
try {
suspendCancellableCoroutine<File> { cont ->
// https://github.com/natario1/Transcoder/pull/160
// ワークアラウンドとしてファイルではなくfdを渡す
val future = Transcoder.into(outFile.canonicalPath)
.addDataSource(inStream.fd)
.setVideoTrackStrategy(
DefaultVideoStrategy.Builder()
.addResizer(
AtMostSquarePixelResizer(resizeConfig.limitPixelMatrix)
)
.frameRate(resizeConfig.limitFrameRate)
.keyFrameInterval(10f)
.bitRate(resizeConfig.limitBitrate)
.build()
)
.setAudioTrackStrategy(
DefaultAudioStrategy.Builder()
.channels(2)
.sampleRate(44100)
.bitRate(96_000L)
.build()
)
.setListener(object : TranscoderListener {
override fun onTranscodeCanceled() {
log.w("onTranscodeCanceled")
cont.resumeWithException(CancellationException("transcode cancelled."))
}
override fun onTranscodeFailed(exception: Throwable) {
log.w("onTranscodeFailed")
cont.resumeWithException(exception)
}
override fun onTranscodeCompleted(successCode: Int) {
when (successCode) {
Transcoder.SUCCESS_TRANSCODED -> outFile
/* Transcoder.SUCCESS_NOT_NEEDED */ else -> inFile
}.let { cont.resumeWith(Result.success(it)) }
}
override fun onTranscodeProgress(progress: Double) {
val result = progressChannel.trySend(progress.toFloat())
if (!result.isSuccess) {
log.w("trySend $result")
}
}
}).transcode()
cont.invokeOnCancellation { future.cancel(true) }
}
} finally {
progressChannel.close()
progressSender.cancelAndJoin()
}
}
resultFile
}
} catch (ex: Throwable) {
log.w("delete outFile due to error.")
outFile.delete()
throw ex
}

View File

@ -0,0 +1,186 @@
package jp.juggler.util
import android.media.MediaCodecList
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.os.Build
import java.io.File
import kotlin.math.min
/**
* 動画の情報
*/
@Suppress("MemberVisibilityCanBePrivate")
class VideoInfo(
val file: File,
mmr: MediaMetadataRetriever,
) {
companion object {
private val log = LogCategory("VideoInfo")
val File.videoInfo: VideoInfo
get() = MediaMetadataRetriever().use { mmr ->
mmr.setDataSource(canonicalPath)
VideoInfo(this, mmr)
}
private fun MediaMetadataRetriever.string(key: Int) =
extractMetadata(key)
private fun MediaMetadataRetriever.int(key: Int) =
string(key)?.toIntOrNull()
private fun MediaMetadataRetriever.long(key: Int) =
string(key)?.toLongOrNull()
/**
* 調査のためコーデックを列挙して情報をログに出す
*/
fun dumpCodec() {
val mcl = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (info in mcl.codecInfos) {
try {
if (!info.isEncoder) continue
val caps = try {
info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC) ?: continue
} catch (ex: Throwable) {
continue
}
for (colorFormat in caps.colorFormats) {
log.i("${info.name} color 0x${colorFormat.toString(16)}")
// OMX.qcom.video.encoder.avc color 7fa30c04 不明
// OMX.qcom.video.encoder.avc color 7f000789 COLOR_FormatSurface
// OMX.qcom.video.encoder.avc color 7f420888 COLOR_FormatYUV420Flexible
// OMX.qcom.video.encoder.avc color 15 COLOR_Format32bitBGRA8888
}
caps.videoCapabilities.bitrateRange?.let { range ->
log.i("bitrateRange $range")
}
caps.videoCapabilities.supportedFrameRates?.let { range ->
log.i("supportedFrameRates $range")
}
if (Build.VERSION.SDK_INT >= 28) {
caps.encoderCapabilities.qualityRange?.let { range ->
log.i("qualityRange $range")
}
}
} catch (ex: Throwable) {
log.w(ex)
// type is not supported
}
}
}
}
data class Size(var w: Int, var h: Int) {
override fun toString() = "[$w,$h]"
private val aspect: Float get() = w.toFloat() / h.toFloat()
/**
* アスペクト比を維持しつつ上限に合わせた解像度を提案する
* - 拡大はしない
*/
fun scaleTo(limitLonger: Int, limitShorter: Int): Size {
val inSize = this
// ゼロ除算対策
if (inSize.w < 1 || inSize.h < 1) {
return Size(limitLonger, limitShorter)
}
val inAspect = inSize.aspect
// 入力の縦横に合わせて上限を決める
val outSize = if (inAspect >= 1f) {
Size(limitLonger, limitShorter)
} else {
Size(limitShorter, limitLonger)
}
// 縦横比を比較する
return if (inAspect >= outSize.aspect) {
// 入力のほうが横長なら横幅基準でスケーリングする
// 拡大はしない
val scale = outSize.w.toFloat() / inSize.w.toFloat()
if (scale >= 1f) inSize else outSize.apply {
h = min(h, (scale * inSize.h + 0.5f).toInt())
}
} else {
// 入力のほうが縦長なら縦幅基準でスケーリングする
// 拡大はしない
val scale = outSize.h.toFloat() / inSize.h.toFloat()
if (scale >= 1f) inSize else outSize.apply {
w = min(w, (scale * inSize.w + 0.5f).toInt())
}
}
}
}
val mimeType = mmr.string(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)
val rotation = mmr.int(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) ?: 0
val size = Size(
mmr.int(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?: 0,
mmr.int(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: 0,
)
val bitrate = mmr.int(MediaMetadataRetriever.METADATA_KEY_BITRATE)
val duration = mmr.long(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toFloat()?.div(1000)?.takeIf { it > 0.1f }
val frameCount = if (Build.VERSION.SDK_INT >= 28) {
mmr.int(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)?.takeIf { it > 0 }
} else {
null
}
val frameRatio = if (frameCount != null && duration != null) {
frameCount.toFloat().div(duration)
} else {
null
}
val audioSampleRate = if (Build.VERSION.SDK_INT >= 31) {
mmr.int(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE)?.takeIf { it > 0 }
} else {
null
}
val actualBps by lazy {
val fileSize = file.length()
// ファイルサイズを取得できないならエラー
if (fileSize <= 0L) return@lazy null
// 時間帳が短すぎるなら算出できない
if (duration == null || duration < 0.1f) return@lazy null
// bpsを計算
fileSize.toFloat().div(duration).times(8).toInt()
}
/**
* 動画のファイルサイズが十分に小さいなら真
*/
fun isSmallEnough(limitBps: Int): Boolean {
val fileSize = file.length()
// ファイルサイズを取得できないならエラー
if (fileSize <= 0L) error("too small file. ${file.canonicalPath}")
// ファイルサイズが500KB以内ならビットレートを気にしない
if (fileSize < 500_000) return true
// ファイルサイズからビットレートを計算できなかったなら再エンコード必要
val actualBps = this.actualBps ?: return false
// bpsを計算
log.i("isSmallEnough duration=$duration, bps=$actualBps/$limitBps")
return actualBps <= limitBps
}
override fun toString() =
"rotation=$rotation, size=$size, frameRatio=$frameRatio, bitrate=${actualBps ?: bitrate}, audioSampleRate=$audioSampleRate, mimeType=$mimeType, file=${file.canonicalPath}"
}

View File

@ -1,15 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/svContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="12dp"
android:paddingBottom="128dp"
android:paddingTop="12dp"
android:scrollbarStyle="outsideOverlay"
tools:ignore="TooManyViews,Autofill">
@ -565,8 +565,7 @@
style="@style/setting_row_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="40dp"
/>
android:minHeight="40dp" />
<Button
android:id="@+id/btnPushSubscription"
@ -659,6 +658,10 @@
android:labelFor="@+id/etMediaSizeMax"
android:text="@string/media_attachment_max_byte_size" />
<TextView
style="@style/setting_row_label"
android:text="@string/option_deprecated_mastodon342" />
<EditText
android:id="@+id/etMediaSizeMax"
style="@style/setting_edit_text"
@ -667,6 +670,23 @@
android:gravity="center"
android:inputType="number" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/resize_image" />
<TextView
style="@style/setting_row_label"
android:text="@string/option_deprecated_mastodon342" />
<Spinner
android:id="@+id/spResizeImage"
style="@style/setting_row_button"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<View style="@style/setting_divider" />
<TextView
@ -674,6 +694,10 @@
android:labelFor="@+id/etMovieSizeMax"
android:text="@string/media_attachment_max_byte_size_movie" />
<TextView
style="@style/setting_row_label"
android:text="@string/option_deprecated_mastodon342" />
<EditText
android:id="@+id/etMovieSizeMax"
style="@style/setting_edit_text"
@ -686,15 +710,54 @@
<TextView
style="@style/setting_row_label"
android:text="@string/resize_image" />
android:text="@string/movie_transcode" />
<TextView
style="@style/setting_row_label_indent1"
android:text="@string/movie_transcode_mode" />
<Spinner
android:id="@+id/spResizeImage"
style="@style/setting_row_button"
android:id="@+id/spMovieTranscodeMode"
style="@style/setting_spinner_indent1"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:minHeight="40dp" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label_indent1"
android:text="@string/movie_transcode_max_bitrate" />
<EditText
android:id="@+id/etMovieBitrate"
style="@style/setting_edit_text_indent1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:inputType="number" />
<TextView
style="@style/setting_row_label_indent1"
android:text="@string/movie_transcode_max_frame_rate" />
<EditText
android:id="@+id/etMovieFrameRate"
style="@style/setting_edit_text_indent1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:inputType="numberDecimal" />
<TextView
style="@style/setting_row_label_indent1"
android:text="@string/movie_transcode_max_square_pixels" />
<EditText
android:id="@+id/etMovieSquarePixels"
style="@style/setting_edit_text_indent1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:inputType="number" />
</LinearLayout>
</ScrollView>

View File

@ -2,7 +2,6 @@
<jp.juggler.subwaytooter.actpost.ActPostRootLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/viewRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
@ -113,13 +112,13 @@
android:layout_height="48dp"
android:layout_marginTop="4dp"
android:baselineAligned="false"
android:gravity="center_vertical"
android:gravity="top|start"
android:orientation="horizontal">
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia1"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
@ -127,7 +126,7 @@
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia2"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
@ -135,7 +134,7 @@
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia3"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
@ -143,10 +142,20 @@
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia4"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:id="@+id/tvAttachmentProgress"
android:visibility="gone"
tools:visibility="visible"
android:textSize="11sp"
tools:text="アップロード中です"
/>
</LinearLayout>
<com.google.android.flexbox.FlexboxLayout
@ -285,7 +294,7 @@
</LinearLayout>
<Spinner
android:id="@+id/spEnquete"
android:id="@+id/spPollType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"

View File

@ -1108,4 +1108,18 @@
<string name="url_of_user_or_status">ユーザや投稿のURL</string>
<string name="unbookmark">ブックマーク解除</string>
<string name="event_text_alpha">一部の通知の本文アルファ値(0.01.0, デフォルト:1。アプリ再起動が必要)</string>
<string name="attachment_handling_start">初期化中…</string>
<string name="attachment_handling_compress">圧縮中…</string>
<string name="attachment_handling_compress_ratio">圧縮中 %1$d%%…</string>
<string name="attachment_handling_uploading">アップロード中 %1$d%%…</string>
<string name="attachment_handling_waiting">応答待ち…</string>
<string name="attachment_handling_waiting2">応答待ち(非同期)…</string>
<string name="option_deprecated_mastodon342">(Mastodon 3.4.2以降では指定した値ではなく、サーバから提供される情報が使われます)</string>
<string name="movie_transcode">動画の再圧縮</string>
<string name="movie_transcode_mode">モード</string>
<string name="movie_transcode_max_bitrate">最大ビットレート(単位:秒間ビット数。デフォルト=2,000,000。サーバからの指定でより少なくなる場合があります。)</string>
<string name="movie_transcode_max_frame_rate">最大フレームレート(単位:秒間フレーム数。デフォルト=30。サーバからの指定でより少なくなる場合があります。)</string>
<string name="movie_transcode_max_square_pixels">最大平方ピクセル数(デフォルト=2304000)</string>
<string name="always">常に</string>
<string name="auto">自動</string>
</resources>

View File

@ -1118,5 +1118,19 @@
<string name="url_of_user_or_status">URL of user or status</string>
<string name="url_parse_failed">parse error.</string>
<string name="unbookmark">Unbookmark</string>
<string name="event_text_alpha">Text alpha for some notifications (0.01.0, default:1. app restart required)</string>
<string name="event_text_alpha">Text alpha for some notifications (0.01.0, default=1. app restart required)</string>
<string name="attachment_handling_start">Initializing…</string>
<string name="attachment_handling_compress">Compressing…</string>
<string name="attachment_handling_compress_ratio">Compressing %1$d%%…</string>
<string name="attachment_handling_uploading">Uploading %1$d%%…</string>
<string name="attachment_handling_waiting">Waiting response…</string>
<string name="attachment_handling_waiting2">Waiting response(asynchronized)…</string>
<string name="option_deprecated_mastodon342">(This option is deprecated for Mastodon 3.4.2+. App uses information from the server.)</string>
<string name="movie_transcode">Movie transcoding</string>
<string name="movie_transcode_mode">Mode</string>
<string name="movie_transcode_max_bitrate">Max bitrate (Unit:bits per second. default=2,000,000. Also it may be reduced by server configuration.)</string>
<string name="movie_transcode_max_frame_rate">Max frame rate(Unit:frames per second. default=30. Also it may be reduced by server configuration.)</string>
<string name="movie_transcode_max_square_pixels">Max square pixels(default=2304000)</string>
<string name="always">Always</string>
<string name="auto">Auto</string>
</resources>

View File

@ -230,6 +230,25 @@
<item name="android:imeOptions">actionDone</item>
</style>
<style name="setting_edit_text_indent1" parent="@style/setting_horizontal_stretch">
<item name="android:imeOptions">actionDone</item>
<item name="android:layout_marginLeft">48dp</item>
<item name="android:layout_marginRight">0dp</item>
<item name="android:layout_marginStart" tools:ignore="NewApi">48dp</item>
<item name="android:layout_marginEnd" tools:ignore="NewApi">0dp</item>
</style>
<style name="setting_spinner_indent1" parent="@style/setting_horizontal_stretch">
<item name="android:layout_marginLeft">48dp</item>
<item name="android:layout_marginRight">0dp</item>
<item name="android:layout_marginStart" tools:ignore="NewApi">48dp</item>
<item name="android:layout_marginEnd" tools:ignore="NewApi">0dp</item>
</style>
<style name="recycler_view_with_scroll_bar">
<item name="android:clipToPadding">false</item>
<item name="android:fadeScrollbars">false</item>

View File

@ -127,8 +127,8 @@ for my $lang ( sort keys %langs ){
# 残りの部分に%が登場したらエラー
my $sv = $value;
$sv =~ s/(%\d+\$[\d\.]*[sdxf])//g;
# Unit:%. を除外したい
$sv =~ s/%[\s.。]//g;
# Unit:%. や %% を除外したい
$sv =~ s/%[\s.。%]//g;
if( $sv =~ /%/ ){
$hasError =1;
print "!! ($lang)$name : broken param: $sv // $value\n";