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

1580 lines
59 KiB
Kotlin

package jp.juggler.subwaytooter
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.provider.MediaStore
import android.text.Editable
import android.text.SpannableString
import android.text.TextWatcher
import android.view.View
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.jrummyapps.android.colorpicker.ColorPickerDialog
import com.jrummyapps.android.colorpicker.ColorPickerDialogListener
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.auth.authRepo
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.notification.*
import jp.juggler.subwaytooter.push.PushBase
import jp.juggler.subwaytooter.push.pushRepo
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.*
import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption
import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.ResizeType
import jp.juggler.util.media.createResizedBitmap
import jp.juggler.util.network.toPatch
import jp.juggler.util.network.toPost
import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.ui.*
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okio.BufferedSink
import org.jetbrains.anko.backgroundColor
import org.jetbrains.anko.textColor
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import kotlin.math.max
class ActAccountSetting : AppCompatActivity(),
View.OnClickListener,
ColorPickerDialogListener,
CompoundButton.OnCheckedChangeListener,
AdapterView.OnItemSelectedListener {
companion object {
internal val log = LogCategory("ActAccountSetting")
internal const val KEY_ACCOUNT_DB_ID = "account_db_id"
internal const val RESULT_INPUT_ACCESS_TOKEN = Activity.RESULT_FIRST_USER + 10
internal const val EXTRA_DB_ID = "db_id"
internal const val max_length_display_name = 30
internal const val max_length_note = 160
internal const val max_length_fields = 255
internal const val MIME_TYPE_JPEG = "image/jpeg"
internal const val MIME_TYPE_PNG = "image/png"
private const val ACTIVITY_STATE = "MyActivityState"
private const val COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR = 1
fun createIntent(activity: Activity, ai: SavedAccount) =
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
data class State(
var propName: String = "",
@kotlinx.serialization.Serializable(with = UriSerializer::class)
var uriCameraImage: Uri? = null,
)
var state = State()
lateinit var handler: Handler
lateinit var account: SavedAccount
private val views by lazy {
ActAccountSettingBinding.inflate(layoutInflater, null, false)
}
private lateinit var nameInvalidator: NetworkEmojiInvalidator
private lateinit var noteInvalidator: NetworkEmojiInvalidator
private lateinit var defaultTextInvalidator: NetworkEmojiInvalidator
private var loadingBusy = false
private var profileBusy = false
// private lateinit var listEtFieldName: List<EditText>
// private lateinit var listEtFieldValue: List<EditText>
private lateinit var listFieldNameInvalidator: List<NetworkEmojiInvalidator>
private lateinit var listFieldValueInvalidator: List<NetworkEmojiInvalidator>
private lateinit var btnFields: View
private class ResizeItem(val config: ResizeConfig, val caption: String)
private lateinit var imageResizeItems: List<ResizeItem>
private class PushPolicyItem(val id: String?, val caption: String)
private lateinit var pushPolicyItems: List<PushPolicyItem>
internal var visibility = TootVisibility.Public
private val languages by lazy {
loadLanguageList()
}
///////////////////////////////////////////////////////////////////
private val arShowAcctColor = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
showAcctColor()
}
private val arAddAttachment = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data
?.handleGetContentResult(contentResolver)
?.firstOrNull()
?.let {
uploadImage(
state.propName,
it.uri,
it.uri.resolveMimeType(it.mimeType, this),
)
}
}
private val arCameraImage = ActivityResultHandler(log) { r ->
if (r.isNotOk) {
// 失敗したら DBからデータを削除
state.uriCameraImage?.let {
contentResolver.delete(it, null, null)
}
state.uriCameraImage = null
} else {
// 画像のURL
val uri = r.data?.data ?: state.uriCameraImage
if (uri != null) {
uploadImage(
state.propName,
uri,
uri.resolveMimeType(null, this),
)
}
}
}
private val prPickAvater = permissionSpecImagePicker.requester { openPicker(it) }
private val prPickHeader = permissionSpecImagePicker.requester { openPicker(it) }
///////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
backPressed { handleBackPressed() }
prPickAvater.register(this)
prPickHeader.register(this)
arShowAcctColor.register(this)
arAddAttachment.register(this)
arCameraImage.register(this)
if (savedInstanceState != null) {
savedInstanceState.getString(ACTIVITY_STATE)
?.let { state = kJson.decodeFromString(it) }
}
App1.setActivityTheme(this)
initUI()
launchAndShowError {
val a = intent.long(KEY_ACCOUNT_DB_ID)
?.let { daoSavedAccount.loadAccount(it) }
if (a == null) {
finish()
return@launchAndShowError
}
supportActionBar?.subtitle = a.acct.pretty
loadUIFromData(a)
initializeProfile()
views.btnOpenBrowser.text =
getString(R.string.open_instance_website, account.apiHost.pretty)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val encodedState = kJson.encodeToString(state)
log.d("encodedState=$encodedState")
val decodedState: State = kJson.decodeFromString(encodedState)
log.d("encodedState.uriCameraImage=${decodedState.uriCameraImage}")
outState.putString(ACTIVITY_STATE, encodedState)
}
var density: Float = 1f
@Suppress("LongMethod")
private fun initUI() {
this.density = resources.displayMetrics.density
this.handler = App1.getAppState(this).handler
setContentView(views.root)
setSupportActionBar(views.toolbar)
fixHorizontalPadding(views.svContent)
setSwitchColor(views.root)
views.apply {
btnPushSubscriptionNotForce.vg(ReleaseType.isDebug)
imageResizeItems = SavedAccount.resizeConfigList.map {
val caption = when (it.type) {
ResizeType.None -> getString(R.string.dont_resize)
ResizeType.LongSide -> getString(R.string.long_side_pixel, it.size)
ResizeType.SquarePixel -> if (it.extraStringId != 0) {
getString(
R.string.resize_square_pixels_2,
it.size * it.size,
getString(it.extraStringId)
)
} else {
getString(
R.string.resize_square_pixels,
it.size * it.size,
it.size
)
}
}
ResizeItem(it, caption)
}
spResizeImage.adapter = ArrayAdapter(
this@ActAccountSetting,
android.R.layout.simple_spinner_item,
imageResizeItems.map { it.caption }.toTypedArray()
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
spLanguageCode.adapter = ArrayAdapter(
this@ActAccountSetting,
android.R.layout.simple_spinner_item,
languages.map { it.second }.toTypedArray()
).apply {
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)),
PushPolicyItem("followed", getString(R.string.following)),
PushPolicyItem("follower", getString(R.string.followers)),
PushPolicyItem("none", getString(R.string.no_one)),
)
spPushPolicy.adapter = ArrayAdapter(
this@ActAccountSetting,
android.R.layout.simple_spinner_item,
pushPolicyItems.map { it.caption }.toTypedArray()
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
listFieldNameInvalidator = intArrayOf(
R.id.etFieldName1,
R.id.etFieldName2,
R.id.etFieldName3,
R.id.etFieldName4
).map {
NetworkEmojiInvalidator(handler, findViewById<EditText>(it))
}
listFieldValueInvalidator = intArrayOf(
R.id.etFieldValue1,
R.id.etFieldValue2,
R.id.etFieldValue3,
R.id.etFieldValue4
).map {
NetworkEmojiInvalidator(handler, findViewById<EditText>(it))
}
// btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup.value)
// invalidaterがないと描画できないので
nameInvalidator = NetworkEmojiInvalidator(handler, etDisplayName)
noteInvalidator = NetworkEmojiInvalidator(handler, etNote)
defaultTextInvalidator = NetworkEmojiInvalidator(handler, etDefaultText)
val watcher1 = simpleTextWatcher {
saveUIToData()
}
views.root.scan {
when (it) {
etMaxTootChars -> etMaxTootChars.addTextChangedListener(
simpleTextWatcher {
val num = etMaxTootChars.parseInt()
if (num != null && num >= 0) {
saveUIToData()
}
}
)
is EditText ->
it.addTextChangedListener(watcher1)
is Spinner ->
it.onItemSelectedListener = this@ActAccountSetting
// CompoundButton はButtonでもあるので上に置く
is CompoundButton ->
it.setOnCheckedChangeListener(this@ActAccountSetting)
is ImageButton ->
it.setOnClickListener(this@ActAccountSetting)
is Button ->
it.setOnClickListener(this@ActAccountSetting)
}
}
}
}
private fun EditText.parseInt(): Int? =
text?.toString()?.toIntOrNull()
private fun loadUIFromData(a: SavedAccount) {
this.account = a
this.visibility = a.visibility
loadingBusy = true
try {
views.apply {
tvInstance.text = a.apiHost.pretty
tvUser.text = a.acct.pretty
cbConfirmBoost.isChecked = a.confirmBoost
cbConfirmFavourite.isChecked = a.confirmFavourite
cbConfirmFollow.isChecked = a.confirmFollow
cbConfirmFollowLockedUser.isChecked = a.confirmFollowLocked
cbConfirmReaction.isChecked = a.confirmReaction
cbConfirmToot.isChecked = a.confirmPost
cbConfirmUnbookmark.isChecked = a.confirmUnbookmark
cbConfirmUnboost.isChecked = a.confirmUnboost
cbConfirmUnfavourite.isChecked = a.confirmUnfavourite
cbConfirmUnfollow.isChecked = a.confirmUnfollow
cbNotificationBoost.isChecked = a.notificationBoost
cbNotificationFavourite.isChecked = a.notificationFavourite
cbNotificationFollow.isChecked = a.notificationFollow
cbNotificationFollowRequest.isChecked = a.notificationFollowRequest
cbNotificationMention.isChecked = a.notificationMention
cbNotificationPost.isChecked = a.notificationPost
cbNotificationReaction.isChecked = a.notificationReaction
cbNotificationStatusReference.isChecked = a.notificationStatusReference
cbNotificationUpdate.isChecked = a.notificationUpdate
cbNotificationVote.isChecked = a.notificationVote
swDontShowTimeout.isChecked = a.dontShowTimeout
swExpandCW.isChecked = a.expandCw
swMarkSensitive.isChecked = a.defaultSensitive
swNSFWOpen.isChecked = a.dontHideNsfw
swNotificationPullEnabled.isChecked = a.notificationPullEnable
swNotificationPushEnabled.isChecked = a.notificationPushEnable
defaultTextInvalidator.text = a.defaultText
etMaxTootChars.setText(a.maxTootChars.toString())
val ti = TootInstance.getCached(a)
if (ti == null) {
etMediaSizeMax.setText(a.imageMaxMegabytes ?: "")
etMovieSizeMax.setText(a.movieMaxMegabytes ?: "")
} else {
etMediaSizeMax.setText(
a.imageMaxMegabytes
?: a.getImageMaxBytes(ti).div(1000000).toString()
)
etMovieSizeMax.setText(
a.movieMaxMegabytes
?: 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.pushPolicy
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)
spLanguageCode.setSelection(max(0, languages.indexOfFirst { it.first == a.lang }))
// アカウントからUIへのデータロードはここまで
loadingBusy = false
val enabled = !a.isPseudo
arrayOf(
btnAccessToken,
btnFields,
btnInputAccessToken,
btnLoadPreference,
btnPushSubscription,
btnPushSubscriptionNotForce,
btnResetNotificationTracking,
btnVisibility,
cbConfirmBoost,
cbConfirmFavourite,
cbConfirmFollow,
cbConfirmFollowLockedUser,
cbConfirmReaction,
cbConfirmToot,
cbConfirmUnbookmark,
cbConfirmUnboost,
cbConfirmUnfavourite,
cbConfirmUnfollow,
cbNotificationBoost,
cbNotificationFavourite,
cbNotificationFollow,
cbNotificationFollowRequest,
cbNotificationMention,
cbNotificationPost,
cbNotificationReaction,
cbNotificationStatusReference,
cbNotificationUpdate,
cbNotificationVote,
etDefaultText,
etMaxTootChars,
etMediaSizeMax,
etMovieBitrate,
etMovieFrameRate,
etMovieSizeMax,
etMovieSquarePixels,
spLanguageCode,
spMovieTranscodeMode,
spPushPolicy,
spResizeImage,
swNotificationPullEnabled,
swNotificationPushEnabled,
btnNotificationAccentColorEdit,
btnNotificationAccentColorReset,
).forEach { it.isEnabledAlpha = enabled }
// arrayOf(
// btnNotificationStyleEdit,
// btnNotificationStyleEditReply,
// ).forEach { it.isEnabledAlpha = enabled }
}
showVisibility()
showAcctColor()
showPushSetting()
showNotificationColor()
} finally {
loadingBusy = false
}
}
private fun showAcctColor() {
val sa = this.account
val ac = daoAcctColor.load(sa)
views.tvUserCustom.apply {
backgroundColor = ac.colorBg
text = ac.nickname
textColor = ac.colorFg.notZero()
?: attrColor(R.attr.colorTimeSmall)
}
}
private fun showPushSetting() {
views.run {
run {
val usePush = swNotificationPushEnabled.isChecked
tvPushPolicyDesc.vg(usePush)
spPushPolicy.vg(usePush)
tvPushActions.vg(usePush)
btnPushSubscription.vg(usePush)
btnPushSubscriptionNotForce.vg(usePush)
tvNotificationAccentColor.vg(usePush)
llNotificationAccentColor.vg(usePush)
}
run {
val usePull = swNotificationPullEnabled.isChecked
tvDontShowTimeout.vg(usePull)
swDontShowTimeout.vg(usePull)
tvPullActions.vg(usePull)
btnResetNotificationTracking.vg(usePull)
}
}
}
private fun saveUIToData() {
if (!::account.isInitialized) return
if (loadingBusy) return
launchAndShowError {
account.visibility = visibility
views.apply {
account.confirmBoost = cbConfirmBoost.isChecked
account.confirmFavourite = cbConfirmFavourite.isChecked
account.confirmFollow = cbConfirmFollow.isChecked
account.confirmFollowLocked = cbConfirmFollowLockedUser.isChecked
account.confirmPost = cbConfirmToot.isChecked
account.confirmReaction = cbConfirmReaction.isChecked
account.confirmUnbookmark = cbConfirmUnbookmark.isChecked
account.confirmUnboost = cbConfirmUnboost.isChecked
account.confirmUnfavourite = cbConfirmUnfavourite.isChecked
account.confirmUnfollow = cbConfirmUnfollow.isChecked
account.defaultSensitive = swMarkSensitive.isChecked
account.dontHideNsfw = swNSFWOpen.isChecked
account.dontShowTimeout = swDontShowTimeout.isChecked
account.expandCw = swExpandCW.isChecked
account.notificationBoost = cbNotificationBoost.isChecked
account.notificationFavourite = cbNotificationFavourite.isChecked
account.notificationFollow = cbNotificationFollow.isChecked
account.notificationFollowRequest = cbNotificationFollowRequest.isChecked
account.notificationMention = cbNotificationMention.isChecked
account.notificationPost = cbNotificationPost.isChecked
account.notificationPullEnable = swNotificationPullEnabled.isChecked
account.notificationPushEnable = swNotificationPushEnabled.isChecked
account.notificationReaction = cbNotificationReaction.isChecked
account.notificationStatusReference = cbNotificationStatusReference.isChecked
account.notificationUpdate = cbNotificationUpdate.isChecked
account.notificationVote = cbNotificationVote.isChecked
// account.soundUri = ""
account.defaultText = etDefaultText.text.toString()
account.maxTootChars = etMaxTootChars.parseInt()?.takeIf { it > 0 } ?: 0
account.movieMaxMegabytes = etMovieSizeMax.text.toString().trim()
account.imageMaxMegabytes = etMediaSizeMax.text.toString().trim()
account.imageResize = (
imageResizeItems.elementAtOrNull(spResizeImage.selectedItemPosition)?.config
?: SavedAccount.defaultResizeConfig
).spec
account.pushPolicy =
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.lang = languages.elementAtOrNull(spLanguageCode.selectedItemPosition)?.first
?: SavedAccount.LANG_WEB
}
daoSavedAccount.save(account)
}
}
private fun handleBackPressed() {
checkNotificationImmediateAll(this, onlyEnqueue = true)
checkNotificationImmediate(this, account.db_id)
finish()
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView) {
views.cbLocked -> {
if (!profileBusy) sendLocked(isChecked)
}
views.swNotificationPullEnabled -> {
saveUIToData()
showPushSetting()
}
views.swNotificationPushEnabled -> launchAndShowError {
val oldChecked = account.notificationPushEnable
try {
if (oldChecked == isChecked) return@launchAndShowError
account.notificationPushEnable = isChecked
if (updatePushSubscription(force = true)) {
saveUIToData()
} else {
account.notificationPushEnable = oldChecked
buttonView.isChecked = oldChecked
}
} finally {
showPushSetting()
}
}
else -> saveUIToData()
}
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
saveUIToData()
}
override fun onNothingSelected(parent: AdapterView<*>?) {
saveUIToData()
}
override fun onClick(v: View) {
when (v.id) {
R.id.btnAccessToken -> performAccessToken()
R.id.btnInputAccessToken -> inputAccessToken()
R.id.btnAccountRemove -> performAccountRemove()
R.id.btnLoadPreference -> performLoadPreference()
R.id.btnVisibility -> performVisibility()
R.id.btnOpenBrowser -> openBrowser("https://${account.apiHost.ascii}/")
R.id.btnPushSubscription -> launchAndShowError {
updatePushSubscription(force = true)
}
R.id.btnPushSubscriptionNotForce -> launchAndShowError {
updatePushSubscription(force = false)
}
R.id.btnResetNotificationTracking ->
resetNotificationTracking(account)
R.id.btnUserCustom -> arShowAcctColor.launch(
ActNickname.createIntent(this, account.acct, false),
)
R.id.btnProfileAvatar -> pickAvatarImage()
R.id.btnProfileHeader -> pickHeaderImage()
R.id.btnDisplayName -> sendDisplayName()
R.id.btnNote -> sendNote()
R.id.btnFields -> sendFields()
// R.id.btnNotificationStyleEdit ->
// PullNotification.openNotificationChannelSetting(
// this
// )
//
// R.id.btnNotificationStyleEditReply ->
// PullNotification.openNotificationChannelSetting(
// this
// )
R.id.btnNotificationAccentColorEdit -> {
ColorPickerDialog.newBuilder().apply {
setDialogType(ColorPickerDialog.TYPE_CUSTOM)
setAllowPresets(true)
setShowAlphaSlider(false)
setDialogId(COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR)
account.notificationAccentColor.notZero()?.let { setColor(it) }
}.show(this)
}
R.id.btnNotificationAccentColorReset -> {
account.notificationAccentColor = 0
saveUIToData()
showNotificationColor()
}
}
}
private fun showVisibility() {
views.btnVisibility.text =
visibility.getVisibilityString(account.isMisskey)
}
private fun performVisibility() {
val list = if (account.isMisskey) {
arrayOf(
// TootVisibility.WebSetting,
TootVisibility.Public,
TootVisibility.UnlistedHome,
TootVisibility.PrivateFollowers,
TootVisibility.LocalPublic,
TootVisibility.LocalHome,
TootVisibility.LocalFollowers,
TootVisibility.DirectSpecified,
TootVisibility.DirectPrivate
)
} else {
arrayOf(
TootVisibility.WebSetting,
TootVisibility.Public,
TootVisibility.UnlistedHome,
TootVisibility.PrivateFollowers,
TootVisibility.DirectSpecified
)
}
val captionList = list.map {
getVisibilityCaption(this, account.isMisskey, it)
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(R.string.choose_visibility)
.setItems(captionList) { _, which ->
if (which in list.indices) {
visibility = list[which]
showVisibility()
saveUIToData()
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun performLoadPreference() {
launchMain {
runApiTask(account) { client ->
client.request("/api/v1/preferences")
}?.let { result ->
val json = result.jsonObject
if (json == null) {
showToast(true, result.error)
return@let
}
var bChanged = false
try {
loadingBusy = true
val tmpVisibility =
TootVisibility.parseMastodon(json.string("posting:default:visibility"))
if (tmpVisibility != null) {
bChanged = true
visibility = tmpVisibility
showVisibility()
}
val tmpDefaultSensitive = json.boolean("posting:default:sensitive")
if (tmpDefaultSensitive != null) {
bChanged = true
views.swMarkSensitive.isChecked = tmpDefaultSensitive
}
val tmpExpandMedia = json.string("reading:expand:media")
if (tmpExpandMedia?.isNotEmpty() == true) {
bChanged = true
views.swNSFWOpen.isChecked = (tmpExpandMedia == "show_all")
}
val tmpExpandCW = json.boolean("reading:expand:spoilers")
if (tmpExpandCW != null) {
bChanged = true
views.swExpandCW.isChecked = tmpExpandCW
}
} finally {
loadingBusy = false
if (bChanged) saveUIToData()
}
}
}
}
///////////////////////////////////////////////////
private fun performAccountRemove() {
launchAndShowError {
confirm(getString(R.string.confirm_account_remove), title = getString(R.string.confirm))
authRepo.accountRemove(account)
finish()
}
}
///////////////////////////////////////////////////
private fun performAccessToken() {
launchMain {
try {
runApiTask2(account) { client ->
val authUrl = client.authStep1(forceUpdateClient = true)
withContext(AppDispatchers.MainImmediate) {
val resultIntent = Intent().apply { data = authUrl }
setResult(Activity.RESULT_OK, resultIntent)
finish()
}
}
} catch (ex: Throwable) {
showApiError(ex)
}
}
}
private fun inputAccessToken() {
val data = Intent()
data.putExtra(EXTRA_DB_ID, account.db_id)
setResult(RESULT_INPUT_ACCESS_TOKEN, data)
finish()
}
//////////////////////////////////////////////////////////////////////////
private fun initializeProfile() {
// 初期状態
val questionId = R.drawable.wide_question
val loadingText = when (account.isPseudo) {
true -> "(disabled for pseudo account)"
else -> "(loading…)"
}
views.apply {
ivProfileAvatar.setErrorImage(defaultColorIcon(this@ActAccountSetting, questionId))
ivProfileAvatar.setDefaultImage(defaultColorIcon(this@ActAccountSetting, questionId))
nameInvalidator.text = loadingText
noteInvalidator.text = loadingText
// 初期状態では編集不可能
arrayOf(
btnProfileAvatar,
btnProfileHeader,
etDisplayName,
btnDisplayName,
etNote,
btnNote,
cbLocked,
).forEach { it.isEnabledAlpha = false }
for (i in listFieldNameInvalidator) {
i.text = loadingText
i.view.isEnabledAlpha = false
}
for (i in listFieldValueInvalidator) {
i.text = loadingText
i.view.isEnabledAlpha = false
}
// 疑似アカウントなら編集不可のまま
if (!account.isPseudo) loadProfile()
}
}
// サーバから情報をロードする
private fun loadProfile() {
launchMain {
try {
runApiTask2(account) { client ->
val json = if (account.isMisskey) {
val result = client.request(
"/api/i",
account.putMisskeyApiToken().toPostRequestBuilder()
) ?: return@runApiTask2
result.error?.let { error(it) }
result.jsonObject
} else {
// 承認待ち状態のチェック
authRepo.checkConfirmed(account, client)
val result = client.request(
"/api/v1/accounts/verify_credentials"
) ?: return@runApiTask2
result.error?.let { error(it) }
result.jsonObject
}
val newAccount = TootParser(this, account)
.account(json) ?: error("parse error.")
withContext(AppDispatchers.MainImmediate) {
showProfile(newAccount)
}
}
} catch (ex: Throwable) {
showApiError(ex)
}
}
}
private fun showProfile(src: TootAccount) {
if (isDestroyed) return
profileBusy = true
try {
views.ivProfileAvatar.setImageUrl(
calcIconRound(views.ivProfileAvatar.layoutParams),
src.avatar_static,
src.avatar
)
views.ivProfileHeader.setImageUrl(
0f,
src.header_static,
src.header
)
val decodeOptions = DecodeOptions(
context = this@ActAccountSetting,
linkHelper = account,
emojiMapProfile = src.profile_emojis,
emojiMapCustom = src.custom_emojis,
authorDomain = account,
emojiSizeMode = account.emojiSizeMode(),
)
val displayName = src.display_name
val name = decodeOptions.decodeEmoji(displayName)
nameInvalidator.text = name
val noteString = src.source?.note ?: src.note
val noteSpannable = when {
account.isMisskey -> {
SpannableString(noteString ?: "")
}
else -> {
decodeOptions.decodeEmoji(noteString)
}
}
noteInvalidator.text = noteSpannable
views.cbLocked.isChecked = src.locked
// 編集可能にする
views.apply {
arrayOf(
btnProfileAvatar,
btnProfileHeader,
etDisplayName,
btnDisplayName,
etNote,
btnNote,
cbLocked,
).forEach { it.isEnabledAlpha = true }
}
if (src.source?.fields != null) {
val fields = src.source.fields
listFieldNameInvalidator.forEachIndexed { i, et ->
// いつからかfields name にもカスタム絵文字が使えるようになった
// https://github.com/tootsuite/mastodon/pull/11350
// しかし
val text = decodeOptions.decodeEmoji(
when {
i >= fields.size -> ""
else -> fields[i].name
}
)
et.text = text
et.view.isEnabledAlpha = true
}
listFieldValueInvalidator.forEachIndexed { i, et ->
val text = decodeOptions.decodeEmoji(
when {
i >= fields.size -> ""
else -> fields[i].value
}
)
et.text = text
et.view.isEnabledAlpha = true
}
} else {
val fields = src.fields
listFieldNameInvalidator.forEachIndexed { i, et ->
// いつからかfields name にもカスタム絵文字が使えるようになった
// https://github.com/tootsuite/mastodon/pull/11350
val text = decodeOptions.decodeEmoji(
when {
fields == null || i >= fields.size -> ""
else -> fields[i].name
}
)
et.text = text
et.view.isEnabledAlpha = true
}
listFieldValueInvalidator.forEachIndexed { i, et ->
val text = decodeOptions.decodeHTML(
when {
fields == null || i >= fields.size -> ""
else -> fields[i].value
}
)
et.text = text
et.view.isEnabledAlpha = true
}
}
} finally {
profileBusy = false
}
}
private fun updateCredential(key: String, value: Any) {
updateCredential(listOf(Pair(key, value)))
}
private suspend fun uploadImageMisskey(
client: TootApiClient,
opener: InputStreamOpener,
): Pair<TootApiResult?, TootAttachment?> {
val size = getStreamSize(true, opener.open())
val multipartBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
val apiKey =
account.tokenJson?.string(AuthBase.KEY_API_KEY_MISSKEY)
if (apiKey?.isNotEmpty() == true) {
multipartBuilder.addFormDataPart("i", apiKey)
}
multipartBuilder.addFormDataPart(
"file",
getDocumentName(contentResolver, opener.uri),
object : RequestBody() {
override fun contentType(): MediaType {
return opener.mimeType.toMediaType()
}
override fun contentLength(): Long {
return size
}
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)
}
}
}
}
)
var ta: TootAttachment? = null
val result = client.request(
"/api/drive/files/create",
multipartBuilder.build().toPost()
)?.also { result ->
ta = parseItem(result.jsonObject) { tootAttachment(ServiceType.MISSKEY, it) }
if (ta == null) result.error = "TootAttachment.parse failed"
}
return Pair(result, ta)
}
private fun updateCredential(args: List<Pair<String, Any>>) {
launchMain {
var resultAccount: TootAccount? = null
runApiTask(account) { client ->
try {
if (account.isMisskey) {
val params = account.putMisskeyApiToken()
for (arg in args) {
val key = arg.first
val value = arg.second
val misskeyKey = when (key) {
"header" -> "bannerId"
"avatar" -> "avatarId"
"display_name" -> "name"
"note" -> "description"
"locked" -> "isLocked"
else -> return@runApiTask TootApiResult("Misskey does not support property '$key'")
}
when (value) {
is String -> params[misskeyKey] = value
is Boolean -> params[misskeyKey] = value
is InputStreamOpener -> {
val (result, ta) = uploadImageMisskey(client, value)
ta ?: return@runApiTask result
params[misskeyKey] = ta.id
}
}
}
client.request("/api/i/update", params.toPostRequestBuilder())
?.also { result ->
result.jsonObject?.let {
resultAccount = TootParser(this, account).account(it)
?: return@runApiTask TootApiResult("TootAccount parse failed.")
}
}
} else {
val multipartBodyBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
for (arg in args) {
val key = arg.first
val value = arg.second
if (value is String) {
multipartBodyBuilder.addFormDataPart(key, value)
} else if (value is Boolean) {
multipartBodyBuilder.addFormDataPart(
key,
if (value) "true" else "false"
)
} else if (value is InputStreamOpener) {
val fileName = "%x".format(System.currentTimeMillis())
multipartBodyBuilder.addFormDataPart(
key,
fileName,
object : RequestBody() {
override fun contentType(): MediaType =
value.mimeType.toMediaType()
override fun writeTo(sink: BufferedSink) {
value.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)
}
}
}
}
)
}
}
client.request(
"/api/v1/accounts/update_credentials",
multipartBodyBuilder.build().toPatch()
)?.also { result ->
result.jsonObject?.let {
resultAccount =
TootParser(this@ActAccountSetting, account).account(it)
?: return@runApiTask TootApiResult("TootAccount parse failed.")
}
}
}
} finally {
for (arg in args) {
val value = arg.second
(value as? InputStreamOpener)?.deleteTempFile()
}
}
}?.let { result ->
val data = resultAccount
if (data != null) {
showProfile(data)
} else {
showToast(true, result.error)
for (arg in args) {
val key = arg.first
val value = arg.second
if (key == "locked" && value is Boolean) {
profileBusy = true
views.cbLocked.isChecked = !value
profileBusy = false
}
}
}
}
}
}
private fun sendDisplayName(bConfirmed: Boolean = false) {
val sv = views.etDisplayName.text.toString()
if (!bConfirmed) {
val length = sv.codePointCount(0, sv.length)
if (length > max_length_display_name) {
AlertDialog.Builder(this)
.setMessage(
getString(
R.string.length_warning,
getString(R.string.display_name),
length,
max_length_display_name
)
)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ -> sendDisplayName(bConfirmed = true) }
.setCancelable(true)
.show()
return
}
}
updateCredential("display_name", EmojiDecoder.decodeShortCode(sv))
}
private fun sendNote(bConfirmed: Boolean = false) {
val sv = views.etNote.text.toString()
if (!bConfirmed) {
val length = TootAccount.countText(sv)
if (length > max_length_note) {
AlertDialog.Builder(this)
.setMessage(
getString(
R.string.length_warning,
getString(R.string.note),
length,
max_length_note
)
)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ -> sendNote(bConfirmed = true) }
.setCancelable(true)
.show()
return
}
}
updateCredential("note", EmojiDecoder.decodeShortCode(sv))
}
private fun sendLocked(willLocked: Boolean) {
updateCredential("locked", willLocked)
}
private fun sendFields(bConfirmed: Boolean = false) {
val args = ArrayList<Pair<String, String>>()
var lengthLongest = -1
for (i in listFieldNameInvalidator.indices) {
val k = listFieldNameInvalidator[i].text.toString().trim()
val v = listFieldValueInvalidator[i].text.toString().trim()
args.add(Pair("fields_attributes[$i][name]", k))
args.add(Pair("fields_attributes[$i][value]", v))
lengthLongest = max(
lengthLongest,
max(
k.codePointCount(0, k.length),
v.codePointCount(0, v.length)
)
)
}
if (!bConfirmed && lengthLongest > max_length_fields) {
AlertDialog.Builder(this)
.setMessage(
getString(
R.string.length_warning,
getString(R.string.profile_metadata),
lengthLongest,
max_length_fields
)
)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ -> sendFields(bConfirmed = true) }
.setCancelable(true)
.show()
return
}
updateCredential(args)
}
private fun pickAvatarImage() {
openPicker(prPickAvater)
}
private fun pickHeaderImage() {
openPicker(prPickHeader)
}
private fun openPicker(permissionRequester: PermissionRequester) {
launchAndShowError {
if (!permissionRequester.checkOrLaunch()) return@launchAndShowError
val propName = when (permissionRequester) {
prPickHeader -> "header"
else -> "avatar"
}
actionsDialog {
action(getString(R.string.pick_image)) {
performAttachment(propName)
}
action(getString(R.string.image_capture)) {
performCamera(propName)
}
}
}
}
private fun performAttachment(propName: String) {
try {
state.propName = propName
val intent = intentGetContent(false, getString(R.string.pick_image), arrayOf("image/*"))
arAddAttachment.launch(intent)
} catch (ex: Throwable) {
log.e(ex, "performAttachment failed.")
showToast(ex, "performAttachment failed.")
}
}
private fun performCamera(propName: String) {
try {
// カメラで撮影
val filename = System.currentTimeMillis().toString() + ".jpg"
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE, filename)
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
state.uriCameraImage = uri
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
state.propName = propName
arCameraImage.launch(intent)
} catch (ex: Throwable) {
log.e(ex, "opening camera app failed.")
showToast(ex, "opening camera app failed.")
}
}
internal interface InputStreamOpener {
val mimeType: String
val uri: Uri
fun open(): InputStream
fun deleteTempFile()
}
private fun createOpener(uriArg: Uri, mimeType: String): InputStreamOpener {
while (true) {
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 resizeTo = 1280
val bitmap = createResizedBitmap(this, uriArg, resizeTo)
if (bitmap != null) {
try {
val cacheDir = externalCacheDir?.apply { mkdirs() }
if (cacheDir == null) {
showToast(false, "getExternalCacheDir returns null.")
break
}
val tempFile = File(
cacheDir,
"tmp." + System.currentTimeMillis() + "." + 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
override val uri: Uri
get() = uriArg
override fun open() = FileInputStream(tempFile)
override fun deleteTempFile() {
tempFile.delete()
}
}
} finally {
bitmap.recycle()
}
}
} catch (ex: Throwable) {
log.e(ex, "Resizing image failed.")
showToast(ex, "Resizing image failed.")
}
break
}
return object : InputStreamOpener {
override val mimeType: String
get() = mimeType
override val uri: Uri
get() = uriArg
override fun open(): InputStream {
return contentResolver.openInputStream(uri) ?: error("openInputStream returns null")
}
override fun deleteTempFile() {
}
}
}
private fun uploadImage(propName: String, uri: Uri, mimeType: String?) {
if (mimeType == null) {
showToast(false, "mime type is not provided.")
return
}
if (!mimeType.startsWith("image/")) {
showToast(false, "mime type is not image.")
return
}
launchProgress(
"preparing image",
doInBackground = { createOpener(uri, mimeType) },
afterProc = { updateCredential(propName, it) }
)
}
private suspend fun updatePushSubscription(force: Boolean): Boolean {
val activity = this
val anyNotificationWanted = account.notificationBoost ||
account.notificationFavourite ||
account.notificationFollow ||
account.notificationMention ||
account.notificationReaction ||
account.notificationVote ||
account.notificationFollowRequest ||
account.notificationPost ||
account.notificationUpdate
val lines = ArrayList<String>()
val subLogger = object : PushBase.SubscriptionLogger {
override val context: Context
get() = activity
override fun i(msg: String) {
log.w(msg)
synchronized(lines) {
lines.add(msg)
}
}
override fun e(msg: String) {
log.e(msg)
synchronized(lines) {
lines.add(msg)
}
}
override fun e(ex: Throwable, msg: String) {
log.e(ex, msg)
synchronized(lines) {
lines.add(ex.withCaption(msg))
}
}
}
val rv = try {
pushRepo.updateSubscription(
subLogger,
account,
willRemoveSubscription = !anyNotificationWanted,
forceUpdate = force,
)
true
} catch (ex: Throwable) {
subLogger.e(ex, "updateSubscription failed.")
false
}
AlertDialog.Builder(activity)
.setMessage("${account.acct}:\n${lines.joinToString("\n")}")
.setPositiveButton(android.R.string.ok, null)
.show()
return rv
}
override fun onDialogDismissed(dialogId: Int) {
}
override fun onColorSelected(dialogId: Int, newColor: Int) {
when (dialogId) {
COLOR_DIALOG_NOTIFICATION_ACCENT_COLOR -> {
account.notificationAccentColor = newColor or Color.BLACK
showNotificationColor()
saveUIToData()
}
else -> Unit
}
}
private fun showNotificationColor() {
views.vNotificationAccentColorColor.backgroundColor =
account.notificationAccentColor.notZero()
?: ContextCompat.getColor(this, R.color.colorOsNotificationAccent)
}
}