package jp.juggler.subwaytooter import android.app.Activity import android.content.ContentValues import android.content.Intent import android.graphics.Bitmap 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 jp.juggler.subwaytooter.Styler.defaultColorIcon import jp.juggler.subwaytooter.action.accountRemove import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.notification.* import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.util.* import kotlinx.serialization.decodeFromString 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" 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 lateinit var viewBinding: ActAccountSettingBinding 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 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.mimeType?.notEmpty() ?: contentResolver.getType(it.uri) ) } } 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) { val type = contentResolver.getType(uri) uploadImage(state.propName, uri, type) } } } private val prPickAvater = permissionSpecImagePicker.requester { openPicker(it) } private val prPickHeader = permissionSpecImagePicker.requester { openPicker(it) } /////////////////////////////////////////////////// override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) backPressed { checkNotificationImmediateAll(this, onlySubscription = true) checkNotificationImmediate(this, account.db_id) finish() } 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() val a = SavedAccount.loadAccount(this, intent.getLongExtra(KEY_ACCOUNT_DB_ID, -1L)) if (a == null) { finish() return } loadUIFromData(a) initializeProfile() viewBinding.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 this.viewBinding = ActAccountSettingBinding.inflate(layoutInflater, null, false) setContentView(viewBinding.root) App1.initEdgeToEdge(this) Styler.fixHorizontalPadding(viewBinding.root) setSwitchColor(viewBinding.root) viewBinding.apply { btnPushSubscriptionNotForce.vg(BuildConfig.DEBUG) 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) } listEtFieldName = intArrayOf( R.id.etFieldName1, R.id.etFieldName2, R.id.etFieldName3, R.id.etFieldName4 ).map { findViewById(it) } listEtFieldValue = intArrayOf( R.id.etFieldValue1, R.id.etFieldValue2, R.id.etFieldValue3, R.id.etFieldValue4 ).map { findViewById(it) } btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup()) nameInvalidator = NetworkEmojiInvalidator(handler, etDisplayName) noteInvalidator = NetworkEmojiInvalidator(handler, etNote) defaultTextInvalidator = NetworkEmojiInvalidator(handler, etDefaultText) listFieldNameInvalidator = listEtFieldName.map { NetworkEmojiInvalidator(handler, it) } listFieldValueInvalidator = listEtFieldValue.map { NetworkEmojiInvalidator(handler, it) } val watcher1 = simpleTextWatcher { saveUIToData() } viewBinding.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 { viewBinding.apply { tvInstance.text = a.apiHost.pretty tvUser.text = a.acct.pretty swNSFWOpen.isChecked = a.dont_hide_nsfw swDontShowTimeout.isChecked = a.dont_show_timeout swExpandCW.isChecked = a.expand_cw swMarkSensitive.isChecked = a.default_sensitive cbNotificationMention.isChecked = a.notification_mention cbNotificationBoost.isChecked = a.notification_boost cbNotificationFavourite.isChecked = a.notification_favourite cbNotificationFollow.isChecked = a.notification_follow cbNotificationFollowRequest.isChecked = a.notification_follow_request cbNotificationReaction.isChecked = a.notification_reaction cbNotificationVote.isChecked = a.notification_vote cbNotificationPost.isChecked = a.notification_post cbNotificationUpdate.isChecked = a.notification_update cbNotificationStatusReference.isChecked = a.notification_status_reference cbConfirmFollow.isChecked = a.confirm_follow cbConfirmFollowLockedUser.isChecked = a.confirm_follow_locked cbConfirmUnfollow.isChecked = a.confirm_unfollow cbConfirmBoost.isChecked = a.confirm_boost cbConfirmFavourite.isChecked = a.confirm_favourite cbConfirmUnboost.isChecked = a.confirm_unboost cbConfirmUnfavourite.isChecked = a.confirm_unfavourite cbConfirmToot.isChecked = a.confirm_post cbConfirmReaction.isChecked = a.confirm_reaction cbConfirmUnbookmark.isChecked = a.confirm_unbookmark 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) spLanguageCode.setSelection(max(0, languages.indexOfFirst { it.first == a.lang })) // アカウントからUIへのデータロードはここまで loadingBusy = false val enabled = !a.isPseudo arrayOf( btnAccessToken, btnInputAccessToken, btnVisibility, btnPushSubscription, btnPushSubscriptionNotForce, btnResetNotificationTracking, cbNotificationMention, cbNotificationBoost, cbNotificationFavourite, cbNotificationFollow, cbNotificationFollowRequest, cbNotificationReaction, cbNotificationVote, cbNotificationPost, cbNotificationUpdate, cbNotificationStatusReference, cbConfirmFollow, cbConfirmFollowLockedUser, cbConfirmUnfollow, cbConfirmBoost, cbConfirmFavourite, cbConfirmUnboost, cbConfirmUnfavourite, cbConfirmToot, cbConfirmReaction, ).forEach { it.isEnabledAlpha = enabled } arrayOf( btnNotificationStyleEdit, btnNotificationStyleEditReply, ).forEach { it.isEnabledAlpha = enabled } } showVisibility() showAcctColor() } finally { loadingBusy = false } } private fun showAcctColor() { val sa = this.account val ac = AcctColor.load(sa) viewBinding.tvUserCustom.apply { backgroundColor = ac.color_bg text = ac.nickname textColor = ac.color_fg.notZero() ?: attrColor(R.attr.colorTimeSmall) } } private fun saveUIToData() { if (!::account.isInitialized) return if (loadingBusy) return account.visibility = visibility viewBinding.apply { account.dont_hide_nsfw = swNSFWOpen.isChecked account.dont_show_timeout = swDontShowTimeout.isChecked account.expand_cw = swExpandCW.isChecked account.default_sensitive = swMarkSensitive.isChecked account.notification_mention = cbNotificationMention.isChecked account.notification_boost = cbNotificationBoost.isChecked account.notification_favourite = cbNotificationFavourite.isChecked account.notification_follow = cbNotificationFollow.isChecked account.notification_follow_request = cbNotificationFollowRequest.isChecked account.notification_reaction = cbNotificationReaction.isChecked account.notification_vote = cbNotificationVote.isChecked account.notification_post = cbNotificationPost.isChecked account.notification_update = cbNotificationUpdate.isChecked account.notification_status_reference = cbNotificationStatusReference.isChecked account.confirm_follow = cbConfirmFollow.isChecked account.confirm_follow_locked = cbConfirmFollowLockedUser.isChecked account.confirm_unfollow = cbConfirmUnfollow.isChecked account.confirm_boost = cbConfirmBoost.isChecked account.confirm_favourite = cbConfirmFavourite.isChecked account.confirm_unboost = cbConfirmUnboost.isChecked account.confirm_unfavourite = cbConfirmUnfavourite.isChecked account.confirm_post = cbConfirmToot.isChecked account.confirm_reaction = cbConfirmReaction.isChecked account.confirm_unbookmark = cbConfirmUnbookmark.isChecked account.sound_uri = "" account.default_text = etDefaultText.text.toString() account.max_toot_chars = etMaxTootChars.parseInt()?.takeIf { it > 0 } ?: 0 account.movie_max_megabytes = etMovieSizeMax.text.toString().trim() account.image_max_megabytes = etMediaSizeMax.text.toString().trim() account.image_resize = ( imageResizeItems.elementAtOrNull(spResizeImage.selectedItemPosition)?.config ?: SavedAccount.defaultResizeConfig ).spec 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.lang = languages.elementAtOrNull(spLanguageCode.selectedItemPosition)?.first ?: SavedAccount.LANG_WEB } account.saveSetting() } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { if (buttonView == viewBinding.cbLocked) { if (!profileBusy) sendLocked(isChecked) } 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 -> updatePushSubscription(force = true) R.id.btnPushSubscriptionNotForce -> 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 -> MessageNotification.openNotificationChannelSetting( this, account, MessageNotification.TRACKING_NAME_DEFAULT ) R.id.btnNotificationStyleEditReply -> MessageNotification.openNotificationChannelSetting( this, account, MessageNotification.TRACKING_NAME_REPLY ) } } private fun showVisibility() { viewBinding.btnVisibility.text = Styler.getVisibilityString(this, account.isMisskey, visibility) } 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 { Styler.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 viewBinding.swMarkSensitive.isChecked = tmpDefaultSensitive } val tmpExpandMedia = json.string("reading:expand:media") if (tmpExpandMedia?.isNotEmpty() == true) { bChanged = true viewBinding.swNSFWOpen.isChecked = (tmpExpandMedia == "show_all") } val tmpExpandCW = json.boolean("reading:expand:spoilers") if (tmpExpandCW != null) { bChanged = true viewBinding.swExpandCW.isChecked = tmpExpandCW } } finally { loadingBusy = false if (bChanged) saveUIToData() } } } } /////////////////////////////////////////////////// private fun performAccountRemove() { AlertDialog.Builder(this) .setTitle(R.string.confirm) .setMessage(R.string.confirm_account_remove) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> accountRemove(account) finish() } .show() } /////////////////////////////////////////////////// private fun performAccessToken() { launchMain { runApiTask(account) { client -> client.authentication1( PrefS.spClientName(this@ActAccountSetting), forceUpdateClient = true ) }?.let { result -> val uri = result.string.mayUri() val error = result.error when { uri != null -> { val data = Intent() data.data = uri setResult(Activity.RESULT_OK, data) finish() } error != null -> { showToast(true, error) log.e("can't get oauth browser URL. $error") } } } } } 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…)" } viewBinding.apply { ivProfileAvatar.setErrorImage(defaultColorIcon(this@ActAccountSetting, questionId)) ivProfileAvatar.setDefaultImage(defaultColorIcon(this@ActAccountSetting, questionId)) etDisplayName.setText(loadingText) etNote.setText(loadingText) // 初期状態では編集不可能 arrayOf( btnProfileAvatar, btnProfileHeader, etDisplayName, btnDisplayName, etNote, btnNote, cbLocked, ).forEach { it.isEnabledAlpha = false } for (et in listEtFieldName) { et.setText(loadingText) et.isEnabledAlpha = false } for (et in listEtFieldValue) { et.setText(loadingText) et.isEnabledAlpha = false } // 疑似アカウントなら編集不可のまま if (!account.isPseudo) loadProfile() } } // サーバから情報をロードする private fun loadProfile() { launchMain { var resultAccount: TootAccount? = null runApiTask(account) { client -> if (account.isMisskey) { client.request( "/api/i", account.putMisskeyApiToken().toPostRequestBuilder() )?.also { result -> val jsonObject = result.jsonObject if (jsonObject != null) { resultAccount = TootParser(this, account).account(jsonObject) ?: return@runApiTask TootApiResult("TootAccount parse failed.") } } } else { val r0 = account.checkConfirmed(this, client) if (r0 == null || r0.error != null) return@runApiTask r0 client.request("/api/v1/accounts/verify_credentials") ?.also { result -> val jsonObject = result.jsonObject if (jsonObject != null) { resultAccount = TootParser(this@ActAccountSetting, account).account(jsonObject) ?: return@runApiTask TootApiResult("TootAccount parse failed.") } } } }?.let { result -> when (val account = resultAccount) { null -> showToast(true, result.error) else -> showProfile(account) } } } } private fun showProfile(src: TootAccount) { if (isDestroyed) return profileBusy = true try { viewBinding.ivProfileAvatar.setImageUrl( Styler.calcIconRound(viewBinding.ivProfileAvatar.layoutParams), src.avatar_static, src.avatar ) viewBinding.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, ) val displayName = src.display_name val name = decodeOptions.decodeEmoji(displayName) viewBinding.etDisplayName.setText(name) nameInvalidator.register(name) val noteString = src.source?.note ?: src.note val noteSpannable = when { account.isMisskey -> { SpannableString(noteString ?: "") } else -> { decodeOptions.decodeEmoji(noteString) } } viewBinding.etNote.setText(noteSpannable) noteInvalidator.register(noteSpannable) viewBinding.cbLocked.isChecked = src.locked // 編集可能にする viewBinding.apply { arrayOf( btnProfileAvatar, btnProfileHeader, etDisplayName, btnDisplayName, etNote, btnNote, cbLocked, ).forEach { it.isEnabledAlpha = true } } if (src.source?.fields != null) { val fields = src.source.fields listEtFieldName.forEachIndexed { i, et -> val handler = et.handler // may null if (handler != null) { // いつからかfields name にもカスタム絵文字が使えるようになった // https://github.com/tootsuite/mastodon/pull/11350 // しかし val text = decodeOptions.decodeEmoji( when { i >= fields.size -> "" else -> fields[i].name } ) et.setText(text) et.isEnabledAlpha = true val invalidator = NetworkEmojiInvalidator(handler, et) invalidator.register(text) } } listEtFieldValue.forEachIndexed { i, et -> val handler = et.handler // may null if (handler != null) { val text = decodeOptions.decodeEmoji( when { i >= fields.size -> "" else -> fields[i].value } ) et.setText(text) et.isEnabledAlpha = true val invalidator = NetworkEmojiInvalidator(handler, et) invalidator.register(text) } } } else { val fields = src.fields listEtFieldName.forEachIndexed { i, et -> val handler = et.handler // may null if (handler != null) { // いつからかfields name にもカスタム絵文字が使えるようになった // https://github.com/tootsuite/mastodon/pull/11350 val text = decodeOptions.decodeEmoji( when { fields == null || i >= fields.size -> "" else -> fields[i].name } ) et.setText(text) et.isEnabledAlpha = true val invalidator = NetworkEmojiInvalidator(handler, et) invalidator.register(text) } } listEtFieldValue.forEachIndexed { i, et -> val handler = et.handler // may null if (handler != null) { val text = decodeOptions.decodeHTML( when { fields == null || i >= fields.size -> "" else -> fields[i].value } ) et.text = text et.isEnabledAlpha = true val invalidator = NetworkEmojiInvalidator(handler, et) invalidator.register(text) } } } } 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.token_info?.string(TootApiClient.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 -> val jsonObject = result.jsonObject if (jsonObject != null) { ta = parseItem(::TootAttachment, ServiceType.MISSKEY, jsonObject) 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 viewBinding.cbLocked.isChecked = !value profileBusy = false } } } } } } private fun sendDisplayName(bConfirmed: Boolean = false) { val sv = viewBinding.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 = viewBinding.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 listEtFieldName.indices) { val k = listEtFieldName[i].text.toString().trim() val v = listEtFieldValue[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) { if (!permissionRequester.checkOrLaunch()) return val propName = when (permissionRequester) { prPickHeader -> "header" else -> "avatar" } val a = ActionsDialog() a.addAction(getString(R.string.pick_image)) { performAttachment(propName) } a.addAction(getString(R.string.image_capture)) { performCamera(propName) } a.show(this, null) } 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 fun updatePushSubscription(force: Boolean) { val wps = PushSubscriptionHelper(applicationContext, account, verbose = true) launchMain { runApiTask(account) { client -> wps.updateSubscription(client, force = force) }?.let { val log = wps.logString if (log.isNotEmpty()) { AlertDialog.Builder(this@ActAccountSetting) .setMessage(log) .setPositiveButton(R.string.close, null) .show() } } } } }