huge refactor of PostCreation to use ViewModel

This commit is contained in:
Matthieu 2022-06-19 13:02:05 +02:00
parent 8cecfa3de6
commit 5c221e004d
6 changed files with 537 additions and 338 deletions

View File

@ -44,9 +44,7 @@
<activity
android:name=".postCreation.PostCreationActivity"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/AppTheme.NoActionBar"
tools:ignore="LockedOrientationActivity">
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />

View File

@ -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<PhotoData> = 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<Int, Long> = mutableMapOf()
// Keep track of temporary files to delete them (avoids filling cache super fast with videos)
private val tempFiles: ArrayList<File> = 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<Long, Boolean> = 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<Long, Boolean> {
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)

View File

@ -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<List<PhotoData>> by lazy {
MutableLiveData<List<PhotoData>>().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<MutableList<PhotoData>> by lazy {
MutableLiveData<MutableList<PhotoData>>().also {
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
}
}
fun getUsers(): LiveData<List<PhotoData>> {
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<Int, Long> = mutableMapOf()
// Keep track of temporary files to delete them (avoids filling cache super fast with videos)
private val tempFiles: java.util.ArrayList<File> = java.util.ArrayList()
private val _uiState = MutableStateFlow(PostCreationActivityUiState())
val uiState: StateFlow<PostCreationActivityUiState> = _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<MutableList<PhotoData>> = 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>? = photoData.value): MutableList<PhotoData> {
val dataToAdd: ArrayList<PhotoData> = arrayListOf()
var count = clipData.itemCount
if(count + (previousList?.size ?: 0) > instance!!.albumLimit){
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().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<Long, Boolean> =
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>) {
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<Long, Boolean> {
val size: Long =
if (uri.scheme =="content") {
getApplication<PixelDroidApplication>().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<PixelDroidApplication>().contentResolver.getType(uri)
val isVideo = type?.startsWith("video/") == true
if(isVideo && !instance!!.videoEnabled){
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().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<PixelDroidApplication>().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<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
} catch (e: FileNotFoundException){
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = getApplication<PixelDroidApplication>().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<PixelDroidApplication>().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<PixelDroidApplication>().getString(R.string.upload_post_success),
Toast.LENGTH_SHORT).show()
val intent = Intent(getApplication(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
//TODO make the activity launch this instead (and surrounding toasts too)L
getApplication<PixelDroidApplication>().startActivity(intent)
} catch (exception: IOException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
} catch (exception: HttpException) {
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.response().toString() + exception.message().toString())
_uiState.update { currentUiState ->
currentUiState.copy(
postCreationSendButtonEnabled = true
)
}
}
}
}
fun 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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(ClipData::class.java).newInstance(bundle)
}
class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java).newInstance(application, clipdata, instance)
}
}

View File

@ -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<CarouselItem>? = 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

View File

@ -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() {

View File

@ -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?