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"> - - - - - - -