diff --git a/app/build.gradle b/app/build.gradle index 99498baa6..a5aa0ebb4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ import com.github.spotbugs.snom.SpotBugsTask plugins { id "org.jetbrains.kotlin.plugin.compose" version "2.0.20" id "org.jetbrains.kotlin.kapt" - id 'com.google.devtools.ksp' version '2.0.20-1.0.25' + id 'com.google.devtools.ksp' version '2.0.20-1.0.24' } apply plugin: 'com.android.application' diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index d08d2b317..537bdeb14 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -11,13 +11,16 @@ import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall +import okhttp3.MultipartBody import retrofit2.http.DELETE import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Query import retrofit2.http.QueryMap import retrofit2.http.Url @@ -96,4 +99,15 @@ interface NcApiCoroutines { @Url url: String?, @Field("password") password: String? ): GenericOverall + + @Multipart + @POST + suspend fun uploadConversationAvatar( + @Header("Authorization") authorization: String, + @Url url: String, + @Part attachment: MultipartBody.Part + ): RoomOverall + + @DELETE + suspend fun deleteConversationAvatar(@Header("Authorization") authorization: String, @Url url: String): RoomOverall } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 021f6e9cf..89acf6c85 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -13,6 +13,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -57,7 +59,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.colorResource @@ -77,6 +81,7 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.contacts.ContactsActivityCompose import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser +import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject @@ -84,7 +89,7 @@ import javax.inject.Inject class ConversationCreationActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - + private lateinit var pickImage: PickImage override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,13 +98,16 @@ class ConversationCreationActivity : BaseActivity() { this, viewModelFactory )[ConversationCreationViewModel::class.java] + val conversationUser = conversationCreationViewModel.currentUser + pickImage = PickImage(this, conversationUser) + setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current MaterialTheme( colorScheme = colorScheme ) { - ConversationCreationScreen(conversationCreationViewModel, context) + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) } SetStatusBarColor() } @@ -125,15 +133,47 @@ private fun SetStatusBarColor() { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationCreationScreen(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { +fun ConversationCreationScreen( + conversationCreationViewModel: ConversationCreationViewModel, + context: Context, + pickImage: PickImage +) { + var selectedImageUri by remember { mutableStateOf(null) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + pickImage.onImagePickerResult(result.data) { uri -> + selectedImageUri = uri + } + } + } + + val remoteFilePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + pickImage.onSelectRemoteFilesResult(imagePickerLauncher, result.data) + } + } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + pickImage.onTakePictureResult(imagePickerLauncher, result.data) + } + } + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> if (result.resultCode == Activity.RESULT_OK) { val data = result.data - val selectedParticipants = data?.getParcelableArrayListExtra("selectedParticipants") - ?: emptyList() + val selectedParticipants = + data?.getParcelableArrayListExtra("selectedParticipants") + ?: emptyList() val participants = selectedParticipants.toMutableList() conversationCreationViewModel.updateSelectedParticipants(participants) } @@ -162,43 +202,75 @@ fun ConversationCreationScreen(conversationCreationViewModel: ConversationCreati .padding(paddingValues) .verticalScroll(rememberScrollState()) ) { - DefaultUserAvatar() - UploadAvatar() + DefaultUserAvatar(selectedImageUri) + UploadAvatar( + pickImage = pickImage, + onImageSelected = { uri -> selectedImageUri = uri }, + imagePickerLauncher = imagePickerLauncher, + remoteFilePickerLauncher = remoteFilePickerLauncher, + cameraLauncher = cameraLauncher, + onDeleteImage = { selectedImageUri = null } + ) + ConversationNameAndDescription(conversationCreationViewModel) AddParticipants(launcher, context, conversationCreationViewModel) RoomCreationOptions(conversationCreationViewModel) - CreateConversation(conversationCreationViewModel, context) + CreateConversation(conversationCreationViewModel, context, selectedImageUri) } } ) } @Composable -fun DefaultUserAvatar() { +fun DefaultUserAvatar(selectedImageUri: Uri?) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - AsyncImage( - model = R.drawable.ic_circular_group, - contentDescription = stringResource(id = R.string.user_avatar), - modifier = Modifier - .size(width = 84.dp, height = 84.dp) - .padding(top = 8.dp) - ) + if (selectedImageUri != null) { + AsyncImage( + model = selectedImageUri, + contentDescription = stringResource(id = R.string.user_avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(84.dp) + .padding(top = 8.dp) + .clip(CircleShape) + ) + } else { + AsyncImage( + model = R.drawable.ic_circular_group, + contentDescription = stringResource(id = R.string.user_avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(84.dp) + .padding(top = 8.dp) + .clip(CircleShape) + ) + } } } @Composable -fun UploadAvatar() { +fun UploadAvatar( + pickImage: PickImage, + onImageSelected: (Uri) -> Unit, + imagePickerLauncher: ManagedActivityResultLauncher, + remoteFilePickerLauncher: ManagedActivityResultLauncher, + cameraLauncher: ManagedActivityResultLauncher, + onDeleteImage: () -> Unit +) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.Center ) { - IconButton(onClick = { - }) { + IconButton( + onClick = { + pickImage.takePicture(cameraLauncher) + } + ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_photo_camera_24), contentDescription = null, @@ -207,24 +279,28 @@ fun UploadAvatar() { } IconButton(onClick = { + pickImage.selectLocal(imagePickerLauncher) }) { Icon( - painter = painterResource(id = R.drawable.ic_folder_multiple_image), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - - IconButton(onClick = { - }) { - Icon( - painter = painterResource(id = R.drawable.baseline_tag_faces_24), + painter = painterResource(id = R.drawable.upload), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + IconButton( + onClick = { + pickImage.selectRemote(remoteFilePickerLauncher) + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mimetype_folder), contentDescription = null, modifier = Modifier.size(24.dp) ) } IconButton(onClick = { + onDeleteImage() }) { Icon( painter = painterResource(id = R.drawable.ic_delete_grey600_24dp), @@ -502,7 +578,11 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con } @Composable -fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { +fun CreateConversation( + conversationCreationViewModel: ConversationCreationViewModel, + context: Context, + selectedImageUri: Uri? +) { val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState() Box( modifier = Modifier @@ -515,7 +595,8 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo conversationCreationViewModel.createRoomAndAddParticipants( roomType = CompanionClass.ROOM_TYPE_GROUP, conversationName = conversationCreationViewModel.roomName.value, - participants = selectedParticipants.toSet() + participants = selectedParticipants.toSet(), + selectedImageUri = selectedImageUri ) { roomToken -> val bundle = Bundle() bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) @@ -530,6 +611,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo } } } + class CompanionClass { companion object { internal val TAG = ConversationCreationActivity::class.simpleName diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepository.kt index 6ebb6c350..12c9dbad8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepository.kt @@ -7,9 +7,11 @@ package com.nextcloud.talk.conversationcreation +import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall +import java.io.File interface ConversationCreationRepository { @@ -21,4 +23,6 @@ interface ConversationCreationRepository { suspend fun createRoom(roomType: String, conversationName: String?): RoomOverall fun getImageUri(avatarId: String, requestBigSize: Boolean): String suspend fun setPassword(roomToken: String, password: String): GenericOverall + suspend fun uploadConversationAvatar(file: File, roomToken: String): ConversationModel + suspend fun deleteConversationAvatar(roomToken: String): ConversationModel } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepositoryImpl.kt index 4e00f174c..1f804e3ad 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepositoryImpl.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.conversationcreation import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RetrofitBucket +import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall @@ -17,6 +18,11 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipant import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipantWithSource +import com.nextcloud.talk.utils.Mimetype +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File class ConversationCreationRepositoryImpl( private val ncApiCoroutines: NcApiCoroutines, @@ -126,6 +132,33 @@ class ConversationCreationRepositoryImpl( return result } + override suspend fun uploadConversationAvatar(file: File, roomToken: String): ConversationModel { + val builder = MultipartBody.Builder() + builder.setType(MultipartBody.FORM) + builder.addFormDataPart( + "file", + file.name, + file.asRequestBody(Mimetype.IMAGE_PREFIX_GENERIC.toMediaTypeOrNull()) + ) + val filePart: MultipartBody.Part = MultipartBody.Part.createFormData( + "file", + file.name, + file.asRequestBody(Mimetype.IMAGE_JPG.toMediaTypeOrNull()) + ) + val response = ncApiCoroutines.uploadConversationAvatar( + credentials!!, + ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, roomToken), + filePart + ) + return ConversationModel.mapToConversationModel(response.ocs?.data!!, _currentUser) + } + + override suspend fun deleteConversationAvatar(roomToken: String): ConversationModel { + val url = ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, roomToken) + val response = ncApiCoroutines.deleteConversationAvatar(credentials!!, url) + return ConversationModel.mapToConversationModel(response.ocs?.data!!, _currentUser) + } + override suspend fun allowGuests(token: String, allow: Boolean): GenericOverall { val url = ApiUtils.getUrlForRoomPublic( apiVersion, diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModel.kt index 759637cde..38586a6e3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModel.kt @@ -7,26 +7,34 @@ package com.nextcloud.talk.conversationcreation +import android.net.Uri import android.util.Log import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.generic.GenericMeta import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl.Companion.STATUS_CODE_OK +import com.nextcloud.talk.users.UserManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject class ConversationCreationViewModel @Inject constructor( - private val repository: ConversationCreationRepository + private val repository: ConversationCreationRepository, + private val userManager: UserManager ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) val selectedParticipants: StateFlow> = _selectedParticipants private val roomViewState = MutableStateFlow(RoomUIState.None) + private val _currentUser = userManager.currentUser.blockingGet() + val currentUser: User = _currentUser + fun updateSelectedParticipants(participants: List) { _selectedParticipants.value = participants } @@ -58,6 +66,7 @@ class ConversationCreationViewModel @Inject constructor( roomType: String, conversationName: String, participants: Set, + selectedImageUri: Uri?, onRoomCreated: (String) -> Unit ) { val scope = when { @@ -100,6 +109,9 @@ class ConversationCreationViewModel @Inject constructor( repository.setPassword(token, _password.value) } repository.openConversation(token, scope) + if (selectedImageUri != null) { + repository.uploadConversationAvatar(selectedImageUri.toFile(), token) + } onRoomCreated(token) } catch (exception: Exception) { allowGuestsResult.value = AllowGuestsUiState.Error(exception.message ?: "")