Merge branch 'post-creation' into 'master'

Refactor of post submission activity

See merge request pixeldroid/PixelDroid!493
This commit is contained in:
Matthieu 2022-10-30 22:40:57 +00:00
commit b36fadd76c
12 changed files with 754 additions and 426 deletions

View File

@ -8,6 +8,7 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply plugin: "kotlin-parcelize"
// Force latest version of Jacoco, initially done to resolve https://github.com/jacoco/jacoco/issues/1155

View File

@ -72,6 +72,9 @@
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<activity
android:name=".postCreation.PostSubmissionActivity">
</activity>
<activity
android:name=".profile.FollowsActivity"
android:screenOrientation="sensorPortrait"

View File

@ -13,7 +13,6 @@ import android.util.Log
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.View.GONE
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
@ -21,7 +20,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@ -108,41 +106,14 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
model.userMessageShown()
}
binding.addPhotoButton.isEnabled = uiState.addPhotoButtonEnabled
enableButton(uiState.postCreationSendButtonEnabled)
binding.uploadProgressBar.visibility =
if (uiState.uploadProgressBarVisible) VISIBLE else INVISIBLE
binding.uploadProgressBar.progress = uiState.uploadProgress
binding.uploadCompletedTextview.visibility =
if (uiState.uploadCompletedTextviewVisible) VISIBLE else INVISIBLE
binding.removePhotoButton.isEnabled = uiState.removePhotoButtonEnabled
binding.editPhotoButton.isEnabled = uiState.editPhotoButtonEnabled
binding.uploadError.visibility =
if (uiState.uploadErrorVisible) VISIBLE else INVISIBLE
binding.uploadErrorTextExplanation.visibility =
if (uiState.uploadErrorExplanationVisible) VISIBLE else INVISIBLE
binding.toolbarPostCreation.visibility =
if (uiState.isCarousel) VISIBLE else INVISIBLE
binding.carousel.layoutCarousel = uiState.isCarousel
binding.uploadErrorTextExplanation.text = uiState.uploadErrorExplanationText
}
}
}
binding.newPostDescriptionInputField.doAfterTextChanged {
model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text)
}
val existingDescription: String? = intent.getStringExtra(PICTURE_DESCRIPTION)
binding.newPostDescriptionInputField.setText(
// Set description from redraft if any, otherwise from the template
existingDescription ?: model.uiState.value.newPostDescriptionText
)
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
binding.carousel.apply {
layoutCarouselCallback = { model.becameCarousel(it)}
@ -156,13 +127,9 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
}
// get the description and send the post
binding.postCreationSendButton.setOnClickListener {
if (validatePost() && model.isNotEmpty()) model.upload()
}
// Button to retry image upload when it fails
binding.retryUploadButton.setOnClickListener {
model.resetUploadStatus()
model.upload()
if (validatePost() && model.isNotEmpty()) {
model.nextStep(binding.root.context)
}
}
binding.editPhotoButton.setOnClickListener {
@ -181,7 +148,6 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
}
}
binding.removePhotoButton.setOnClickListener {
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
model.removeAt(currentPosition)
@ -300,15 +266,7 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
private fun validatePost(): Boolean {
binding.postTextInputLayout.run {
val content = editText?.length() ?: 0
if (content > counterMaxLength) {
// error, too many characters
error = resources.getQuantityString(R.plurals.description_max_characters, counterMaxLength, counterMaxLength)
return false
}
}
if(model.getPhotoData().value?.all { it.videoEncodeProgress == null } == false){
if(model.getPhotoData().value?.all { !it.video || it.videoEncodeComplete } == false){
AlertDialog.Builder(this).apply {
setMessage(R.string.still_encoding)
setNegativeButton(android.R.string.ok) { _, _ -> }
@ -318,18 +276,6 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
return true
}
private fun enableButton(enable: Boolean = true){
binding.postCreationSendButton.isEnabled = enable
if(enable){
binding.postingProgressBar.visibility = GONE
binding.postCreationSendButton.visibility = VISIBLE
} else {
binding.postingProgressBar.visibility = VISIBLE
binding.postCreationSendButton.visibility = GONE
}
}
private val editResultContract: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){
result: ActivityResult? ->
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {

View File

@ -2,43 +2,48 @@ package org.pixeldroid.app.postCreation
import android.app.Application
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import android.provider.OpenableColumns
import android.text.Editable
import android.util.Log
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.jarsilio.android.scrambler.exceptions.UnsupportedFileFormatException
import com.jarsilio.android.scrambler.stripMetadata
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MultipartBody
import org.pixeldroid.app.MainActivity
import kotlinx.parcelize.Parcelize
import org.pixeldroid.app.R
import org.pixeldroid.media_editor.photoEdit.VideoEditActivity
import org.pixeldroid.media_editor.photoEdit.VideoEditActivity.RelativeCropPosition
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.fileExtension
import org.pixeldroid.app.utils.getMimeType
import retrofit2.HttpException
import org.pixeldroid.media_editor.photoEdit.VideoEditActivity
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.net.URI
import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.collections.MutableList
import kotlin.collections.MutableMap
import kotlin.collections.arrayListOf
import kotlin.collections.forEach
import kotlin.collections.get
import kotlin.collections.getOrNull
import kotlin.collections.indexOfFirst
import kotlin.collections.isNotEmpty
import kotlin.collections.mutableListOf
import kotlin.collections.mutableMapOf
import kotlin.collections.plus
import kotlin.collections.set
import kotlin.collections.toMutableList
import kotlin.math.ceil
@ -49,20 +54,13 @@ data class PostCreationActivityUiState(
val addPhotoButtonEnabled: Boolean = true,
val editPhotoButtonEnabled: Boolean = true,
val removePhotoButtonEnabled: Boolean = true,
val postCreationSendButtonEnabled: Boolean = true,
val isCarousel: Boolean = true,
val newPostDescriptionText: String = "",
)
val uploadProgressBarVisible: Boolean = false,
val uploadProgress: Int = 0,
val uploadCompletedTextviewVisible: Boolean = false,
val uploadErrorVisible: Boolean = false,
val uploadErrorExplanationText: String = "",
val uploadErrorExplanationVisible: Boolean = false,
)
@Parcelize
data class PhotoData(
var imageUri: Uri,
var size: Long,
@ -74,12 +72,12 @@ data class PhotoData(
var videoEncodeStabilizationFirstPass: Boolean? = null,
var videoEncodeComplete: Boolean = false,
var videoEncodeError: Boolean = false,
)
) : Parcelable
class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) {
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
MutableLiveData<MutableList<PhotoData>>().also {
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
}
}
@ -195,33 +193,6 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
photoData.value = photoData.value
}
fun resetUploadStatus() {
photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList()
}
fun setVideoEncodeAtPosition(uri: Uri, progress: Int?, stabilizationFirstPass: Boolean = false, error: Boolean = false) {
photoData.value?.indexOfFirst { it.imageUri == uri }?.let { position ->
photoData.value?.set(position,
photoData.value!![position].copy(
videoEncodeProgress = progress,
videoEncodeStabilizationFirstPass = stabilizationFirstPass,
videoEncodeError = error,
)
)
photoData.value = photoData.value
}
}
fun setUriAtPosition(uri: Uri, position: Int) {
photoData.value?.set(position, photoData.value!![position].copy(imageUri = uri))
photoData.value = photoData.value
}
fun setSizeAtPosition(imageSize: Long, position: Int) {
photoData.value?.set(position, photoData.value!![position].copy(size = imageSize))
photoData.value = photoData.value
}
fun removeAt(currentPosition: Int) {
photoData.value?.removeAt(currentPosition)
_uiState.update {
@ -233,183 +204,16 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
}
/**
* Uploads the images that are in the [photoData] array.
* Keeps track of them in the [PhotoData.progress] (for the upload progress), and the
* [PhotoData.uploadId] (for the list of ids of the uploads).
* Next step
*/
@OptIn(ExperimentalUnsignedTypes::class)
fun upload() {
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = false,
addPhotoButtonEnabled = false,
editPhotoButtonEnabled = false,
removePhotoButtonEnabled = false,
uploadCompletedTextviewVisible = false,
uploadErrorVisible = false,
uploadProgressBarVisible = true
)
}
for (data: PhotoData in getPhotoData().value ?: emptyList()) {
val extension = data.imageUri.fileExtension(getApplication<PixelDroidApplication>().contentResolver)
val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication<PixelDroidApplication>().cacheDir)
val imageUri = data.imageUri
val (strippedOrNot, size) = try {
val orientation = ExifInterface(getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
stripMetadata(imageUri, strippedImage, getApplication<PixelDroidApplication>().contentResolver)
// Restore EXIF orientation
val exifInterface = ExifInterface(strippedImage)
exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
exifInterface.saveAttributes()
Pair(strippedImage.inputStream(), strippedImage.length())
} catch (e: UnsupportedFileFormatException){
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
val imageInputStream = try {
getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
} catch (e: FileNotFoundException){
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
return
}
Pair(imageInputStream, data.size)
} catch (e: IOException){
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
return
}
val type = data.imageUri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
val imagePart = ProgressRequestBody(strippedOrNot, size, type)
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
.build()
val sub = imagePart.progressSubject
.subscribeOn(Schedulers.io())
.subscribe { percentage ->
data.progress = percentage.toInt()
_uiState.update { currentUiState ->
currentUiState.copy(
uploadProgress = getPhotoData().value!!.sumOf { it.progress ?: 0 } / getPhotoData().value!!.size
)
}
}
var postSub: Disposable? = null
val description = data.imageDescription?.let { MultipartBody.Part.createFormData("description", it) }
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val inter = api.mediaUpload(description, requestBody.parts[0])
postSub = inter
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ attachment: Attachment ->
data.progress = 0
data.uploadId = attachment.id!!
},
{ e: Throwable ->
_uiState.update { currentUiState ->
currentUiState.copy(
uploadErrorVisible = true,
uploadErrorExplanationText = if(e is HttpException){
getApplication<PixelDroidApplication>().getString(R.string.upload_error, e.code())
} else "",
uploadErrorExplanationVisible = e is HttpException,
)
}
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
e.printStackTrace()
postSub?.dispose()
sub.dispose()
},
{
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
data.progress = 100
if (getPhotoData().value!!.all { it.progress == 100 && it.uploadId != null }) {
_uiState.update { currentUiState ->
currentUiState.copy(
uploadProgressBarVisible = false,
uploadCompletedTextviewVisible = true
)
}
post()
}
postSub?.dispose()
sub.dispose()
}
)
}
}
private fun post() {
val description = uiState.value.newPostDescriptionText
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = false
)
}
viewModelScope.launch {
try {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
api.postStatus(
statusText = description,
media_ids = getPhotoData().value!!.mapNotNull { it.uploadId }.toList()
)
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_success),
Toast.LENGTH_SHORT).show()
val intent = Intent(getApplication(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
//TODO make the activity launch this instead (and surrounding toasts too)
getApplication<PixelDroidApplication>().startActivity(intent)
} catch (exception: IOException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
} catch (exception: HttpException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.response().toString() + exception.message().toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
}
}
fun nextStep(context: Context) {
val intent = Intent(context, PostSubmissionActivity::class.java)
intent.putExtra(PostSubmissionActivity.PHOTO_DATA, getPhotoData().value?.let { ArrayList(it) })
ContextCompat.startActivity(context, intent, null)
}
fun modifyAt(position: Int, data: Intent): Unit? {
val result: PhotoData = photoData.value?.getOrNull(position)?.run {
val result: PhotoData = photoData.value?.getOrNull(position)?.run {
if (video) {
val modified: Boolean = data.getBooleanExtra(VideoEditActivity.MODIFIED, false)
if(modified){
@ -446,10 +250,6 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
return Unit
}
fun newPostDescriptionChanged(text: Editable?) {
_uiState.update { it.copy(newPostDescriptionText = text.toString()) }
}
/**
* @param originalUri the Uri of the file you sent to be edited
* @param progress percentage of (this pass of) encoding that is done
@ -506,7 +306,6 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
tempFiles.forEach {
it.delete()
}
}
fun registerNewFFmpegSession(position: Uri, sessionId: Long) {
@ -520,11 +319,10 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
)
}
}
}
class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java).newInstance(application, clipdata, instance)
}
}
}

View File

@ -0,0 +1,188 @@
package org.pixeldroid.app.postCreation
import android.app.AlertDialog
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import androidx.activity.viewModels
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityPostSubmissionBinding
import org.pixeldroid.app.postCreation.PostCreationActivity.Companion.TEMP_FILES
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.setSquareImageFromURL
import java.io.File
class PostSubmissionActivity : BaseThemedWithoutBarActivity() {
companion object {
internal const val PICTURE_DESCRIPTION = "picture_description"
internal const val PHOTO_DATA = "photo_data"
}
private lateinit var accounts: List<UserDatabaseEntity>
private var selectedAccount: Int = -1
private lateinit var menu: Menu
private var user: UserDatabaseEntity? = null
private lateinit var instance: InstanceDatabaseEntity
private lateinit var binding: ActivityPostSubmissionBinding
private lateinit var model: PostSubmissionViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPostSubmissionBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setTitle(R.string.add_details)
user = db.userDao().getActiveUser()
accounts = db.userDao().getAll()
instance = user?.run {
db.instanceDao().getAll().first { instanceDatabaseEntity ->
instanceDatabaseEntity.uri.contains(instance_uri)
}
} ?: InstanceDatabaseEntity("", "")
val photoData = intent.getParcelableArrayListExtra<PhotoData>(PHOTO_DATA) as ArrayList<PhotoData>?
val _model: PostSubmissionViewModel by viewModels {
PostSubmissionViewModelFactory(
application,
photoData!!
)
}
model = _model
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.uiState.collect { uiState ->
uiState.userMessage?.let {
AlertDialog.Builder(binding.root.context).apply {
setMessage(it)
setNegativeButton(android.R.string.ok) { _, _ -> }
}.show()
// Notify the ViewModel the message is displayed
model.userMessageShown()
}
enableButton(uiState.postCreationSendButtonEnabled)
binding.uploadProgressBar.visibility =
if (uiState.uploadProgressBarVisible) VISIBLE else INVISIBLE
binding.uploadProgressBar.progress = uiState.uploadProgress
binding.uploadCompletedTextview.visibility =
if (uiState.uploadCompletedTextviewVisible) VISIBLE else INVISIBLE
binding.uploadError.visibility =
if (uiState.uploadErrorVisible) VISIBLE else INVISIBLE
binding.uploadErrorTextExplanation.visibility =
if (uiState.uploadErrorExplanationVisible) VISIBLE else INVISIBLE
selectedAccount = accounts.indexOf(uiState.chosenAccount)
binding.uploadErrorTextExplanation.text = uiState.uploadErrorExplanationText
}
}
}
binding.newPostDescriptionInputField.doAfterTextChanged {
model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text)
}
binding.nsfwSwitch.setOnCheckedChangeListener { _, isChecked ->
model.updateNSFW(isChecked)
}
val existingDescription: String? = intent.getStringExtra(PICTURE_DESCRIPTION)
binding.newPostDescriptionInputField.setText(
// Set description from redraft if any, otherwise from the template
existingDescription ?: model.uiState.value.newPostDescriptionText
)
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
setSquareImageFromURL(View(applicationContext), photoData!![0].imageUri.toString(), binding.postPreview)
// get the description and send the post
binding.postCreationSendButton.setOnClickListener {
if (validatePost()) model.upload()
}
// Button to retry image upload when it fails
binding.retryUploadButton.setOnClickListener {
model.resetUploadStatus()
model.upload()
}
// Clean up temporary files, if any
val tempFiles = intent.getStringArrayExtra(TEMP_FILES)
tempFiles?.asList()?.forEach {
val file = File(binding.root.context.cacheDir, it)
model.trackTempFile(file)
}
}
override fun onCreateOptionsMenu(newMenu: Menu): Boolean {
menuInflater.inflate(R.menu.post_submission_account_menu, newMenu)
menu = newMenu
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId){
R.id.action_switch_accounts -> {
AlertDialog.Builder(this).apply {
setIcon(R.drawable.material_drawer_ico_account)
setTitle(R.string.switch_accounts)
setSingleChoiceItems(accounts.map { it.username + " (${it.fullHandle})" }.toTypedArray(), selectedAccount) { dialog, which ->
if(selectedAccount != which){
model.chooseAccount(accounts[which])
}
dialog.dismiss()
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun validatePost(): Boolean {
binding.postTextInputLayout.run {
val content = editText?.length() ?: 0
if (content > counterMaxLength) {
// error, too many characters
error = resources.getQuantityString(R.plurals.description_max_characters, counterMaxLength, counterMaxLength)
return false
}
}
return true
}
private fun enableButton(enable: Boolean = true){
binding.postCreationSendButton.isEnabled = enable
if(enable){
binding.postingProgressBar.visibility = GONE
binding.postCreationSendButton.visibility = VISIBLE
} else {
binding.postingProgressBar.visibility = VISIBLE
binding.postCreationSendButton.visibility = GONE
}
}
}

View File

@ -0,0 +1,314 @@
package org.pixeldroid.app.postCreation
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.text.Editable
import android.util.Log
import android.widget.Toast
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.*
import androidx.preference.PreferenceManager
import com.jarsilio.android.scrambler.exceptions.UnsupportedFileFormatException
import com.jarsilio.android.scrambler.stripMetadata
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MultipartBody
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.fileExtension
import org.pixeldroid.app.utils.getMimeType
import retrofit2.HttpException
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.net.URI
import javax.inject.Inject
// Models the UI state for the PostCreationActivity
data class PostSubmissionActivityUiState(
val userMessage: String? = null,
val postCreationSendButtonEnabled: Boolean = true,
val newPostDescriptionText: String = "",
val nsfw: Boolean = false,
val chosenAccount: UserDatabaseEntity? = null,
val uploadProgressBarVisible: Boolean = false,
val uploadProgress: Int = 0,
val uploadCompletedTextviewVisible: Boolean = false,
val uploadErrorVisible: Boolean = false,
val uploadErrorExplanationText: String = "",
val uploadErrorExplanationVisible: Boolean = false,
)
class PostSubmissionViewModel(application: Application, photodata: ArrayList<PhotoData>? = null) : AndroidViewModel(application) {
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
MutableLiveData<MutableList<PhotoData>>().also {
if (photodata != null) {
it.value = photodata.toMutableList()
}
}
}
@Inject
lateinit var apiHolder: PixelfedAPIHolder
private val _uiState: MutableStateFlow<PostSubmissionActivityUiState>
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
val sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(application)
val initialDescription = sharedPreferences.getString("prefill_description", "") ?: ""
_uiState = MutableStateFlow(PostSubmissionActivityUiState(newPostDescriptionText = initialDescription))
}
val uiState: StateFlow<PostSubmissionActivityUiState> = _uiState
// Map photoData indexes to FFmpeg Session IDs
private val sessionMap: MutableMap<Uri, Long> = mutableMapOf()
// Keep track of temporary files to delete them (avoids filling cache super fast with videos)
private val tempFiles: java.util.ArrayList<File> = java.util.ArrayList()
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
fun getPhotoData(): LiveData<MutableList<PhotoData>> = photoData
fun resetUploadStatus() {
photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList()
}
/**
* Uploads the images that are in the [photoData] array.
* Keeps track of them in the [PhotoData.progress] (for the upload progress), and the
* [PhotoData.uploadId] (for the list of ids of the uploads).
*/
@OptIn(ExperimentalUnsignedTypes::class)
fun upload() {
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = false,
uploadCompletedTextviewVisible = false,
uploadErrorVisible = false,
uploadProgressBarVisible = true
)
}
for (data: PhotoData in getPhotoData().value ?: emptyList()) {
val extension = data.imageUri.fileExtension(getApplication<PixelDroidApplication>().contentResolver)
val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication<PixelDroidApplication>().cacheDir)
val imageUri = data.imageUri
val (strippedOrNot, size) = try {
val orientation = ExifInterface(getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
stripMetadata(imageUri, strippedImage, getApplication<PixelDroidApplication>().contentResolver)
// Restore EXIF orientation
val exifInterface = ExifInterface(strippedImage)
exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
exifInterface.saveAttributes()
Pair(strippedImage.inputStream(), strippedImage.length())
} catch (e: UnsupportedFileFormatException){
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
val imageInputStream = try {
getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
} catch (e: FileNotFoundException){
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
return
}
Pair(imageInputStream, data.size)
} catch (e: IOException){
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
return
}
val type = data.imageUri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
val imagePart = ProgressRequestBody(strippedOrNot, size, type)
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
.build()
val sub = imagePart.progressSubject
.subscribeOn(Schedulers.io())
.subscribe { percentage ->
data.progress = percentage.toInt()
_uiState.update { currentUiState ->
currentUiState.copy(
uploadProgress = getPhotoData().value!!.sumOf { it.progress ?: 0 } / getPhotoData().value!!.size
)
}
}
var postSub: Disposable? = null
val description = data.imageDescription?.let { MultipartBody.Part.createFormData("description", it) }
//Ugly temporary account switching, but it works well enough for now
val api = uiState.value.chosenAccount?.let {
apiHolder.setToCurrentUser(it)
} ?: apiHolder.api ?: apiHolder.setToCurrentUser()
val inter = api.mediaUpload(description, requestBody.parts[0])
apiHolder.api = null
postSub = inter
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ attachment: Attachment ->
data.progress = 0
data.uploadId = attachment.id!!
},
{ e: Throwable ->
_uiState.update { currentUiState ->
currentUiState.copy(
uploadErrorVisible = true,
uploadErrorExplanationText = if(e is HttpException){
getApplication<PixelDroidApplication>().getString(R.string.upload_error, e.code())
} else "",
uploadErrorExplanationVisible = e is HttpException,
)
}
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
e.printStackTrace()
postSub?.dispose()
sub.dispose()
},
{
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
data.progress = 100
if (getPhotoData().value!!.all { it.progress == 100 && it.uploadId != null }) {
_uiState.update { currentUiState ->
currentUiState.copy(
uploadProgressBarVisible = false,
uploadCompletedTextviewVisible = true
)
}
post()
}
postSub?.dispose()
sub.dispose()
}
)
}
}
private fun post() {
val description = uiState.value.newPostDescriptionText
//TODO investigate why this works but booleans don't
val nsfw = if(uiState.value.nsfw) 1 else 0
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = false
)
}
viewModelScope.launch {
try {
//Ugly temporary account switching, but it works well enough for now
val api = uiState.value.chosenAccount?.let {
apiHolder.setToCurrentUser(it)
} ?: apiHolder.api ?: apiHolder.setToCurrentUser()
api.postStatus(
statusText = description,
media_ids = getPhotoData().value!!.mapNotNull { it.uploadId }.toList(),
sensitive = nsfw
)
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_success),
Toast.LENGTH_SHORT).show()
val intent = Intent(getApplication(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
//TODO make the activity launch this instead (and surrounding toasts too)
getApplication<PixelDroidApplication>().startActivity(intent)
} catch (exception: IOException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
} catch (exception: HttpException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.response().toString() + exception.message().toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
} finally {
apiHolder.api = null
}
}
}
fun newPostDescriptionChanged(text: Editable?) {
_uiState.update { it.copy(newPostDescriptionText = text.toString()) }
}
fun trackTempFile(file: File) {
tempFiles.add(file)
}
override fun onCleared() {
super.onCleared()
tempFiles.forEach {
it.delete()
}
}
fun updateNSFW(checked: Boolean) { _uiState.update { it.copy(nsfw = checked) } }
fun chooseAccount(which: UserDatabaseEntity) {
_uiState.update { it.copy(chosenAccount = which) }
}
}
class PostSubmissionViewModelFactory(val application: Application, val photoData: ArrayList<PhotoData>) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java, ArrayList::class.java).newInstance(application, photoData)
}
}

