diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormActivity.kt index 49b6e6cca849..5a511473d2ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormActivity.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.main.feedbackform +import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.viewModels @@ -8,6 +9,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.ui.LocaleAwareActivity +import org.wordpress.android.ui.RequestCodes @AndroidEntryPoint class FeedbackFormActivity : LocaleAwareActivity() { @@ -26,6 +28,7 @@ class FeedbackFormActivity : LocaleAwareActivity() { FeedbackFormScreen( messageText = viewModel.messageText.collectAsState(), isProgressShowing = viewModel.isProgressShowing.collectAsState(), + attachments = viewModel.attachments.collectAsState(), onMessageChanged = { viewModel.updateMessageText(it) }, @@ -34,10 +37,27 @@ class FeedbackFormActivity : LocaleAwareActivity() { }, onCloseClick = { viewModel.onCloseClick(this@FeedbackFormActivity) + }, + onChooseMediaClick = { + viewModel.onChooseMediaClick(this@FeedbackFormActivity) + }, + onRemoveMediaClick = { + viewModel.onRemoveMediaClick(it) } ) } } ) } + + @Deprecated("Deprecated in Java") + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == RequestCodes.PHOTO_PICKER) { + data?.let { + viewModel.onPhotoPickerResult(this, it) + } + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormAttachment.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormAttachment.kt new file mode 100644 index 000000000000..1dddd517f914 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormAttachment.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.ui.main.feedbackform + +import android.net.Uri +import java.io.File + +data class FeedbackFormAttachment( + val uri: Uri, + val tempFile: File, + val displayName: String, + val mimeType: String, + val attachmentType: FeedbackFormAttachmentType, + val size: Long, +) + +enum class FeedbackFormAttachmentType { + IMAGE, + VIDEO, +} + +/** + * TODO + * +fun FeedbackFormAttachment.toZenDeskAttachment(): SupportNetworkService.ZenDeskSupportTicket.Attachment { + return SupportNetworkService.ZenDeskSupportTicket.Attachment( + file = this.tempFile, + type = this.mimeType + ) +} +*/ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormScreen.kt index d4635be61b06..4ab3bd0b3d72 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormScreen.kt @@ -2,13 +2,19 @@ package org.wordpress.android.ui.main.feedbackform import android.content.Context import android.content.res.Configuration +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -19,28 +25,36 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.M3Theme +import java.io.File @Composable fun FeedbackFormScreen( messageText: State?, isProgressShowing: State, + attachments: State>, onMessageChanged: (String) -> Unit, onSubmitClick: (context: Context) -> Unit, - onCloseClick: (context: Context) -> Unit + onCloseClick: (context: Context) -> Unit, + onChooseMediaClick: () -> Unit, + onRemoveMediaClick: (uri: Uri) -> Unit, ) { val context = LocalContext.current val message = messageText?.value ?: "" @@ -51,6 +65,14 @@ fun FeedbackFormScreen( onMessageChanged(it) }, ) + AttachmentButton( + onChooseMediaClick = onChooseMediaClick + ) + attachments.value.forEach { attachment -> + AttachmentRow(attachment) { + onRemoveMediaClick(attachment.uri) + } + } SubmitButton( isEnabled = message.isNotEmpty(), isProgressShowing = isProgressShowing.value, @@ -124,6 +146,90 @@ private fun SubmitButton( } } +@Composable +private fun AttachmentButton( + onChooseMediaClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onChooseMediaClick() + } + .padding( + vertical = V_PADDING.dp, + horizontal = H_PADDING.dp + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_attachment_link), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null // decorative element + ) + Text( + text = stringResource(R.string.feedback_form_add_attachments), + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 10.dp + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun AttachmentRow( + attachment: FeedbackFormAttachment, + onDeleteClick: (Uri) -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = H_PADDING.dp, + vertical = 2.dp + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Text( + text = attachment.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .weight(1.0f, true) + .padding( + vertical = V_PADDING.dp, + horizontal = H_PADDING.dp + ) + ) + IconButton( + onClick = { onDeleteClick(attachment.uri) }, + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + Icon( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f, false) + .align(Alignment.CenterVertically) + .size(24.dp), + imageVector = Icons.Filled.Close, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Screen( @@ -167,20 +273,27 @@ private fun Screen( ) @Composable private fun FeedbackFormScreenPreview() { - val content: @Composable () -> Unit = @Composable { - MessageSection( - messageText = null, - onMessageChanged = {}, - ) - SubmitButton( - isEnabled = true, - isProgressShowing = false, - onClick = { } - ) - } - Screen( - content = content, + val attachment = FeedbackFormAttachment( + uri = Uri.parse("https://via.placeholder.com/150"), + attachmentType = FeedbackFormAttachmentType.IMAGE, + size = 123456789, + displayName = "attachment.jpg (1.2 MB)", + mimeType = "image/jpeg", + tempFile = File("/tmp/attachment.jpg") + ) + val attachments = MutableStateFlow(listOf(attachment)) + val isProgressShowing = MutableStateFlow(null) + val messageText = MutableStateFlow("I love this app!") + + FeedbackFormScreen( + messageText = messageText.collectAsState(), + isProgressShowing = isProgressShowing.collectAsState(), + attachments = attachments.collectAsState(), + onMessageChanged = {}, + onSubmitClick = {}, onCloseClick = {}, + onChooseMediaClick = {}, + onRemoveMediaClick = {} ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormUtils.kt new file mode 100644 index 000000000000..0adcca87887e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormUtils.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.main.feedbackform + +import javax.inject.Inject + +class FeedbackFormUtils @Inject constructor() { + /** + * Only images & photos are supported at this point + */ + fun isSupportedMimeType(mimeType: String): Boolean { + return when { + mimeType.startsWith("image") -> true + mimeType.startsWith("video") -> true + else -> false + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormViewModel.kt index 3843fd4d6cb0..8d0b8d0ea40b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/feedbackform/FeedbackFormViewModel.kt @@ -2,18 +2,32 @@ package org.wordpress.android.ui.main.feedbackform import android.app.Activity import android.content.Context -import android.widget.Toast +import android.content.Intent +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import org.wordpress.android.R +import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.support.ZendeskHelper import org.wordpress.android.ui.accounts.HelpActivity +import org.wordpress.android.ui.media.MediaBrowserType import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.photopicker.MediaPickerConstants +import org.wordpress.android.ui.photopicker.MediaPickerLauncher +import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtils +import org.wordpress.android.util.ToastUtilsWrapper +import org.wordpress.android.util.extensions.copyToTempFile +import org.wordpress.android.util.extensions.fileSize +import org.wordpress.android.util.extensions.mimeType +import org.wordpress.android.util.extensions.sizeFmt import org.wordpress.android.viewmodel.ScopedViewModel import javax.inject.Inject import javax.inject.Named @@ -23,6 +37,10 @@ class FeedbackFormViewModel @Inject constructor( @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, private val zendeskHelper: ZendeskHelper, private val selectedSiteRepository: SelectedSiteRepository, + private val appLogWrapper: AppLogWrapper, + private val toastUtilsWrapper: ToastUtilsWrapper, + private val feedbackFormUtils: FeedbackFormUtils, + private val mediaPickerLauncher: MediaPickerLauncher, ) : ScopedViewModel(mainDispatcher) { private val _messageText = MutableStateFlow("") val messageText = _messageText.asStateFlow() @@ -30,6 +48,9 @@ class FeedbackFormViewModel @Inject constructor( private val _isProgressShowing = MutableStateFlow(null) val isProgressShowing = _isProgressShowing.asStateFlow() + private val _attachments = MutableStateFlow>(emptyList()) + val attachments = _attachments.asStateFlow() + fun updateMessageText(message: String) { if (message != _messageText.value) { _messageText.value = message @@ -56,7 +77,7 @@ class FeedbackFormViewModel @Inject constructor( override fun onError(errorMessage: String?) { _isProgressShowing.value = false - onFailure(context, errorMessage) + onFailure(errorMessage) } }) } @@ -77,7 +98,7 @@ class FeedbackFormViewModel @Inject constructor( fun onCloseClick(context: Context) { (context as? Activity)?.let { activity -> - if (_messageText.value.isEmpty()) { + if (_messageText.value.isEmpty() && _attachments.value.isEmpty()) { activity.finish() } else { confirmDiscard(activity) @@ -98,13 +119,99 @@ class FeedbackFormViewModel @Inject constructor( } private fun onSuccess(context: Context) { - Toast.makeText(context, R.string.feedback_form_success, Toast.LENGTH_LONG).show() + showToast(R.string.feedback_form_success) (context as? Activity)?.finish() } - private fun onFailure(context: Context, errorMessage: String? = null) { - val message = context.getString(R.string.feedback_form_failure) + "\n$errorMessage" - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + private fun onFailure(errorMessage: String? = null) { + appLogWrapper.e(AppLog.T.SUPPORT, "Failed to submit feedback form: $errorMessage") + showToast(R.string.feedback_form_failure) + } + + fun onChooseMediaClick(activity: Activity) { + mediaPickerLauncher.showPhotoPickerForResult( + activity, + browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, + site = selectedSiteRepository.getSelectedSite(), + localPostId = null + ) + } + + fun onPhotoPickerResult(context: Context, data: Intent) { + if (data.hasExtra(MediaPickerConstants.EXTRA_MEDIA_URIS)) { + val stringArray = data.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) + stringArray?.forEach { stringUri -> + addAttachment(context, Uri.parse(stringUri)) + } + } + } + + private fun addAttachment(context: Context, uri: Uri) { + val list = _attachments.value + val newList = list.toMutableList() + val size = uri.fileSize(context) + val mimeType = uri.mimeType(context) + val file = uri.copyToTempFile(mimeType, context) + + if (list.size >= MAX_ATTACHMENTS) { + showToast(R.string.feedback_form_max_attachments_reached) + } else if (newList.any { it.uri == uri }) { + showToast(R.string.feedback_form_attachment_already_added) + } else if (size > MAX_SINGLE_ATTACHMENT_SIZE) { + showToast(R.string.feedback_form_attachment_too_large) + } else if (totalAttachmentSize() + size > MAX_TOTAL_ATTACHMENT_SIZE) { + showToast(R.string.feedback_form_total_attachments_too_large) + } else if (file == null) { + showToast(R.string.feedback_form_unable_to_create_tempfile) + } else if (!feedbackFormUtils.isSupportedMimeType(mimeType)) { + showToast(R.string.feedback_form_unsupported_attachment) + } else { + val attachmentType = if (mimeType.startsWith("video")) { + FeedbackFormAttachmentType.VIDEO + } else { + FeedbackFormAttachmentType.IMAGE + } + val sizeFmt = uri.sizeFmt(context) + val counter = newList.filter { it.attachmentType == attachmentType }.size + 1 + val displayName = "${attachmentType}_$counter ($sizeFmt)" + + newList.add( + FeedbackFormAttachment( + uri = uri, + tempFile = file, + size = size, + displayName = displayName, + mimeType = mimeType, + attachmentType = attachmentType + ) + ) + _attachments.value = newList.toList() + } + } + + fun onRemoveMediaClick(uri: Uri) { + val list = _attachments.value + val newList = list.toMutableList() + if (newList.removeIf { it.uri == uri }) { + _attachments.value = newList.toList() + } + } + + private fun totalAttachmentSize(): Long { + val list = _attachments.value + return list.sumOf { it.size } + } + + private fun showToast(@StringRes msgId: Int) { + viewModelScope.launch { + toastUtilsWrapper.showToast(msgId) + } + } + + companion object { + private const val MAX_SINGLE_ATTACHMENT_SIZE = 50000000 + private const val MAX_TOTAL_ATTACHMENT_SIZE = MAX_SINGLE_ATTACHMENT_SIZE * 3 + private const val MAX_ATTACHMENTS = 15 } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserType.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserType.java index c4995c671eb8..90db41d0b3db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserType.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserType.java @@ -14,7 +14,8 @@ public enum MediaBrowserType { GUTENBERG_SINGLE_MEDIA_PICKER, // select a single image or video to insert into a post GUTENBERG_MEDIA_PICKER, // select multiple images or videos to insert into a post GUTENBERG_SINGLE_FILE_PICKER, // select a file to insert into a post - GUTENBERG_SINGLE_AUDIO_FILE_PICKER; // select an audio file to insert into a post + GUTENBERG_SINGLE_AUDIO_FILE_PICKER, // select an audio file to insert into a post + FEEDBACK_FORM_MEDIA_PICKER; // select images or videos for support form public boolean isPicker() { return this != BROWSER; @@ -41,7 +42,8 @@ public boolean isImagePicker() { || this == GUTENBERG_SINGLE_IMAGE_PICKER || this == GUTENBERG_SINGLE_MEDIA_PICKER || this == GUTENBERG_MEDIA_PICKER - || this == GUTENBERG_SINGLE_FILE_PICKER; + || this == GUTENBERG_SINGLE_FILE_PICKER + || this == FEEDBACK_FORM_MEDIA_PICKER; } public boolean isVideoPicker() { @@ -51,7 +53,8 @@ public boolean isVideoPicker() { || this == GUTENBERG_SINGLE_VIDEO_PICKER || this == GUTENBERG_SINGLE_MEDIA_PICKER || this == GUTENBERG_MEDIA_PICKER - || this == GUTENBERG_SINGLE_FILE_PICKER; + || this == GUTENBERG_SINGLE_FILE_PICKER + || this == FEEDBACK_FORM_MEDIA_PICKER; } public boolean isAudioPicker() { @@ -89,7 +92,8 @@ public boolean canMultiselect() { return this == EDITOR_PICKER || this == AZTEC_EDITOR_PICKER || this == GUTENBERG_IMAGE_PICKER - || this == GUTENBERG_VIDEO_PICKER; + || this == GUTENBERG_VIDEO_PICKER + || this == FEEDBACK_FORM_MEDIA_PICKER; } public boolean canFilter() { diff --git a/WordPress/src/main/java/org/wordpress/android/util/extensions/UriExt.kt b/WordPress/src/main/java/org/wordpress/android/util/extensions/UriExt.kt new file mode 100644 index 000000000000..60b469092666 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/extensions/UriExt.kt @@ -0,0 +1,101 @@ +package org.wordpress.android.util.extensions + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.google.common.io.Files +import org.wordpress.android.util.AppLog +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +/** + * Returns the file size in bytes from a Uri + */ +@Suppress("ReturnCount") +fun Uri.fileSize(context: Context): Long { + val assetFileDescriptor = try { + context.contentResolver.openAssetFileDescriptor(this, "r") + } catch (e: FileNotFoundException) { + AppLog.e(AppLog.T.UTILS, e) + null + } + + // uses ParcelFileDescriptor#getStatSize underneath if failed + val length = assetFileDescriptor?.use { it.length } ?: 0L + if (length != 0L) { + return length + } + + // if "content://" uri scheme, try contentResolver table + if (scheme.equals(ContentResolver.SCHEME_CONTENT)) { + return context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + // maybe shouldn't trust ContentResolver for size: https://stackoverflow.com/questions/48302972/content-resolver-returns-wrong-size + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex == -1) { + return@use 0L + } + cursor.moveToFirst() + return cursor.getLong(sizeIndex) + } ?: return 0L + } else { + return 0L + } +} + +/** + * Copies the Uri to a temporary file and returns the file + */ +@Suppress("NestedBlockDepth", "ReturnCount") +fun Uri.copyToTempFile(mimeType: String, context: Context): File? { + this.fileName(context)?.let { name -> + try { + var extension = mimeType.substringAfterLast("/") + if (extension.isEmpty() || extension == "*") { + extension = "tmp" + } + @Suppress("UnstableApiUsage") val file = File.createTempFile( + Files.getNameWithoutExtension(name), + ".$extension" + ) + context.contentResolver.openInputStream(this).use { inputStream -> + inputStream?.let { + file.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + return file + } ?: return null + } + } catch (e: IOException) { + AppLog.e(AppLog.T.UTILS, e) + return null + } + } ?: return null +} + +fun Uri.mimeType(context: Context): String { + return context.contentResolver.getType(this) ?: "" +} + +/** + * Returns a human-readable file size, ex: "1.5 MB" + */ +fun Uri.sizeFmt(context: Context): String { + return android.text.format.Formatter.formatShortFileSize( + context, + fileSize(context) + ) +} + +/** + * Returns the file name from a Uri without any path info + */ +fun Uri.fileName(context: Context): String? { + return context.contentResolver.query(this, null, null, null, null)?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + it.moveToFirst() + it.getString(nameIndex) + } +} diff --git a/WordPress/src/main/res/drawable/ic_attachment_link.xml b/WordPress/src/main/res/drawable/ic_attachment_link.xml new file mode 100644 index 000000000000..2fe8d905f026 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_attachment_link.xml @@ -0,0 +1,20 @@ + + + + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 22972328d015..16e4d6b7d811 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1134,6 +1134,13 @@ Discard your feedback? Thanks for submitting your feedback! We were unable to submit your feedback + Maximum number of attachments reached + This file type is not supported + Attachments must be 50MB or smaller + The total size of the attachments must be 150MB or smaller + Attachment already added + Unable to create temporary file + Add attachments Activity Log