diff --git a/app/build.gradle b/app/build.gradle
index 58c480f3..033aa3f0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b77f5f17..8334af4a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -72,6 +72,9 @@
+
+
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 = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){
result: ActivityResult? ->
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {
diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt
index ef640f83..47d54d9c 100644
--- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt
+++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt
@@ -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> by lazy {
MutableLiveData>().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().contentResolver)
-
- val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication().cacheDir)
-
- val imageUri = data.imageUri
-
- val (strippedOrNot, size) = try {
- val orientation = ExifInterface(getApplication().contentResolver.openInputStream(imageUri)!!).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
-
- stripMetadata(imageUri, strippedImage, getApplication().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().contentResolver.openInputStream(imageUri)!!
- } catch (e: FileNotFoundException){
- _uiState.update { currentUiState ->
- currentUiState.copy(
- userMessage = getApplication().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().getString(R.string.file_not_found,
- data.imageUri)
- )
- }
- return
- }
-
- val type = data.imageUri.getMimeType(getApplication().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().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().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().startActivity(intent)
- } catch (exception: IOException) {
- Toast.makeText(getApplication(), getApplication().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().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 create(modelClass: Class): T {
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java).newInstance(application, clipdata, instance)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionActivity.kt
new file mode 100644
index 00000000..6ead56d7
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionActivity.kt
@@ -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
+ 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(PHOTO_DATA) as ArrayList?
+
+ 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
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionViewModel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionViewModel.kt
new file mode 100644
index 00000000..816046f1
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionViewModel.kt
@@ -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? = null) : AndroidViewModel(application) {
+ private val photoData: MutableLiveData> by lazy {
+ MutableLiveData>().also {
+ if (photodata != null) {
+ it.value = photodata.toMutableList()
+ }
+ }
+ }
+
+ @Inject
+ lateinit var apiHolder: PixelfedAPIHolder
+
+ private val _uiState: MutableStateFlow
+
+ 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 = _uiState
+
+ // Map photoData indexes to FFmpeg Session IDs
+ private val sessionMap: MutableMap = mutableMapOf()
+ // Keep track of temporary files to delete them (avoids filling cache super fast with videos)
+ private val tempFiles: java.util.ArrayList = java.util.ArrayList()
+
+ fun userMessageShown() {
+ _uiState.update { currentUiState ->
+ currentUiState.copy(userMessage = null)
+ }
+ }
+
+ fun getPhotoData(): LiveData> = 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().contentResolver)
+
+ val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication().cacheDir)
+
+ val imageUri = data.imageUri
+
+ val (strippedOrNot, size) = try {
+ val orientation = ExifInterface(getApplication().contentResolver.openInputStream(imageUri)!!).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
+
+ stripMetadata(imageUri, strippedImage, getApplication().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().contentResolver.openInputStream(imageUri)!!
+ } catch (e: FileNotFoundException){
+ _uiState.update { currentUiState ->
+ currentUiState.copy(
+ userMessage = getApplication().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().getString(R.string.file_not_found,
+ data.imageUri)
+ )
+ }
+ return
+ }
+
+ val type = data.imageUri.getMimeType(getApplication().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().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().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().startActivity(intent)
+ } catch (exception: IOException) {
+ Toast.makeText(getApplication(), getApplication().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().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) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return modelClass.getConstructor(Application::class.java, ArrayList::class.java).newInstance(application, photoData)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
index 14b39e8c..5313f130 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
@@ -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 = emptyList(),
- @Field("poll[options][]") poll_options : List? = null,
- @Field("poll[expires_in]") poll_expires : List? = null,
- @Field("poll[multiple]") poll_multiple : List? = null,
- @Field("poll[hide_totals]") poll_hideTotals : List? = 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 = emptyList(),
+ @Field("poll[options][]") poll_options: List? = null,
+ @Field("poll[expires_in]") poll_expires: List? = null,
+ @Field("poll[multiple]") poll_multiple: List? = null,
+ @Field("poll[hide_totals]") poll_hideTotals: List? = 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}")
diff --git a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt
index b9a779bb..54bd1759 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt
@@ -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?
diff --git a/app/src/main/res/layout/activity_post_creation.xml b/app/src/main/res/layout/activity_post_creation.xml
index 7fce4465..57d5def9 100644
--- a/app/src/main/res/layout/activity_post_creation.xml
+++ b/app/src/main/res/layout/activity_post_creation.xml
@@ -6,149 +6,35 @@
android:layout_height="match_parent"
tools:context=".postCreation.PostCreationActivity">
-
-
-
-
-
-
-
-
-
-
-
-
-
+ app:layout_constraintEnd_toEndOf="parent">
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/post_submission_account_menu.xml b/app/src/main/res/menu/post_submission_account_menu.xml
new file mode 100644
index 00000000..4841e6bb
--- /dev/null
+++ b/app/src/main/res/menu/post_submission_account_menu.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d5dfbe29..2eabfd8c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -103,6 +103,8 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"
Post upload error
Description…
Post
+ Next step
+ Add some details
Add a photo
One of the images in the post
Switch to grid view
@@ -322,4 +324,6 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"
Changes saved!
Something went wrong. Tap to retry
Change your profile picture
+ Contains NSFW media
+ Switch accounts
\ No newline at end of file