diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ba866d4..81d102bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,9 +44,7 @@ + android:theme="@style/AppTheme.NoActionBar"> diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt index 3e4890e9..63cd86af 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt @@ -7,7 +7,6 @@ import android.media.MediaScannerConnection import android.net.Uri import android.os.* import android.provider.MediaStore -import android.provider.OpenableColumns import android.util.Log import android.view.View import android.view.View.INVISIBLE @@ -20,14 +19,19 @@ import androidx.activity.viewModels import androidx.core.net.toFile import androidx.core.net.toUri import androidx.core.os.HandlerCompat -import androidx.lifecycle.Observer +import androidx.core.widget.addTextChangedListener +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView import com.arthenica.ffmpegkit.* import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import okhttp3.MultipartBody import org.pixeldroid.app.MainActivity import org.pixeldroid.app.R @@ -48,16 +52,11 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.OutputStream import java.text.SimpleDateFormat - -import kotlin.math.ceil -import com.arthenica.ffmpegkit.FFprobeKit import java.util.* -import kotlin.collections.ArrayList - import kotlin.math.roundToInt -private const val TAG = "Post Creation Activity" +const val TAG = "Post Creation Activity" data class PhotoData( var imageUri: Uri, @@ -74,16 +73,11 @@ class PostCreationActivity : BaseActivity() { private var user: UserDatabaseEntity? = null private lateinit var instance: InstanceDatabaseEntity - private val photoData: ArrayList = ArrayList() - private lateinit var binding: ActivityPostCreationBinding private val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper()) - // 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: ArrayList = ArrayList() + private lateinit var model: PostCreationViewModel override fun onCreate(savedInstanceState: Bundle?) { @@ -91,9 +85,6 @@ class PostCreationActivity : BaseActivity() { binding = ActivityPostCreationBinding.inflate(layoutInflater) setContentView(binding.root) - - - user = db.userDao().getActiveUser() instance = user?.run { @@ -102,47 +93,87 @@ class PostCreationActivity : BaseActivity() { } } ?: InstanceDatabaseEntity("", "") - binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars + val _model: PostCreationViewModel by viewModels { PostCreationViewModelFactory(application, intent.clipData!!, instance) } + model = _model - // get image URIs - intent.clipData?.let { addPossibleImages(it) } + model.getPhotoData().observe(this) { newPhotoData -> + // update UI + binding.carousel.addData( + newPhotoData.map { + CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) + } + ) + } - val carousel: ImageCarousel = binding.carousel - carousel.addData(photoData.map { CarouselItem(it.imageUri, video = it.video, encodeProgress = null) }) - carousel.layoutCarouselCallback = { - if(it){ - // Became a carousel - binding.toolbarPostCreation.visibility = VISIBLE - } else { - // Became a grid - binding.toolbarPostCreation.visibility = INVISIBLE + 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() + } + 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 + + uiState.newEncodingJobPosition?.let { position -> + uiState.newEncodingJobMuted?.let { muted -> + uiState.newEncodingJobVideoStart?.let { videoStart -> + uiState.newEncodingJobVideoEnd?.let { videoEnd -> + startEncoding(position, muted, videoStart, videoEnd) + model.encodingStarted() + } + } + } + } + } } } - carousel.maxEntries = instance.albumLimit - carousel.addPhotoButtonCallback = { - addPhoto() - } - carousel.updateDescriptionCallback = { position: Int, description: String -> - photoData.getOrNull(position)?.imageDescription = description + binding.newPostDescriptionInputField.doAfterTextChanged { + model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text) } + binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars + binding.carousel.apply { + layoutCarouselCallback = { model.becameCarousel(it)} + maxEntries = instance.albumLimit + addPhotoButtonCallback = { + addPhoto() + } + updateDescriptionCallback = { position: Int, description: String -> + model.updateDescription(position, description) + } + } // get the description and send the post binding.postCreationSendButton.setOnClickListener { - if (validatePost() && photoData.isNotEmpty()) upload() + if (validatePost() && model.isNotEmpty()) model.upload() } // Button to retry image upload when it fails binding.retryUploadButton.setOnClickListener { - binding.uploadError.visibility = View.GONE - photoData.forEach { - it.uploadId = null - it.progress = null - } - upload() + model.resetUploadStatus() + model.upload() } binding.editPhotoButton.setOnClickListener { - carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> + binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> edit(currentPosition) } } @@ -152,104 +183,25 @@ class PostCreationActivity : BaseActivity() { } binding.savePhotoButton.setOnClickListener { - carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> + binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> savePicture(it, currentPosition) } } binding.removePhotoButton.setOnClickListener { - carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> - photoData.removeAt(currentPosition) - sessionMap[currentPosition]?.let { FFmpegKit.cancel(it) } - carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) }) - binding.addPhotoButton.isEnabled = true + binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition -> + model.removeAt(currentPosition) + model.cancelEncode(currentPosition) } } } - override fun onDestroy() { - super.onDestroy() - FFmpegKit.cancel() - tempFiles.forEach { - it.delete() - } - } - - /** - * Will add as many images as possible to [photoData], from the [clipData], and if - * ([photoData].size + [clipData].itemCount) > [InstanceDatabaseEntity.albumLimit] then it will only add as many images - * as are legal (if any) and a dialog will be shown to the user alerting them of this fact. - */ - private fun addPossibleImages(clipData: ClipData) { - var count = clipData.itemCount - if(count + photoData.size > instance.albumLimit){ - AlertDialog.Builder(this).apply { - setMessage(getString(R.string.total_exceeds_album_limit).format(instance.albumLimit)) - setNegativeButton(android.R.string.ok) { _, _ -> } - }.show() - count = count.coerceAtMost(instance.albumLimit - photoData.size) - } - if (count + photoData.size >= instance.albumLimit) { - // Disable buttons to add more images - binding.addPhotoButton.isEnabled = false - } - for (i in 0 until count) { - clipData.getItemAt(i).uri.let { - val sizeAndVideoPair: Pair = it.getSizeAndVideoValidate(photoData.size + 1) - photoData.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second)) - } - } - } - - /** - * Returns the size of the file of the Uri, and whether it is a video, - * and opens a dialog in case it is too big or in case the file is unsupported. - */ - private fun Uri.getSizeAndVideoValidate(editPosition: Int): Pair { - val size: Long = - if (toString().startsWith("content")) { - contentResolver.query(this, null, null, null, null) - ?.use { cursor -> - /* Get the column indexes of the data in the Cursor, - * move to the first row in the Cursor, get the data, - * and display it. - */ - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - cursor.moveToFirst() - cursor.getLong(sizeIndex) - } ?: 0 - } else { - toFile().length() - } - - val sizeInkBytes = ceil(size.toDouble() / 1000).toLong() - val type = contentResolver.getType(this) - val isVideo = type?.startsWith("video/") == true - - if(isVideo && !instance.videoEnabled){ - AlertDialog.Builder(this@PostCreationActivity).apply { - setMessage(R.string.video_not_supported) - setNegativeButton(android.R.string.ok) { _, _ -> } - }.show() - } - - if (sizeInkBytes > instance.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) { - val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize - AlertDialog.Builder(this@PostCreationActivity).apply { - setMessage(getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)) - setNegativeButton(android.R.string.ok) { _, _ -> } - }.show() - } - return Pair(size, isVideo) - } - private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK && result.data?.clipData != null) { result.data?.clipData?.let { - addPossibleImages(it) + model.setImages(model.addPossibleImages(it)) } - binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) }) } else if (result.resultCode != Activity.RESULT_CANCELED) { Toast.makeText(applicationContext, "Error while adding images", Toast.LENGTH_SHORT).show() } @@ -268,7 +220,7 @@ class PostCreationActivity : BaseActivity() { val outputStream: OutputStream = pair.first val path: String = pair.second - contentResolver.openInputStream(photoData[currentPosition].imageUri)!!.use { input -> + contentResolver.openInputStream(model.getPhotoData().value!![currentPosition].imageUri)!!.use { input -> outputStream.use { output -> input.copyTo(output) } @@ -331,7 +283,7 @@ class PostCreationActivity : BaseActivity() { return false } } - if(!photoData.all { it.videoEncodeProgress == null }){ + if(model.getPhotoData().value?.all { it.videoEncodeProgress == null } == false){ AlertDialog.Builder(this).apply { setMessage(R.string.still_encoding) setNegativeButton(android.R.string.ok) { _, _ -> } @@ -341,118 +293,6 @@ class PostCreationActivity : BaseActivity() { return true } - /** - * 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). - */ - private fun upload() { - enableButton(false) - binding.uploadProgressBar.visibility = VISIBLE - binding.uploadCompletedTextview.visibility = INVISIBLE - binding.removePhotoButton.isEnabled = false - binding.editPhotoButton.isEnabled = false - binding.addPhotoButton.isEnabled = false - - for (data: PhotoData in photoData) { - val imageUri = data.imageUri - val imageInputStream = try { - contentResolver.openInputStream(imageUri)!! - } catch (e: FileNotFoundException){ - AlertDialog.Builder(this).apply { - setMessage(getString(R.string.file_not_found).format(imageUri)) - - setNegativeButton(android.R.string.ok) { _, _ -> } - }.show() - return - } - - val imagePart = ProgressRequestBody(imageInputStream, data.size) - 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() - binding.uploadProgressBar.progress = - photoData.sumOf { it.progress ?: 0 } / photoData.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 -> - binding.uploadError.visibility = View.VISIBLE - if(e is HttpException){ - binding.uploadErrorTextExplanation.text = - getString(R.string.upload_error, e.code()) - binding.uploadErrorTextExplanation.visibility= VISIBLE - } else { - binding.uploadErrorTextExplanation.visibility= View.GONE - } - e.printStackTrace() - postSub?.dispose() - sub.dispose() - }, - { - data.progress = 100 - if (photoData.all { it.progress == 100 && it.uploadId != null }) { - binding.uploadProgressBar.visibility = View.GONE - binding.uploadCompletedTextview.visibility = View.VISIBLE - post() - } - postSub?.dispose() - sub.dispose() - } - ) - } - } - - private fun post() { - val description = binding.newPostDescriptionInputField.text.toString() - enableButton(false) - lifecycleScope.launchWhenCreated { - try { - val api = apiHolder.api ?: apiHolder.setToCurrentUser() - - api.postStatus( - statusText = description, - media_ids = photoData.mapNotNull { it.uploadId }.toList() - ) - Toast.makeText(applicationContext, getString(R.string.upload_post_success), - Toast.LENGTH_SHORT).show() - val intent = Intent(this@PostCreationActivity, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - } catch (exception: IOException) { - Toast.makeText(applicationContext, getString(R.string.upload_post_error), - Toast.LENGTH_SHORT).show() - Log.e(TAG, exception.toString()) - enableButton(true) - } catch (exception: HttpException) { - Toast.makeText(applicationContext, getString(R.string.upload_post_failed), - Toast.LENGTH_SHORT).show() - Log.e(TAG, exception.response().toString() + exception.message().toString()) - enableButton(true) - } - } - } - private fun enableButton(enable: Boolean = true){ binding.postCreationSendButton.isEnabled = enable if(enable){ @@ -469,32 +309,8 @@ class PostCreationActivity : BaseActivity() { result: ActivityResult? -> if (result?.resultCode == Activity.RESULT_OK && result.data != null) { val position: Int = result.data!!.getIntExtra(PhotoEditActivity.PICTURE_POSITION, 0) - photoData.getOrNull(position)?.apply { - if (video) { - val muted: Boolean = result.data!!.getBooleanExtra(VideoEditActivity.MUTED, false) - val videoStart: Float? = result.data!!.getFloatExtra(VideoEditActivity.VIDEO_START, -1f).let { - if(it == -1f) null else it - } - val modified: Boolean = result.data!!.getBooleanExtra(VideoEditActivity.MODIFIED, false) - val videoEnd: Float? = result.data!!.getFloatExtra(VideoEditActivity.VIDEO_END, -1f).let { - if(it == -1f) null else it - } - if(modified){ - videoEncodeProgress = 0 - sessionMap[position]?.let { FFmpegKit.cancel(it) } - startEncoding(position, muted, videoStart, videoEnd) - } - } else { - imageUri = result.data!!.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri() - val (imageSize, imageVideo) = imageUri.getSizeAndVideoValidate(position) - size = imageSize - video = imageVideo - } - progress = null - uploadId = null - } ?: Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() - - binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) }) + model.modifyAt(position, result.data!!) + ?: Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() } else if(result?.resultCode != Activity.RESULT_CANCELED){ Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() } @@ -508,21 +324,21 @@ class PostCreationActivity : BaseActivity() { * don't want to remove the end */ private fun startEncoding(position: Int, muted: Boolean, videoStart: Float?, videoEnd: Float?) { - val originalUri = photoData[position].imageUri + val originalUri = model.getPhotoData().value!![position].imageUri // Having a meaningful suffix is necessary so that ffmpeg knows what to put in output val suffix = if(originalUri.scheme == "content") { - contentResolver.getType(photoData[position].imageUri)?.takeLastWhile { it != '/' } + contentResolver.getType(model.getPhotoData().value!![position].imageUri)?.takeLastWhile { it != '/' } } else { originalUri.toString().takeLastWhile { it != '/' } } val file = File.createTempFile("temp_video", ".$suffix") //val file = File.createTempFile("temp_video", ".webm") - tempFiles.add(file) + model.trackTempFile(file) val fileUri = file.toUri() val outputVideoPath = ffmpegSafeUri(fileUri) - val inputUri = photoData[position].imageUri + val inputUri = model.getPhotoData().value!![position].imageUri val inputSafePath = ffmpegSafeUri(inputUri) @@ -542,18 +358,12 @@ class PostCreationActivity : BaseActivity() { fun successResult() { // Hide progress indicator in carousel binding.carousel.updateProgress(null, position, false) - val (imageSize, imageVideo) = outputVideoPath.toUri().let { - photoData[position].imageUri = it - it.getSizeAndVideoValidate(position) + val (imageSize, _) = outputVideoPath.toUri().let { + model.setUriAtPosition(it, position) + model.getSizeAndVideoValidate(it, position) } - photoData[position].videoEncodeProgress = null - photoData[position].size = imageSize - binding.carousel.addData(photoData.map { - CarouselItem(it.imageUri, - it.imageDescription, - it.video, - it.videoEncodeProgress) - }) + model.setVideoEncodeAtPosition(position, null) + model.setSizeAtPosition(imageSize, position) } val post = resultHandler.post { @@ -567,7 +377,7 @@ class PostCreationActivity : BaseActivity() { } else { resultHandler.post { binding.carousel.updateProgress(null, position, error = true) - photoData[position].videoEncodeProgress = null + model.setVideoEncodeAtPosition(position, null) } Log.e(TAG, "Encode failed with state ${session.state} and rc $returnCode.${session.failStackTrace}") } @@ -584,8 +394,8 @@ class PostCreationActivity : BaseActivity() { } resultHandler.post { completePercentage?.let { - val rounded = it.roundToInt() - photoData[position].videoEncodeProgress = rounded + val rounded: Int = it.roundToInt() + model.setVideoEncodeAtPosition(position, rounded) binding.carousel.updateProgress(rounded, position, false) } } @@ -593,15 +403,15 @@ class PostCreationActivity : BaseActivity() { } } } - sessionMap[position] = session.sessionId + model.registerNewFFmpegSession(position, session.sessionId) } private fun edit(position: Int) { val intent = Intent( this, - if(photoData[position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java + if(model.getPhotoData().value!![position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java ) - .putExtra(PhotoEditActivity.PICTURE_URI, photoData[position].imageUri) + .putExtra(PhotoEditActivity.PICTURE_URI, model.getPhotoData().value!![position].imageUri) .putExtra(PhotoEditActivity.PICTURE_POSITION, position) editResultContract.launch(intent) 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 8b7f1d1b..6f4ad5be 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt @@ -1,30 +1,436 @@ package org.pixeldroid.app.postCreation +import android.app.Application import android.content.ClipData -import android.os.Bundle -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.text.Editable +import android.util.Log +import android.widget.Toast +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.lifecycle.* +import com.arthenica.ffmpegkit.FFmpegKit +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.postCreation.photoEdit.PhotoEditActivity +import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity +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 retrofit2.HttpException +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import javax.inject.Inject +import kotlin.math.ceil -class PostCreationViewModel : ViewModel() { - private val photoData: MutableLiveData> by lazy { - MutableLiveData>().also { - loadUsers() +// Models the UI state for the PostCreationActivity +data class PostCreationActivityUiState( + val userMessage: String? = null, + + 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, + + val newEncodingJobPosition: Int? = null, + val newEncodingJobMuted: Boolean? = null, + val newEncodingJobVideoStart: Float? = null, + val newEncodingJobVideoEnd: Float? = null, +) + +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()) } } } - fun getUsers(): LiveData> { - return photoData + @Inject + lateinit var apiHolder: PixelfedAPIHolder + + init { + (application as PixelDroidApplication).getAppComponent().inject(this) } - private fun loadUsers() { - // Do an asynchronous operation to fetch users. + // 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() + + + private val _uiState = MutableStateFlow(PostCreationActivityUiState()) + val uiState: StateFlow = _uiState + + fun userMessageShown() { + _uiState.update { currentUiState -> + currentUiState.copy(userMessage = null) + } } + + fun encodingStarted() { + _uiState.update { currentUiState -> + currentUiState.copy( + newEncodingJobPosition = null, + newEncodingJobMuted = null, + newEncodingJobVideoStart = null, + newEncodingJobVideoEnd = null, + ) + } + } + + fun getPhotoData(): LiveData> = photoData + + /** + * Will add as many images as possible to [photoData], from the [clipData], and if + * ([photoData].size + [clipData].itemCount) > [InstanceDatabaseEntity.albumLimit] then it will only add as many images + * as are legal (if any) and a dialog will be shown to the user alerting them of this fact. + */ + fun addPossibleImages(clipData: ClipData, previousList: MutableList? = photoData.value): MutableList { + val dataToAdd: ArrayList = arrayListOf() + var count = clipData.itemCount + if(count + (previousList?.size ?: 0) > instance!!.albumLimit){ + _uiState.update { currentUiState -> + currentUiState.copy(userMessage = getApplication().getString(R.string.total_exceeds_album_limit).format(instance.albumLimit)) + } + count = count.coerceAtMost(instance.albumLimit - (previousList?.size ?: 0)) + } + if (count + (previousList?.size ?: 0) >= instance.albumLimit) { + // Disable buttons to add more images + _uiState.update { currentUiState -> + currentUiState.copy(addPhotoButtonEnabled = false) + } + } + for (i in 0 until count) { + clipData.getItemAt(i).uri.let { + val sizeAndVideoPair: Pair = + getSizeAndVideoValidate(it, (previousList?.size ?: 0) + dataToAdd.size + 1) + dataToAdd.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second)) + } + } + return previousList?.plus(dataToAdd)?.toMutableList() ?: mutableListOf() + } + + fun setImages(addPossibleImages: MutableList) { + photoData.value = addPossibleImages + } + + /** + * Returns the size of the file of the Uri, and whether it is a video, + * and opens a dialog in case it is too big or in case the file is unsupported. + */ + fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair { + val size: Long = + if (uri.scheme =="content") { + getApplication().contentResolver.query(uri, null, null, null, null) + ?.use { cursor -> + /* Get the column indexes of the data in the Cursor, + * move to the first row in the Cursor, get the data, + * and display it. + */ + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } ?: 0 + } else { + uri.toFile().length() + } + + val sizeInkBytes = ceil(size.toDouble() / 1000).toLong() + val type = getApplication().contentResolver.getType(uri) + val isVideo = type?.startsWith("video/") == true + + if(isVideo && !instance!!.videoEnabled){ + _uiState.update { currentUiState -> + currentUiState.copy(userMessage = getApplication().getString(R.string.video_not_supported)) + } + } + + if (sizeInkBytes > instance!!.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) { + val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize + _uiState.update { currentUiState -> + currentUiState.copy( + userMessage = getApplication().getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize) + ) + } + } + return Pair(size, isVideo) + } + + fun isNotEmpty(): Boolean = photoData.value?.isNotEmpty() ?: false + + fun updateDescription(position: Int, description: String) { + photoData.value?.getOrNull(position)?.imageDescription = description + photoData.value = photoData.value + } + + fun resetUploadStatus() { + photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList() + } + + fun setVideoEncodeAtPosition(position: Int, progress: Int?) { + photoData.value?.set(position, photoData.value!![position].copy(videoEncodeProgress = progress)) + 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 { + it.copy( + addPhotoButtonEnabled = true + ) + } + photoData.value = photoData.value + } + + /** + * 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). + */ + 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 imageUri = data.imageUri + val imageInputStream = try { + getApplication().contentResolver.openInputStream(imageUri)!! + } catch (e: FileNotFoundException){ + _uiState.update { currentUiState -> + currentUiState.copy( + userMessage = getApplication().getString(R.string.file_not_found, + imageUri) + ) + } + return + } + + val imagePart = ProgressRequestBody(imageInputStream, data.size) + 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, + ) + } + e.printStackTrace() + postSub?.dispose() + sub.dispose() + }, + { + 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)L + 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 modifyAt(position: Int, data: Intent): Unit? { + val result: PhotoData = photoData.value?.get(position)?.run { + if (video) { + val muted: Boolean = data.getBooleanExtra(VideoEditActivity.MUTED, false) + val videoStart: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_START, -1f).let { + if(it == -1f) null else it + } + val modified: Boolean = data.getBooleanExtra(VideoEditActivity.MODIFIED, false) + val videoEnd: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_END, -1f).let { + if(it == -1f) null else it + } + if(modified){ + videoEncodeProgress = 0 + sessionMap[position]?.let { FFmpegKit.cancel(it) } + _uiState.update { currentUiState -> + currentUiState.copy( + newEncodingJobPosition = position, + newEncodingJobMuted = muted, + newEncodingJobVideoStart = videoStart, + newEncodingJobVideoEnd = videoEnd + ) + } + } + } else { + imageUri = data.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri() + val (imageSize, imageVideo) = getSizeAndVideoValidate(imageUri, position) + size = imageSize + video = imageVideo + } + progress = null + uploadId = null + this + } ?: return null + result.let { + photoData.value?.set(position, it) + photoData.value = photoData.value + } + return Unit + } + + fun newPostDescriptionChanged(text: Editable?) { + _uiState.update { it.copy(newPostDescriptionText = text.toString()) } + } + + fun trackTempFile(file: File) { + tempFiles.add(file) + } + + fun cancelEncode(currentPosition: Int) { + sessionMap[currentPosition]?.let { FFmpegKit.cancel(it) } + } + + override fun onCleared() { + super.onCleared() + FFmpegKit.cancel() + tempFiles.forEach { + it.delete() + } + + } + + fun registerNewFFmpegSession(position: Int, sessionId: Long) { + sessionMap[position] = sessionId + } + + fun becameCarousel(became: Boolean) { + _uiState.update { currentUiState -> + currentUiState.copy( + isCarousel = became + ) + } + } + } -class PostCreationViewModelFactory(val bundle: ClipData? = null) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.getConstructor(ClipData::class.java).newInstance(bundle) - } +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/carousel/ImageCarousel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt index bb2473aa..58712df5 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt @@ -59,15 +59,7 @@ class ImageCarousel( initIndicator() } - - - private var btnPrevious: View? = null - private var btnNext: View? = null - - private var btnGrid: View? = null - private var btnCarousel: View? = null - - + private var isBuiltInIndicator = false private var data: MutableList? = null @@ -231,27 +223,24 @@ class ImageCarousel( set(value) { field = value - btnGrid = binding.switchToGridButton - btnCarousel = binding.switchToCarouselButton - - btnGrid?.setOnClickListener { + binding.switchToGridButton.setOnClickListener { layoutCarousel = false } - btnCarousel?.setOnClickListener { + binding.switchToCarouselButton.setOnClickListener { layoutCarousel = true } if(value){ if(layoutCarousel){ - btnGrid?.visibility = VISIBLE - btnCarousel?.visibility = GONE + binding.switchToGridButton.visibility = VISIBLE + binding.switchToCarouselButton.visibility = GONE } else { - btnGrid?.visibility = GONE - btnCarousel?.visibility = VISIBLE + binding.switchToGridButton.visibility = GONE + binding.switchToCarouselButton.visibility = VISIBLE } } else { - btnGrid?.visibility = GONE - btnCarousel?.visibility = GONE + binding.switchToGridButton.visibility = GONE + binding.switchToCarouselButton.visibility = GONE } } @@ -267,15 +256,15 @@ class ImageCarousel( if(value){ recyclerView.layoutManager = CarouselLinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - btnNext?.visibility = VISIBLE - btnPrevious?.visibility = VISIBLE + binding.btnNext.visibility = VISIBLE + binding.btnPrevious.visibility = VISIBLE binding.editMediaDescriptionLayout.visibility = if(editingMediaDescription) VISIBLE else INVISIBLE tvCaption.visibility = if(editingMediaDescription) INVISIBLE else VISIBLE } else { recyclerView.layoutManager = GridLayoutManager(context, 3) - btnNext?.visibility = GONE - btnPrevious?.visibility = GONE + binding.btnNext.visibility = GONE + binding.btnPrevious.visibility = GONE binding.editMediaDescriptionLayout.visibility = INVISIBLE tvCaption.visibility = INVISIBLE diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt index 11d0526a..22f92dc9 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt @@ -15,8 +15,6 @@ import android.view.MenuItem import android.view.View import android.widget.FrameLayout import android.widget.ImageView -import android.widget.SeekBar -import android.widget.SeekBar.OnSeekBarChangeListener import androidx.core.net.toUri import androidx.core.os.HandlerCompat import androidx.media.AudioAttributesCompat @@ -33,10 +31,6 @@ import org.pixeldroid.app.postCreation.carousel.dpToPx import org.pixeldroid.app.utils.BaseActivity import org.pixeldroid.app.utils.ffmpegSafeUri import java.io.File -import java.text.NumberFormat -import java.time.format.DateTimeFormatter -import java.util.* -import kotlin.collections.ArrayList class VideoEditActivity : BaseActivity() { 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 3a59eec0..0a646b08 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 @@ -7,6 +7,7 @@ import org.pixeldroid.app.utils.PixelDroidApplication import org.pixeldroid.app.utils.db.AppDatabase import org.pixeldroid.app.utils.BaseFragment import dagger.Component +import org.pixeldroid.app.postCreation.PostCreationViewModel import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker import javax.inject.Singleton @@ -18,6 +19,7 @@ interface ApplicationComponent { fun inject(activity: BaseActivity?) fun inject(feedFragment: BaseFragment) fun inject(notificationsWorker: NotificationsWorker) + fun inject(postCreationViewModel: PostCreationViewModel) val context: Context? val application: Application?