package org.pixeldroid.app.postCreation import android.app.Activity import android.app.AlertDialog import android.content.* import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.MediaStore import android.provider.OpenableColumns import android.util.Log import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toFile import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import org.pixeldroid.app.MainActivity import org.pixeldroid.app.R import org.pixeldroid.app.databinding.ActivityPostCreationBinding import org.pixeldroid.app.postCreation.camera.CameraActivity import org.pixeldroid.app.postCreation.carousel.CarouselItem import org.pixeldroid.app.postCreation.carousel.ImageCarousel import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity import org.pixeldroid.app.utils.BaseActivity import org.pixeldroid.app.utils.api.objects.Attachment import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import okhttp3.MultipartBody import retrofit2.HttpException import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.OutputStream import java.text.SimpleDateFormat import java.util.* import kotlin.collections.ArrayList import kotlin.math.ceil private const val TAG = "Post Creation Activity" data class PhotoData( var imageUri: Uri, var size: Long, var uploadId: String? = null, var progress: Int? = null, var imageDescription: String? = null, ) class PostCreationActivity : BaseActivity() { private var user: UserDatabaseEntity? = null private lateinit var instance: InstanceDatabaseEntity private val photoData: ArrayList = ArrayList() private lateinit var binding: ActivityPostCreationBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityPostCreationBinding.inflate(layoutInflater) setContentView(binding.root) user = db.userDao().getActiveUser() instance = user?.run { db.instanceDao().getAll().first { instanceDatabaseEntity -> instanceDatabaseEntity.uri.contains(instance_uri) } } ?: InstanceDatabaseEntity("", "") binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars // get image URIs intent.clipData?.let { addPossibleImages(it) } val carousel: ImageCarousel = binding.carousel carousel.addData(photoData.map { CarouselItem(it.imageUri) }) carousel.layoutCarouselCallback = { if(it){ // Became a carousel binding.toolbarPostCreation.visibility = VISIBLE } else { // Became a grid binding.toolbarPostCreation.visibility = INVISIBLE } } carousel.maxEntries = instance.albumLimit carousel.addPhotoButtonCallback = { addPhoto() } carousel.updateDescriptionCallback = { position: Int, description: String -> photoData.getOrNull(position)?.imageDescription = description } // get the description and send the post binding.postCreationSendButton.setOnClickListener { if (validateDescription() && photoData.isNotEmpty()) 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() } binding.editPhotoButton.setOnClickListener { carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition -> edit(currentPosition) } } binding.addPhotoButton.setOnClickListener { addPhoto() } binding.savePhotoButton.setOnClickListener { carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition -> savePicture(it, currentPosition) } } binding.removePhotoButton.setOnClickListener { carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition -> photoData.removeAt(currentPosition) carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) }) binding.addPhotoButton.isEnabled = true } } } /** * Will add as many images as possible to [photoData], from the [clipData], and if * ([photoData].size + [clipData].itemCount) > [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 size = it.getSize() photoData.add(PhotoData(imageUri = it, size = size)) } } } /** * Returns the size of the file of the Uri, and opens a dialog in case it is too big. */ private fun Uri.getSize(): Long { 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() if (sizeInkBytes > instance.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) { val maxSize = when { instance.maxPhotoSize != instance.maxVideoSize -> { val type = contentResolver.getType(this) if (type?.startsWith("video/") == true) { instance.maxVideoSize } else instance.maxPhotoSize } else -> instance.maxPhotoSize } AlertDialog.Builder(this@PostCreationActivity).apply { setMessage(getString(R.string.size_exceeds_instance_limit, photoData.size + 1, sizeInkBytes, maxSize)) setNegativeButton(android.R.string.ok) { _, _ -> } }.show() } return size } private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK && result.data?.clipData != null) { result.data?.clipData?.let { addPossibleImages(it) } binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) }) } else if (result.resultCode != Activity.RESULT_CANCELED) { Toast.makeText(applicationContext, "Error while adding images", Toast.LENGTH_SHORT).show() } } private fun addPhoto(){ addPhotoResultContract.launch( Intent(this, CameraActivity::class.java) ) } private fun savePicture(button: View, currentPosition: Int) { val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) .format(System.currentTimeMillis()) + ".png" val pair = getOutputFile(name) val outputStream: OutputStream = pair.first val path: String = pair.second contentResolver.openInputStream(photoData[currentPosition].imageUri)!!.use { input -> outputStream.use { output -> input.copyTo(output) } } if(path.startsWith("file")) { MediaScannerConnection.scanFile( this, arrayOf(path.toUri().toFile().absolutePath), null ) { path, uri -> if (uri == null) { Log.e( "NEW IMAGE SCAN FAILED", "Tried to scan $path, but it failed" ) } } } Snackbar.make( button, getString(R.string.save_image_success), Snackbar.LENGTH_LONG ).show() } private fun getOutputFile(name: String): Pair { val outputStream: OutputStream val path: String if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val resolver: ContentResolver = contentResolver val contentValues = ContentValues() contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/png") contentValues.put( MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES ) val imageUri: Uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!! path = imageUri.toString() outputStream = resolver.openOutputStream(Objects.requireNonNull(imageUri))!! } else { @Suppress("DEPRECATION") val imagesDir = Environment.getExternalStoragePublicDirectory(getString(R.string.app_name)) imagesDir.mkdir() val file = File(imagesDir, name) path = Uri.fromFile(file).toString() outputStream = file.outputStream() } return Pair(outputStream, path) } private fun validateDescription(): 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 } /** * 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 = View.VISIBLE binding.uploadCompletedTextview.visibility = View.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.sumBy { 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){ binding.postingProgressBar.visibility = View.GONE binding.postCreationSendButton.visibility = View.VISIBLE } else { binding.postingProgressBar.visibility = View.VISIBLE binding.postCreationSendButton.visibility = View.GONE } } private val editResultContract: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ 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 { imageUri = result.data!!.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri() size = imageUri.getSize() progress = null uploadId = null } ?: Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) }) } else if(result?.resultCode != Activity.RESULT_CANCELED){ Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() } } private fun edit(position: Int) { val intent = Intent(this, PhotoEditActivity::class.java) .putExtra(PhotoEditActivity.PICTURE_URI, photoData[position].imageUri) .putExtra(PhotoEditActivity.PICTURE_POSITION, position) editResultContract.launch(intent) } }