android: fix race condition on taking photo resulting in null URI; ios: use fullScreenCover for camera (closes #662) (#663)

This commit is contained in:
JRoberts
2022-05-18 21:32:30 +04:00
committed by GitHub
parent 5f67c450b1
commit e4b1ff5e0f
10 changed files with 251 additions and 115 deletions
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="chat.simplex.app">
<uses-feature android:name="android.hardware.camera" />
@@ -15,7 +16,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
@@ -177,7 +177,7 @@ class ChatModel(val controller: ChatController) {
// clear preview
val i = getChatIndex(cInfo.id)
if (i >= 0) {
chats[i] = chats[i]?.copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats())
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats())
}
// clear current chat
if (chatId.value == cInfo.id) {
@@ -1,8 +1,6 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -28,7 +26,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -46,10 +43,9 @@ fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
val user = chatModel.currentUser.value
val composeState = remember { mutableStateOf(ComposeState()) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
if (chat == null || user == null) {
chatModel.chatId.value = null
@@ -77,15 +73,11 @@ fun ChatView(chatModel: ChatModel) {
composeState,
composeView = {
ComposeView(
chatModel,
chat,
composeState,
chosenImage,
chosenFile,
showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
},
chosenImage,
chosenFile,
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
@@ -137,8 +129,7 @@ fun ChatLayout(
chat: Chat,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
@@ -149,17 +140,6 @@ fun ChatLayout(
receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
fun onFileChange(uri: Uri) {
val fileName = getFileName(context, uri)
if (fileName != null) {
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
}
Surface(
Modifier
.fillMaxWidth()
@@ -170,14 +150,10 @@ fun ChatLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
::onImageChange,
chosenFile,
::onFileChange,
hideBottomSheet = {
scope.launch { attachmentBottomSheetState.hide() }
})
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
@@ -389,8 +365,7 @@ fun PreviewChatLayout() {
),
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenFile = remember { mutableStateOf(null) },
chosenImage = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
@@ -435,8 +410,7 @@ fun PreviewGroupChatLayout() {
),
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenImage = remember { mutableStateOf(null) },
chosenFile = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
@@ -2,8 +2,20 @@ package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.CallSuper
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -12,7 +24,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -21,12 +33,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import java.io.File
sealed class ComposePreview {
object NoPreview: ComposePreview()
@@ -101,9 +116,8 @@ fun ComposeView(
chatModel: ChatModel,
chat: Chat,
composeState: MutableState<ComposeState>,
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
showAttachmentBottomSheet: () -> Unit
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
val context = LocalContext.current
val linkUrl = remember { mutableStateOf<String?>(null) }
@@ -112,6 +126,107 @@ fun ComposeView(
val cancelledLinks = remember { mutableSetOf<String>() }
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
val photoUri = remember { mutableStateOf<Uri?>(null) }
val photoTmpFile = remember { mutableStateOf<File?>(null) }
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
val photoUriVal = photoUri.value
val photoTmpFileVal = photoTmpFile.value
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
val bitmap = ImageDecoder.decodeBitmap(source)
photoTmpFileVal.delete()
bitmap
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
photoTmpFile.value?.delete()
null
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
if (bitmap != null) {
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
val fileName = getFileName(SimplexApp.context, uri)
if (fileName != null) {
chosenFile.value = uri
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
}
}
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
attachmentOption.value = null
}
AttachmentOption.PickImage -> {
galleryLauncher.launch("image/*")
attachmentOption.value = null
}
AttachmentOption.PickFile -> {
filesLauncher.launch("*/*")
attachmentOption.value = null
}
else -> {}
}
}
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
@@ -195,15 +310,15 @@ fun ComposeView(
}
fun sendMessage() {
withApi {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -213,37 +328,39 @@ fun ComposeView(
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (mc != null) {
if (mc != null) {
withApi {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -255,8 +372,8 @@ fun ComposeView(
}
}
}
clearState()
}
clearState()
}
fun onMessageChange(s: String) {
@@ -343,7 +460,7 @@ fun ComposeView(
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showAttachmentBottomSheet()
showChooseAttachment()
}
}
)
@@ -0,0 +1,54 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.newchat.ActionButton
sealed class AttachmentOption {
object TakePhoto: AttachmentOption()
object PickImage: AttachmentOption()
object PickFile: AttachmentOption()
}
@Composable
fun ChooseAttachmentView(
attachmentOption: MutableState<AttachmentOption?>,
hide: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
attachmentOption.value = AttachmentOption.TakePhoto
hide()
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
attachmentOption.value = AttachmentOption.PickImage
hide()
}
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
attachmentOption.value = AttachmentOption.PickFile
hide()
}
}
}
}
@@ -127,18 +127,15 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
}
}
}
//class GetGalleryContent: ActivityResultContracts.GetContent() {
// override fun createIntent(context: Context, input: String): Intent {
// super.createIntent(context, input)
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
// }
//}
//@Composable
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
@@ -29,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
@@ -124,9 +124,8 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@State private var showTakePhoto = false
@State var chosenImage: UIImage? = nil
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@@ -171,27 +170,26 @@ struct ComposeView: View {
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
imageSource = .camera
showImagePicker = true
showTakePhoto = true
}
Button("Choose from library") {
imageSource = .imageLibrary
showImagePicker = true
}
Button("Choose file") {
showFileImporter = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
case .imageLibrary:
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
case .camera:
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
}
.onChange(of: chosenImage) { image in
if let image = image,
let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) {
@@ -9,11 +9,6 @@
import SwiftUI
import PhotosUI
enum ImageSource {
case imageLibrary
case camera
}
struct LibraryImagePicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
@Binding var image: UIImage?
@@ -14,7 +14,7 @@ struct UserProfile: View {
@State private var editProfile = false
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
var body: some View {
@@ -87,24 +87,23 @@ struct UserProfile: View {
.frame(maxHeight: .infinity, alignment: .top)
.confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
imageSource = .camera
showImagePicker = true
showTakePhoto = true
}
Button("Choose from library") {
imageSource = .imageLibrary
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
case .imageLibrary:
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
case .camera:
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
}
.onChange(of: chosenImage) { image in
if let image = image {
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)