View File

@ -153,18 +153,18 @@ interface PixelfedAPI {
@FormUrlEncoded
@POST("/api/v1/statuses")
suspend fun postStatus(
@Field("status") statusText : String,
@Field("in_reply_to_id") in_reply_to_id : String? = null,
@Field("media_ids[]") media_ids : List<String> = emptyList(),
@Field("poll[options][]") poll_options : List<String>? = null,
@Field("poll[expires_in]") poll_expires : List<String>? = null,
@Field("poll[multiple]") poll_multiple : List<String>? = null,
@Field("poll[hide_totals]") poll_hideTotals : List<String>? = null,
@Field("sensitive") sensitive : Boolean? = null,
@Field("spoiler_text") spoiler_text : String? = null,
@Field("visibility") visibility : String = "public",
@Field("scheduled_at") scheduled_at : String? = null,
@Field("language") language : String? = null
@Field("status") statusText: String,
@Field("in_reply_to_id") in_reply_to_id: String? = null,
@Field("media_ids[]") media_ids: List<String> = emptyList(),
@Field("poll[options][]") poll_options: List<String>? = null,
@Field("poll[expires_in]") poll_expires: List<String>? = null,
@Field("poll[multiple]") poll_multiple: List<String>? = null,
@Field("poll[hide_totals]") poll_hideTotals: List<String>? = null,
@Field("sensitive") sensitive: Int? = null,
@Field("spoiler_text") spoiler_text: String? = null,
@Field("visibility") visibility: String = "public",
@Field("scheduled_at") scheduled_at: String? = null,
@Field("language") language: String? = null
) : Status
@DELETE("/api/v1/statuses/{id}")

View File

@ -9,6 +9,7 @@ import org.pixeldroid.app.utils.BaseFragment
import dagger.Component
import org.pixeldroid.app.postCreation.PostCreationViewModel
import org.pixeldroid.app.profile.EditProfileViewModel
import org.pixeldroid.app.postCreation.PostSubmissionViewModel
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import javax.inject.Singleton
@ -22,6 +23,7 @@ interface ApplicationComponent {
fun inject(notificationsWorker: NotificationsWorker)
fun inject(postCreationViewModel: PostCreationViewModel)
fun inject(editProfileViewModel: EditProfileViewModel)
fun inject(postSubmissionViewModel: PostSubmissionViewModel)
val context: Context?
val application: Application?

View File

@ -6,149 +6,35 @@
android:layout_height="match_parent"
tools:context=".postCreation.PostCreationActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/upload_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<TextView
android:id="@+id/upload_error_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorError"
android:text="@string/media_upload_failed"
android:textColor="?attr/colorOnError"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:drawableStartCompat="@drawable/cloud_off_24"
app:drawableTint="?attr/colorOnError" />
<TextView
android:id="@+id/upload_error_text_explanation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorError"
tools:text="Error code returned by server: 413"
android:textColor="?attr/colorOnError"
android:textSize="20sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_view"
tools:visibility="visible" />
<Button
android:id="@+id/retry_upload_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintEnd_toEndOf="@id/upload_error_text_view"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="@id/upload_error_text_view"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_explanation" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.pixeldroid.app.postCreation.carousel.ImageCarousel
android:id="@+id/carousel"
android:layout_width="match_parent"
android:layout_height="0dp"
app:showCaption="true"
app:layout_constraintBottom_toBottomOf="@+id/uploadProgressBar"
app:layout_constraintBottom_toTopOf="@+id/buttonConstraints"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/upload_completed_textview"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:text="@string/media_upload_completed"
android:textColor="@android:color/holo_green_light"
android:textSize="16sp"
android:visibility="invisible"
app:drawableStartCompat="@drawable/cloud_done_24"
app:drawableTint="@android:color/holo_green_light"
app:layout_constraintBottom_toTopOf="@id/postTextInputLayout"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonConstraints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/postTextInputLayout">
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/post_creation_send_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:enabled="true"
android:text="@string/post"
android:text="@string/upload_next_step"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/posting_progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/uploadProgressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/postTextInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/postTextInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/description"
app:counterEnabled="true"
android:paddingStart="15dp"
android:paddingEnd="15dp"
app:errorEnabled="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/buttonConstraints"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/new_post_description_input_field"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ems="10"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbarPostCreation"
android:layout_width="match_parent"
@ -198,7 +84,6 @@
app:layout_constraintStart_toEndOf="@+id/removePhotoButton"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/addPhotoButton"
android:layout_width="wrap_content"

View File

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".postCreation.PostSubmissionActivity">
<ImageView
android:id="@+id/post_preview"
android:layout_margin="20dp"
android:layout_width="88dp"
android:layout_height="88dp"
android:contentDescription="@string/post_preview"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/upload_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<TextView
android:id="@+id/upload_error_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorError"
android:text="@string/media_upload_failed"
android:textColor="?attr/colorOnError"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:drawableStartCompat="@drawable/cloud_off_24"
app:drawableTint="?attr/colorOnError" />
<TextView
android:id="@+id/upload_error_text_explanation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorError"
tools:text="Error code returned by server: 413"
android:textColor="?attr/colorOnError"
android:textSize="20sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_view"
tools:visibility="visible" />
<Button
android:id="@+id/retry_upload_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintEnd_toEndOf="@id/upload_error_text_view"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="@id/upload_error_text_view"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_explanation" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/upload_completed_textview"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:text="@string/media_upload_completed"
android:textColor="@android:color/holo_green_light"
android:textSize="16sp"
android:visibility="invisible"
app:drawableStartCompat="@drawable/cloud_done_24"
app:drawableTint="@android:color/holo_green_light"
app:layout_constraintBottom_toTopOf="@+id/buttonConstraints"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonConstraints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/post_creation_send_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:enabled="true"
android:text="@string/post"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/posting_progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/uploadProgressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/upload_completed_textview"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/postTextInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/description"
android:paddingStart="15dp"
android:paddingTop="8dp"
android:paddingEnd="15dp"
app:counterEnabled="true"
app:errorEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_preview">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/new_post_description_input_field"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ems="10"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/nsfwSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="@+id/post_preview"
app:layout_constraintStart_toEndOf="@+id/post_preview"
app:layout_constraintTop_toTopOf="@+id/post_preview" />
<TextView
android:id="@+id/privateTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/contains_nsfw"
android:textStyle="bold"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@+id/nsfwSwitch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/nsfwSwitch"
app:layout_constraintTop_toTopOf="@+id/nsfwSwitch" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_switch_accounts"
android:orderInCategory="100"
android:title="@string/switch_accounts"
android:icon="@drawable/material_drawer_ico_account"
app:showAsAction="ifRoom"/>
</menu>

View File

@ -103,6 +103,8 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="upload_post_error">Post upload error</string>
<string name="description">Description…</string>
<string name="post">Post</string>
<string name="upload_next_step">Next step</string>
<string name="add_details">Add some details</string>
<string name="add_photo">Add a photo</string>
<string name="post_image">One of the images in the post</string>
<string name="switch_to_grid">Switch to grid view</string>
@ -322,4 +324,6 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="profile_saved">Changes saved!</string>
<string name="error_profile">Something went wrong. Tap to retry</string>
<string name="change_profile_picture">Change your profile picture</string>
<string name="contains_nsfw">Contains NSFW media</string>
<string name="switch_accounts">Switch accounts</string>
</resources>