package jp.juggler.subwaytooter import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.os.Handler import android.text.Editable import android.text.SpannableString import android.text.TextWatcher import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Button import android.widget.CompoundButton import android.widget.EditText import android.widget.ImageButton import android.widget.Spinner import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.jrummyapps.android.colorpicker.ColorPickerDialogType import com.jrummyapps.android.colorpicker.dialogColorPicker import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.authRepo import jp.juggler.subwaytooter.api.entity.ServiceType import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.api.entity.parseItem import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.runApiTask2 import jp.juggler.subwaytooter.api.showApiError import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.notification.checkNotificationImmediate import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll import jp.juggler.subwaytooter.notification.resetNotificationTracking 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.backPressed 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.UriAndType import jp.juggler.util.data.UriSerializer import jp.juggler.util.data.getDocumentName import jp.juggler.util.data.getStreamSize import jp.juggler.util.data.notZero import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast import jp.juggler.util.log.withCaption import jp.juggler.util.long 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.ActivityResultHandler import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.isEnabledAlpha import jp.juggler.util.ui.isOk import jp.juggler.util.ui.scan import jp.juggler.util.ui.vg import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch 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, 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 // private lateinit var listEtFieldValue: List private lateinit var listFieldNameInvalidator: List private lateinit var listFieldValueInvalidator: List private lateinit var btnFields: View private class ResizeItem(val config: ResizeConfig, val caption: String) private lateinit var imageResizeItems: List private class PushPolicyItem(val id: String?, val caption: String) private lateinit var pushPolicyItems: List internal var visibility = TootVisibility.Public private val languages by lazy { loadLanguageList() } ///////////////////////////////////////////////////////////////////// private val cameraOpener = CameraOpener { uploadImage(state.propName, it) } private val visualMediaPicker = VisualMediaPickerCompat { uploadImage(state.propName, it?.firstOrNull()) } private val arShowAcctColor = ActivityResultHandler(log) { r -> if (r.isOk) showAcctColor() } /////////////////////////////////////////////////// override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) backPressed { handleBackPressed() } visualMediaPicker.register(this) cameraOpener.register(this) arShowAcctColor.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(it)) } listFieldValueInvalidator = intArrayOf( R.id.etFieldValue1, R.id.etFieldValue2, R.id.etFieldValue3, R.id.etFieldValue4 ).map { NetworkEmojiInvalidator(handler, findViewById(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 -> { lifecycleScope.launch { try { account.notificationAccentColor = dialogColorPicker( colorInitial = account.notificationAccentColor.notZero(), alphaEnabled = false, ) showNotificationColor() saveUIToData() } catch (ex: Throwable) { if (ex is CancellationException) return@launch log.e(ex, "openColorPicker failed.") } } } 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 { 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>) { 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>() 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() { openImagePickerOrCamera("avatar") } private fun pickHeaderImage() { openImagePickerOrCamera("header") } private fun openImagePickerOrCamera(propName: String) { state.propName = propName launchAndShowError { actionsDialog { action(getString(R.string.pick_image)) { visualMediaPicker.open() } action(getString(R.string.image_capture)) { cameraOpener.open() } } } } /////////////////////////////////////////////////// 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, src: UriAndType?) { src ?: return val uri = src.uri val mimeType = src.mimeType 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() 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 } private fun showNotificationColor() { views.vNotificationAccentColorColor.backgroundColor = account.notificationAccentColor.notZero() ?: ContextCompat.getColor(this, R.color.colorOsNotificationAccent) } }