chinwag-android/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt

510 lines
18 KiB
Kotlin
Raw Normal View History

/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.MediaUtils
import com.squareup.picasso.Picasso
import com.theartofdev.edmodo.cropper.CropImage
import kotlinx.android.synthetic.main.activity_edit_profile.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.*
import java.util.*
import javax.inject.Inject
private const val TAG = "EditProfileActivity"
private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png"
private const val KEY_OLD_DISPLAY_NAME = "OLD_DISPLAY_NAME"
private const val KEY_OLD_NOTE = "OLD_NOTE"
2018-03-28 05:46:53 +11:00
private const val KEY_OLD_LOCKED = "OLD_LOCKED"
private const val KEY_IS_SAVING = "IS_SAVING"
private const val KEY_CURRENTLY_PICKING = "CURRENTLY_PICKING"
private const val KEY_AVATAR_CHANGED = "AVATAR_CHANGED"
private const val KEY_HEADER_CHANGED = "HEADER_CHANGED"
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
2018-03-28 05:13:24 +11:00
private const val AVATAR_SIZE = 400
private const val HEADER_WIDTH = 700
private const val HEADER_HEIGHT = 335
class EditProfileActivity : BaseActivity(), Injectable {
private var oldDisplayName: String? = null
private var oldNote: String? = null
2018-03-28 05:46:53 +11:00
private var oldLocked: Boolean = false
private var isSaving: Boolean = false
private var currentlyPicking: PickType = PickType.NOTHING
private var avatarChanged: Boolean = false
private var headerChanged: Boolean = false
@Inject
lateinit var mastodonApi: MastodonApi
private enum class PickType {
NOTHING,
AVATAR,
HEADER
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit_profile)
setSupportActionBar(toolbar)
supportActionBar?.run {
setTitle(R.string.title_edit_profile)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
}
savedInstanceState?.let {
oldDisplayName = it.getString(KEY_OLD_DISPLAY_NAME)
oldNote = it.getString(KEY_OLD_NOTE)
2018-03-28 05:46:53 +11:00
oldLocked = it.getBoolean(KEY_OLD_LOCKED)
isSaving = it.getBoolean(KEY_IS_SAVING)
currentlyPicking = it.getSerializable(KEY_CURRENTLY_PICKING) as PickType
avatarChanged = it.getBoolean(KEY_AVATAR_CHANGED)
headerChanged = it.getBoolean(KEY_HEADER_CHANGED)
if (avatarChanged) {
val avatar = BitmapFactory.decodeFile(getCacheFileForName(AVATAR_FILE_NAME).absolutePath)
avatarPreview.setImageBitmap(avatar)
}
if (headerChanged) {
val header = BitmapFactory.decodeFile(getCacheFileForName(HEADER_FILE_NAME).absolutePath)
headerPreview.setImageBitmap(header)
}
}
avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) }
headerButton.setOnClickListener { onMediaPick(PickType.HEADER) }
avatarPreview.setOnClickListener {
avatarPreview.setImageBitmap(null)
avatarPreview.visibility = View.INVISIBLE
}
headerPreview.setOnClickListener {
headerPreview.setImageBitmap(null)
headerPreview.visibility = View.INVISIBLE
}
mastodonApi.accountVerifyCredentials().enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
if (!response.isSuccessful) {
onAccountVerifyCredentialsFailed()
return
}
val me = response.body()
oldDisplayName = me!!.displayName
oldNote = me.source?.note
2018-03-28 05:46:53 +11:00
oldLocked = me.locked
displayNameEditText.setText(oldDisplayName)
noteEditText.setText(oldNote)
2018-03-28 05:46:53 +11:00
lockedCheckBox.isChecked = oldLocked
if (!avatarChanged) {
Picasso.with(avatarPreview.context)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.into(avatarPreview)
}
if (!headerChanged) {
Picasso.with(headerPreview.context)
.load(me.header)
.into(headerPreview)
}
}
override fun onFailure(call: Call<Account>, t: Throwable) {
onAccountVerifyCredentialsFailed()
}
})
}
override fun onSaveInstanceState(outState: Bundle) {
outState.run {
putString(KEY_OLD_DISPLAY_NAME, oldDisplayName)
putString(KEY_OLD_NOTE, oldNote)
2018-03-28 05:46:53 +11:00
putBoolean(KEY_OLD_LOCKED, oldLocked)
putBoolean(KEY_IS_SAVING, isSaving)
putSerializable(KEY_CURRENTLY_PICKING, currentlyPicking)
putBoolean(KEY_AVATAR_CHANGED, avatarChanged)
putBoolean(KEY_HEADER_CHANGED, headerChanged)
}
super.onSaveInstanceState(outState)
}
private fun onAccountVerifyCredentialsFailed() {
Log.e(TAG, "The account failed to load.")
}
private fun onMediaPick(pickType: PickType) {
if (currentlyPicking != PickType.NOTHING) {
// Ignore inputs if another pick operation is still occurring.
return
}
currentlyPicking = pickType
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
} else {
initiateMediaPicking()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking()
} else {
endMediaPicking()
Snackbar.make(avatarButton, R.string.error_media_upload_permission, Snackbar.LENGTH_LONG).show()
}
}
}
}
private fun initiateMediaPicking() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
when (currentlyPicking) {
EditProfileActivity.PickType.AVATAR -> {
startActivityForResult(intent, AVATAR_PICK_RESULT)
}
EditProfileActivity.PickType.HEADER -> {
startActivityForResult(intent, HEADER_PICK_RESULT)
}
2018-04-22 22:11:26 +10:00
EditProfileActivity.PickType.NOTHING -> { /* do nothing */ }
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.edit_profile_toolbar, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_save -> {
save()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun save() {
if (isSaving || currentlyPicking != PickType.NOTHING) {
return
}
isSaving = true
saveProgressBar.visibility = View.VISIBLE
val newDisplayName = displayNameEditText.text.toString()
val displayName = if (oldDisplayName == newDisplayName) {
null
} else {
RequestBody.create(MultipartBody.FORM, newDisplayName)
}
val newNote = noteEditText.text.toString()
val note = if (oldNote == newNote) {
null
} else {
RequestBody.create(MultipartBody.FORM, newNote)
}
2018-03-28 05:46:53 +11:00
val newLocked = lockedCheckBox.isChecked
val locked = if (oldLocked == newLocked) {
null
} else {
RequestBody.create(MultipartBody.FORM, newLocked.toString())
}
val avatar = if (avatarChanged) {
val avatarBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(AVATAR_FILE_NAME))
MultipartBody.Part.createFormData("avatar", getFileName(), avatarBody)
} else {
null
}
val header = if (headerChanged) {
val headerBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(HEADER_FILE_NAME))
MultipartBody.Part.createFormData("header", getFileName(), headerBody)
} else {
null
}
2018-03-28 05:46:53 +11:00
if (displayName == null && note == null && locked == null && avatar == null && header == null) {
/** if nothing has changed, there is no need to make a network request */
Account activity redesign (#662) * Refactor-all-the-things version of the fix for issue #573 * Migrate SpanUtils to kotlin because why not * Minimal fix for issue #573 * Add tests for compose spanning * Clean up code suggestions * Make FakeSpannable.getSpans implementation less awkward * Add secondary validation pass for urls * Address code review feedback * Fixup type filtering in FakeSpannable again * Make all mentions in compose activity use the default link color * new layout for AccountActivity * fix the light theme * convert AccountActivity to Kotlin * introduce AccountViewModel * Merge branch 'master' into account-activity-redesign # Conflicts: # app/src/main/java/com/keylesspalace/tusky/AccountActivity.java * add Bot badge to profile * parse custom emojis in usernames * add possibility to cancel follow request * add third tab on profiles * add account fields to profile * add support for moved accounts * set click listener on account moved view * fix tests * use 24dp as statusbar size * add ability to hide reblogs from followed accounts * add button to edit own account to AccountActivity * set toolbar top margin programmatically * fix crash * add shadow behind statusbar * introduce ViewExtensions to clean up code * move code out of offsetChangedListener for perf reasons * clean up stuff * add error handling * improve type safety * fix ConstraintLayout warning * remove unneeded ressources * fix event dispatching * fix crash in event handling * set correct emoji on title * improve some things * wrap follower/foillowing/status views
2018-06-18 21:26:18 +10:00
setResult(Activity.RESULT_OK)
finish()
return
}
2018-03-28 05:46:53 +11:00
mastodonApi.accountUpdateCredentials(displayName, note, locked, avatar, header).enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
if (!response.isSuccessful) {
onSaveFailure()
return
}
privatePreferences.edit()
.putBoolean("refreshProfileHeader", true)
.apply()
Account activity redesign (#662) * Refactor-all-the-things version of the fix for issue #573 * Migrate SpanUtils to kotlin because why not * Minimal fix for issue #573 * Add tests for compose spanning * Clean up code suggestions * Make FakeSpannable.getSpans implementation less awkward * Add secondary validation pass for urls * Address code review feedback * Fixup type filtering in FakeSpannable again * Make all mentions in compose activity use the default link color * new layout for AccountActivity * fix the light theme * convert AccountActivity to Kotlin * introduce AccountViewModel * Merge branch 'master' into account-activity-redesign # Conflicts: # app/src/main/java/com/keylesspalace/tusky/AccountActivity.java * add Bot badge to profile * parse custom emojis in usernames * add possibility to cancel follow request * add third tab on profiles * add account fields to profile * add support for moved accounts * set click listener on account moved view * fix tests * use 24dp as statusbar size * add ability to hide reblogs from followed accounts * add button to edit own account to AccountActivity * set toolbar top margin programmatically * fix crash * add shadow behind statusbar * introduce ViewExtensions to clean up code * move code out of offsetChangedListener for perf reasons * clean up stuff * add error handling * improve type safety * fix ConstraintLayout warning * remove unneeded ressources * fix event dispatching * fix crash in event handling * set correct emoji on title * improve some things * wrap follower/foillowing/status views
2018-06-18 21:26:18 +10:00
setResult(Activity.RESULT_OK)
finish()
}
override fun onFailure(call: Call<Account>, t: Throwable) {
onSaveFailure()
}
})
}
private fun onSaveFailure() {
isSaving = false
Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
saveProgressBar.visibility = View.GONE
}
private fun beginMediaPicking() {
when (currentlyPicking) {
EditProfileActivity.PickType.AVATAR -> {
avatarProgressBar.visibility = View.VISIBLE
avatarPreview.visibility = View.INVISIBLE
avatarButton.setImageDrawable(null)
}
EditProfileActivity.PickType.HEADER -> {
headerProgressBar.visibility = View.VISIBLE
headerPreview.visibility = View.INVISIBLE
headerButton.setImageDrawable(null)
}
2018-04-22 22:11:26 +10:00
EditProfileActivity.PickType.NOTHING -> { /* do nothing */ }
}
}
private fun endMediaPicking() {
avatarProgressBar.visibility = View.GONE
headerProgressBar.visibility = View.GONE
currentlyPicking = PickType.NOTHING
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
AVATAR_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
} else {
endMediaPicking()
}
}
HEADER_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
} else {
endMediaPicking()
}
}
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data)
when (resultCode) {
Activity.RESULT_OK -> beginResize(result.uri)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking()
}
}
}
}
private fun beginResize(uri: Uri) {
beginMediaPicking()
val width: Int
val height: Int
val cacheFile: File
when (currentlyPicking) {
EditProfileActivity.PickType.AVATAR -> {
width = AVATAR_SIZE
height = AVATAR_SIZE
cacheFile = getCacheFileForName(AVATAR_FILE_NAME)
}
EditProfileActivity.PickType.HEADER -> {
width = HEADER_WIDTH
height = HEADER_HEIGHT
cacheFile = getCacheFileForName(HEADER_FILE_NAME)
}
else -> {
throw AssertionError("PickType not set.")
}
}
ResizeImageTask(contentResolver, width, height, cacheFile, object : ResizeImageTask.Listener {
override fun onSuccess(resizedImage: Bitmap?) {
val pickType = currentlyPicking
endMediaPicking()
when (pickType) {
EditProfileActivity.PickType.AVATAR -> {
avatarPreview.setImageBitmap(resizedImage)
avatarPreview.visibility = View.VISIBLE
avatarButton.setImageResource(R.drawable.ic_add_a_photo_32dp)
avatarChanged = true
}
EditProfileActivity.PickType.HEADER -> {
headerPreview.setImageBitmap(resizedImage)
headerPreview.visibility = View.VISIBLE
headerButton.setImageResource(R.drawable.ic_add_a_photo_32dp)
headerChanged = true
}
2018-04-22 22:11:26 +10:00
EditProfileActivity.PickType.NOTHING -> { /* do nothing */ }
}
}
override fun onFailure() {
onResizeFailure()
}
}).execute(uri)
}
private fun onResizeFailure() {
Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking()
}
private fun getCacheFileForName(filename: String): File {
return File(cacheDir, filename)
}
private fun getFileName(): String {
return java.lang.Long.toHexString(Random().nextLong())
}
private class ResizeImageTask(private val contentResolver: ContentResolver,
private val resizeWidth: Int,
private val resizeHeight: Int,
private val cacheFile: File,
private val listener: Listener) : AsyncTask<Uri, Void, Boolean>() {
private var resultBitmap: Bitmap? = null
override fun doInBackground(vararg uris: Uri): Boolean? {
val uri = uris[0]
val sourceBitmap = MediaUtils.getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
if (sourceBitmap == null) {
return false
}
//dont upscale image if its smaller than the desired size
val bitmap =
if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) {
sourceBitmap
} else {
Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true)
}
resultBitmap = bitmap
if (!saveBitmapToFile(bitmap, cacheFile)) {
return false
}
if (isCancelled) {
return false
}
return true
}
override fun onPostExecute(successful: Boolean) {
if (successful) {
listener.onSuccess(resultBitmap)
} else {
listener.onFailure()
}
}
fun saveBitmapToFile(bitmap: Bitmap, file: File): Boolean {
val outputStream: OutputStream
try {
outputStream = FileOutputStream(file)
} catch (e: FileNotFoundException) {
Log.w(TAG, Log.getStackTraceString(e))
return false
}
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
IOUtils.closeQuietly(outputStream)
return true
}
internal interface Listener {
fun onSuccess(resizedImage: Bitmap?)
fun onFailure()
}
}
}