Browse Source

Merge pull request #2712 from nextcloud/feature/2555/conversation-avatars

Conversation avatars
Marcel Hibbe 1 year ago
parent
commit
be216c4289
34 changed files with 1240 additions and 310 deletions
  1. 5 1
      app/src/main/AndroidManifest.xml
  2. 2 2
      app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt
  3. 3 2
      app/src/main/java/com/nextcloud/talk/adapters/ReactionsViewHolder.kt
  4. 2 1
      app/src/main/java/com/nextcloud/talk/adapters/items/AdvancedUserItem.java
  5. 4 3
      app/src/main/java/com/nextcloud/talk/adapters/items/ContactItem.java
  6. 22 14
      app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt
  7. 4 4
      app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java
  8. 5 5
      app/src/main/java/com/nextcloud/talk/adapters/items/ParticipantItem.java
  9. 112 61
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  10. 33 5
      app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
  11. 56 14
      app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
  12. 3 3
      app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt
  13. 381 0
      app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt
  14. 74 25
      app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt
  15. 3 1
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt
  16. 4 1
      app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt
  17. 6 0
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt
  18. 2 2
      app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt
  19. 2 2
      app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt
  20. 31 144
      app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt
  21. 2 1
      app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountDialogFragment.java
  22. 2 2
      app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountShareToDialogFragment.kt
  23. 13 0
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  24. 6 5
      app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java
  25. 203 0
      app/src/main/java/com/nextcloud/talk/utils/PickImage.kt
  26. 8 0
      app/src/main/java/com/nextcloud/talk/utils/database/user/CapabilitiesUtilNew.kt
  27. 1 1
      app/src/main/res/drawable/account_circle_96dp.xml
  28. 1 1
      app/src/main/res/drawable/ic_avatar_link.xml
  29. 11 9
      app/src/main/res/layout/activity_conversation_info.xml
  30. 170 0
      app/src/main/res/layout/activity_conversation_info_edit.xml
  31. 32 0
      app/src/main/res/menu/menu_conversation_info.xml
  32. 32 0
      app/src/main/res/menu/menu_conversation_info_edit.xml
  33. 4 1
      app/src/main/res/menu/menu_profile.xml
  34. 1 0
      app/src/main/res/values/strings.xml

+ 5 - 1
app/src/main/AndroidManifest.xml

@@ -211,7 +211,11 @@
             android:theme="@style/AppTheme" />
 
         <activity
-            android:name=".conversation.info.ConversationInfoActivity"
+            android:name=".conversationinfo.ConversationInfoActivity"
+            android:theme="@style/AppTheme" />
+
+        <activity
+            android:name=".conversationinfoedit.ConversationInfoEditActivity"
             android:theme="@style/AppTheme" />
 
         <activity

+ 2 - 2
app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt

@@ -39,7 +39,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.CallNotificationActivityBinding
-import com.nextcloud.talk.extensions.loadAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.models.json.conversations.Conversation
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.participants.Participant
@@ -249,7 +249,7 @@ class CallNotificationActivity : CallBaseActivity() {
     private fun setUpAfterConversationIsKnown() {
         binding!!.conversationNameTextView.text = currentConversation!!.displayName
         if (currentConversation!!.type === Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
-            binding!!.avatarImageView.loadAvatar(userBeingCalled!!, currentConversation!!.name!!)
+            binding!!.avatarImageView.loadUserAvatar(userBeingCalled!!, currentConversation!!.name!!, true, false)
         } else {
             binding!!.avatarImageView.setImageResource(R.drawable.ic_circular_group)
         }

+ 3 - 2
app/src/main/java/com/nextcloud/talk/adapters/ReactionsViewHolder.kt

@@ -26,8 +26,8 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ReactionItemBinding
-import com.nextcloud.talk.extensions.loadAvatar
 import com.nextcloud.talk.extensions.loadGuestAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.models.json.reactions.ReactionVoter
 
 class ReactionsViewHolder(
@@ -53,9 +53,10 @@ class ReactionsViewHolder(
             }
             binding.avatar.loadGuestAvatar(user!!.baseUrl!!, displayName!!, false)
         } else if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) {
-            binding.avatar.loadAvatar(
+            binding.avatar.loadUserAvatar(
                 user!!,
                 reactionItem.reactionVoter.actorId!!,
+                false,
                 false
             )
         }

+ 2 - 1
app/src/main/java/com/nextcloud/talk/adapters/items/AdvancedUserItem.java

@@ -128,7 +128,8 @@ public class AdvancedUserItem extends AbstractFlexibleItem<AdvancedUserItem.User
         if (user != null &&
             user.getBaseUrl() != null &&
             (user.getBaseUrl().startsWith("http://") || user.getBaseUrl().startsWith("https://"))) {
-            ImageViewExtensionsKt.loadAvatar(holder.binding.userIcon, user, participant.getCalculatedActorId(), true);
+            ImageViewExtensionsKt.loadUserAvatar(holder.binding.userIcon, user, participant.getCalculatedActorId(),
+                                                 true, false);
         }
     }
 

+ 4 - 3
app/src/main/java/com/nextcloud/talk/adapters/items/ContactItem.java

@@ -181,10 +181,11 @@ public class ContactItem extends AbstractFlexibleItem<ContactItem.ContactItemVie
                 displayName = "Guest";
             }
 
-            ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView, user, displayName, true);
+            ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, user, displayName, true, false);
         } else if (participant.getCalculatedActorType() == Participant.ActorType.USERS ||
             PARTICIPANT_SOURCE_USERS.equals(participant.getSource())) {
-            ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView, user, participant.getCalculatedActorId(), true);
+            ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, user, participant.getCalculatedActorId(),
+                                                 true, false);
         }
     }
 
@@ -203,7 +204,7 @@ public class ContactItem extends AbstractFlexibleItem<ContactItem.ContactItemVie
             avatar = fallbackImageResource;
         }
 
-        ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView, avatar);
+        ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, avatar);
     }
 
     @Override

+ 22 - 14
app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt

@@ -40,10 +40,9 @@ import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHo
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding
-import com.nextcloud.talk.extensions.loadAvatar
-import com.nextcloud.talk.extensions.loadGroupCallAvatar
-import com.nextcloud.talk.extensions.loadPublicCallAvatar
+import com.nextcloud.talk.extensions.loadConversationAvatar
 import com.nextcloud.talk.extensions.loadSystemAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.models.json.conversations.Conversation
 import com.nextcloud.talk.models.json.conversations.Conversation.ConversationType
@@ -169,17 +168,24 @@ class ConversationItem(
         }
         if (shouldLoadAvatar) {
             when (model.type) {
-                ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(model.name)) {
-                    holder.binding.dialogAvatar.loadAvatar(user, model.name!!)
-                } else {
-                    holder.binding.dialogAvatar.visibility = View.GONE
+                ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> {
+                    if (!TextUtils.isEmpty(model.name)) {
+                        holder.binding.dialogAvatar.loadUserAvatar(
+                            user,
+                            model.name!!,
+                            true,
+                            false
+                        )
+                    } else {
+                        holder.binding.dialogAvatar.visibility = View.GONE
+                    }
                 }
-                ConversationType.ROOM_GROUP_CALL ->
-                    holder.binding.dialogAvatar.loadGroupCallAvatar(viewThemeUtils)
-                ConversationType.FORMER_ONE_TO_ONE ->
-                    holder.binding.dialogAvatar.loadGroupCallAvatar(viewThemeUtils)
+
+                ConversationType.ROOM_GROUP_CALL,
+                ConversationType.FORMER_ONE_TO_ONE,
                 ConversationType.ROOM_PUBLIC_CALL ->
-                    holder.binding.dialogAvatar.loadPublicCallAvatar(viewThemeUtils)
+                    holder.binding.dialogAvatar.loadConversationAvatar(user, model, false, viewThemeUtils)
+
                 else -> holder.binding.dialogAvatar.visibility = View.GONE
             }
         }
@@ -198,21 +204,23 @@ class ConversationItem(
                 )
                 false
             }
+
             Conversation.ObjectType.FILE -> {
                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-                    holder.binding.dialogAvatar.loadAvatar(
+                    holder.binding.dialogAvatar.loadUserAvatar(
                         viewThemeUtils.talk.themePlaceholderAvatar(
                             holder.binding.dialogAvatar,
                             R.drawable.ic_avatar_document
                         )
                     )
                 } else {
-                    holder.binding.dialogAvatar.loadAvatar(
+                    holder.binding.dialogAvatar.loadUserAvatar(
                         R.drawable.ic_circular_document
                     )
                 }
                 false
             }
+
             else -> true
         }
     }

+ 4 - 4
app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java

@@ -157,22 +157,22 @@ public class MentionAutocompleteItem extends AbstractFlexibleItem<ParticipantIte
 
         if (SOURCE_CALLS.equals(source) || SOURCE_GROUPS.equals(source)) {
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-                ImageViewExtensionsKt.loadAvatar(
+                ImageViewExtensionsKt.loadUserAvatar(
                     holder.binding.avatarView,
                     viewThemeUtils.talk.themePlaceholderAvatar(
                         holder.binding.avatarView,
                         R.drawable.ic_avatar_group
                                                               )
-                                                );
+                                                    );
             } else {
-                ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView, R.drawable.ic_circular_group);
+                ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, R.drawable.ic_circular_group);
             }
         } else {
             String avatarId = objectId;
             if (SOURCE_GUESTS.equals(source)) {
                 avatarId = displayName;
             }
-            ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView, currentUser, avatarId, true);
+            ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView, currentUser, avatarId, true, false);
         }
 
         drawStatus(holder);

+ 5 - 5
app/src/main/java/com/nextcloud/talk/adapters/items/ParticipantItem.java

@@ -147,7 +147,7 @@ public class ParticipantItem extends AbstractFlexibleItem<ParticipantItem.Partic
             "groups".equals(participant.getSource()) ||
             participant.getCalculatedActorType() == Participant.ActorType.CIRCLES ||
             "circles".equals(participant.getSource())) {
-            ImageViewExtensionsKt.loadGroupCallAvatar(holder.binding.avatarView, viewThemeUtils);
+            ImageViewExtensionsKt.loadDefaultGroupCallAvatar(holder.binding.avatarView, viewThemeUtils);
         } else if (participant.getCalculatedActorType() == Participant.ActorType.EMAILS) {
             ImageViewExtensionsKt.loadMailAvatar(holder.binding.avatarView, viewThemeUtils);
         } else if (participant.getCalculatedActorType() == Participant.ActorType.GUESTS ||
@@ -168,10 +168,10 @@ public class ParticipantItem extends AbstractFlexibleItem<ParticipantItem.Partic
 
         } else if (participant.getCalculatedActorType() == Participant.ActorType.USERS ||
             "users".equals(participant.getSource())) {
-            ImageViewExtensionsKt.loadAvatar(holder.binding.avatarView,
-                                             user,
-                                             participant.getCalculatedActorId(),
-                                             true);
+            ImageViewExtensionsKt.loadUserAvatar(holder.binding.avatarView,
+                                                 user,
+                                                 participant.getCalculatedActorId(),
+                                                 true, false);
         }
 
         Resources resources = NextcloudTalkApplication.Companion.getSharedApplication().getResources();

+ 112 - 61
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -92,16 +92,19 @@ public interface NcApi {
 
      */
     @GET
-    Observable<ResponseBody> getContactsWithSearchParam(@Header("Authorization") String authorization, @Url String url,
-                                                        @Nullable @Query("shareTypes[]") List<String> listOfShareTypes, @QueryMap Map<String, Object> options);
+    Observable<ResponseBody> getContactsWithSearchParam(@Header("Authorization") String authorization,
+                                                        @Url String url,
+                                                        @Nullable @Query("shareTypes[]") List<String> listOfShareTypes,
+                                                        @QueryMap Map<String, Object> options);
 
 
     /*
         Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
      */
     @GET
-    Observable<RoomsOverall> getRooms(@Header("Authorization") String authorization, @Url String url,
-                                     @Nullable @Query("includeStatus") Boolean includeStatus);
+    Observable<RoomsOverall> getRooms(@Header("Authorization") String authorization,
+                                      @Url String url,
+                                      @Nullable @Query("includeStatus") Boolean includeStatus);
 
     /*
         Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken
@@ -118,7 +121,8 @@ public interface NcApi {
      */
 
     @POST
-    Observable<RoomOverall> createRoom(@Header("Authorization") String authorization, @Url String url,
+    Observable<RoomOverall> createRoom(@Header("Authorization") String authorization,
+                                       @Url String url,
                                        @QueryMap Map<String, String> options);
 
     /*
@@ -130,10 +134,17 @@ public interface NcApi {
 
     @FormUrlEncoded
     @PUT
-    Observable<GenericOverall> renameRoom(@Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> renameRoom(@Header("Authorization") String authorization,
+                                          @Url String url,
                                           @Field("roomName") String roomName);
 
 
+    @FormUrlEncoded
+    @PUT
+    Observable<GenericOverall> setConversationDescription(@Header("Authorization") String authorization,
+                                                          @Url String url,
+                                                          @Field("description") String description);
+
     /*
         QueryMap items are as follows:
             - "newParticipant" : "user"
@@ -141,8 +152,10 @@ public interface NcApi {
         Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants
     */
     @POST
-    Observable<AddParticipantOverall> addParticipant(@Header("Authorization") String authorization, @Url String url,
-                                                     @QueryMap Map<String, String> options);
+    Observable<AddParticipantOverall> addParticipant(@Header("Authorization") String authorization,
+                                                     @Url String url,
+                                                     @QueryMap Map<String,
+                                                         String> options);
 
     @POST
     Observable<GenericOverall> resendParticipantInvitations(@Header("Authorization") String authorization,
@@ -151,24 +164,36 @@ public interface NcApi {
     // also used for removing a guest from a conversation
     @Deprecated
     @DELETE
-    Observable<GenericOverall> removeParticipantFromConversation(@Header("Authorization") String authorization, @Url String url, @Query("participant") String participantId);
+    Observable<GenericOverall> removeParticipantFromConversation(@Header("Authorization") String authorization,
+                                                                 @Url String url,
+                                                                 @Query("participant") String participantId);
 
     @DELETE
-    Observable<GenericOverall> removeAttendeeFromConversation(@Header("Authorization") String authorization, @Url String url, @Query("attendeeId") Long attendeeId);
+    Observable<GenericOverall> removeAttendeeFromConversation(@Header("Authorization") String authorization,
+                                                              @Url String url,
+                                                              @Query("attendeeId") Long attendeeId);
 
     @Deprecated
     @POST
-    Observable<GenericOverall> promoteUserToModerator(@Header("Authorization") String authorization, @Url String url, @Query("participant") String participantId);
+    Observable<GenericOverall> promoteUserToModerator(@Header("Authorization") String authorization,
+                                                      @Url String url,
+                                                      @Query("participant") String participantId);
 
     @Deprecated
     @DELETE
-    Observable<GenericOverall> demoteModeratorToUser(@Header("Authorization") String authorization, @Url String url, @Query("participant") String participantId);
+    Observable<GenericOverall> demoteModeratorToUser(@Header("Authorization") String authorization,
+                                                     @Url String url,
+                                                     @Query("participant") String participantId);
 
     @POST
-    Observable<GenericOverall> promoteAttendeeToModerator(@Header("Authorization") String authorization, @Url String url, @Query("attendeeId") Long attendeeId);
+    Observable<GenericOverall> promoteAttendeeToModerator(@Header("Authorization") String authorization,
+                                                          @Url String url,
+                                                          @Query("attendeeId") Long attendeeId);
 
     @DELETE
-    Observable<GenericOverall> demoteAttendeeFromModerator(@Header("Authorization") String authorization, @Url String url, @Query("attendeeId") Long attendeeId);
+    Observable<GenericOverall> demoteAttendeeFromModerator(@Header("Authorization") String authorization,
+                                                           @Url String url,
+                                                           @Query("attendeeId") Long attendeeId);
 
     /*
         Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants/self
@@ -199,12 +224,15 @@ public interface NcApi {
     Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization, @Url String url);
 
     @GET
-    Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization, @Url String url,
+    Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization,
+                                                    @Url String url,
                                                     @QueryMap Map<String, Boolean> fields);
 
     @FormUrlEncoded
     @POST
-    Observable<RoomOverall> joinRoom(@Nullable @Header("Authorization") String authorization, @Url String url, @Nullable @Field("password") String password);
+    Observable<RoomOverall> joinRoom(@Nullable @Header("Authorization") String authorization,
+                                     @Url String url,
+                                     @Nullable @Field("password") String password);
 
     @DELETE
     Observable<GenericOverall> leaveRoom(@Nullable @Header("Authorization") String authorization, @Url String url);
@@ -237,16 +265,16 @@ public interface NcApi {
     */
     @FormUrlEncoded
     @POST
-    Observable<SignalingOverall> sendSignalingMessages(@Nullable @Header("Authorization") String authorization, @Url String url,
+    Observable<SignalingOverall> sendSignalingMessages(@Nullable @Header("Authorization") String authorization,
+                                                       @Url String url,
                                                        @Field("messages") String messages);
 
     /*
         Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /signaling
     */
     @GET
-    Observable<SignalingOverall> pullSignalingMessages(@Nullable @Header("Authorization") String authorization, @Url
-        String
-        url);
+    Observable<SignalingOverall> pullSignalingMessages(@Nullable @Header("Authorization") String authorization,
+                                                       @Url String url);
 
      /*
         QueryMap items are as follows:
@@ -264,8 +292,10 @@ public interface NcApi {
 
     @FormUrlEncoded
     @PUT
-    Observable<GenericOverall> setUserData(@Header("Authorization") String authorization, @Url String url,
-                                           @Field("key") String key, @Field("value") String value);
+    Observable<GenericOverall> setUserData(@Header("Authorization") String authorization,
+                                           @Url String url,
+                                           @Field("key") String key,
+                                           @Field("value") String value);
 
 
     /*
@@ -286,16 +316,15 @@ public interface NcApi {
      */
 
     @POST
-    Observable<PushRegistrationOverall> registerDeviceForNotificationsWithNextcloud(@Header("Authorization")
-                                                                                        String authorization,
-                                                                                    @Url String url,
-                                                                                    @QueryMap Map<String,
-                                                                                        String> options);
+    Observable<PushRegistrationOverall> registerDeviceForNotificationsWithNextcloud(
+        @Header("Authorization") String authorization,
+        @Url String url,
+        @QueryMap Map<String, String> options);
 
     @DELETE
-    Observable<GenericOverall> unregisterDeviceForNotificationsWithNextcloud(@Header("Authorization")
-                                                                                 String authorization,
-                                                                             @Url String url);
+    Observable<GenericOverall> unregisterDeviceForNotificationsWithNextcloud(
+        @Header("Authorization") String authorization,
+        @Url String url);
 
     @FormUrlEncoded
     @POST
@@ -311,11 +340,12 @@ public interface NcApi {
     */
     @DELETE
     Observable<Void> unregisterDeviceForNotificationsWithProxy(@Url String url,
-                                                               @QueryMap Map<String, String> fields);
+                                                               @QueryMap Map<String,String> fields);
 
     @FormUrlEncoded
     @PUT
-    Observable<GenericOverall> setPassword(@Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> setPassword(@Header("Authorization") String authorization,
+                                           @Url String url,
                                            @Field("password") String password);
 
     @FormUrlEncoded
@@ -335,7 +365,8 @@ public interface NcApi {
          - "lastKnownMessageId", int, use one from X-Chat-Last-Given
    */
     @GET
-    Observable<Response<ChatOverall>> pullChatMessages(@Header("Authorization") String authorization, @Url String url,
+    Observable<Response<ChatOverall>> pullChatMessages(@Header("Authorization") String authorization,
+                                                       @Url String url,
                                                        @QueryMap Map<String, Integer> fields);
 
     /*
@@ -354,10 +385,12 @@ public interface NcApi {
                                                @Field("silent") Boolean sendWithoutNotification);
 
     @GET
-    Observable<Response<ChatShareOverall>> getSharedItems(@Header("Authorization") String authorization, @Url String url,
-                                                          @Query("objectType") String objectType,
-                                                          @Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId,
-                                                          @Nullable @Query("limit") Integer limit);
+    Observable<Response<ChatShareOverall>> getSharedItems(
+        @Header("Authorization") String authorization,
+        @Url String url,
+        @Query("objectType") String objectType,
+        @Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId,
+        @Nullable @Query("limit") Integer limit);
 
     @GET
     Observable<Response<ChatShareOverviewOverall>> getSharedItemsOverview(@Header("Authorization") String authorization,
@@ -367,7 +400,8 @@ public interface NcApi {
 
     @GET
     Observable<MentionOverall> getMentionAutocompleteSuggestions(@Header("Authorization") String authorization,
-                                                                 @Url String url, @Query("search") String query,
+                                                                 @Url String url,
+                                                                 @Query("search") String query,
                                                                  @Nullable @Query("limit") Integer limit,
                                                                  @QueryMap Map<String, String> fields);
 
@@ -387,24 +421,30 @@ public interface NcApi {
 
     @FormUrlEncoded
     @POST
-    Observable<GenericOverall> setNotificationLevel(@Header("Authorization") String authorization, @Url String url, @Field("level") int level);
+    Observable<GenericOverall> setNotificationLevel(@Header("Authorization") String authorization,
+                                                    @Url String url,
+                                                    @Field("level") int level);
 
     @FormUrlEncoded
     @PUT
-    Observable<GenericOverall> setReadOnlyState(@Header("Authorization") String authorization, @Url String url, @Field("state") int state);
+    Observable<GenericOverall> setReadOnlyState(@Header("Authorization") String authorization,
+                                                @Url String url,
+                                                @Field("state") int state);
 
     @FormUrlEncoded
     @POST
-    Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization") String authorization, @Url String url,
-                                       @Field("path") String remotePath,
-                                       @Field("shareWith") String roomToken,
-                                       @Field("shareType") String shareType,
-                                       @Field("talkMetaData") String talkMetaData);
+    Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization") String authorization,
+                                                 @Url String url,
+                                                 @Field("path") String remotePath,
+                                                 @Field("shareWith") String roomToken,
+                                                 @Field("shareType") String shareType,
+                                                 @Field("talkMetaData") String talkMetaData);
 
     @FormUrlEncoded
     @PUT
     Observable<GenericOverall> setLobbyForConversation(@Header("Authorization") String authorization,
-                                                       @Url String url, @Field("state") Integer state,
+                                                       @Url String url,
+                                                       @Field("state") Integer state,
                                                        @Field("timer") Long timer);
 
     @POST
@@ -424,7 +464,7 @@ public interface NcApi {
 
     @HEAD
     Observable<Response<Void>> checkIfFileExists(@Header("Authorization") String authorization,
-                                               @Url String url);
+                                                 @Url String url);
 
     @GET
     Call<ResponseBody> downloadFile(@Header("Authorization") String authorization,
@@ -437,12 +477,22 @@ public interface NcApi {
     @DELETE
     Observable<GenericOverall> deleteAvatar(@Header("Authorization") String authorization, @Url String url);
 
+    @DELETE
+    Observable<RoomOverall> deleteConversationAvatar(@Header("Authorization") String authorization, @Url String url);
+
+
     @Multipart
     @POST
     Observable<GenericOverall> uploadAvatar(@Header("Authorization") String authorization,
                                             @Url String url,
                                             @Part MultipartBody.Part attachment);
 
+    @Multipart
+    @POST
+    Observable<RoomOverall> uploadConversationAvatar(@Header("Authorization") String authorization,
+                                            @Url String url,
+                                            @Part MultipartBody.Part attachment);
+
     @GET
     Observable<UserProfileFieldsOverall> getEditableUserProfileFields(@Header("Authorization") String authorization,
                                                                       @Url String url);
@@ -464,7 +514,8 @@ public interface NcApi {
 
     @FormUrlEncoded
     @POST
-    Observable<GenericOverall> notificationCalls(@Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> notificationCalls(@Header("Authorization") String authorization,
+                                                 @Url String url,
                                                  @Field("level") Integer level);
 
     @GET
@@ -524,11 +575,13 @@ public interface NcApi {
 
 
     @POST
-    Observable<GenericOverall> sendReaction(@Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> sendReaction(@Header("Authorization") String authorization,
+                                            @Url String url,
                                             @Query("reaction") String reaction);
 
     @DELETE
-    Observable<GenericOverall> deleteReaction(@Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> deleteReaction(@Header("Authorization") String authorization,
+                                              @Url String url,
                                               @Query("reaction") String reaction);
 
     @GET
@@ -570,8 +623,8 @@ public interface NcApi {
     @FormUrlEncoded
     @POST
     Observable<GenericOverall> setMessageExpiration(@Header("Authorization") String authorization,
-                                     @Url String url,
-                                     @Field("seconds") Integer seconds);
+                                                    @Url String url,
+                                                    @Field("seconds") Integer seconds);
 
     @GET
     Observable<OpenGraphOverall> getOpenGraph(@Header("Authorization") String authorization,
@@ -581,26 +634,24 @@ public interface NcApi {
     @FormUrlEncoded
     @POST
     Observable<GenericOverall> startRecording(@Header("Authorization") String authorization,
-                                                    @Url String url,
-                                                    @Field("status") Integer status);
+                                              @Url String url,
+                                              @Field("status") Integer status);
 
     @DELETE
     Observable<GenericOverall> stopRecording(@Header("Authorization") String authorization,
-                                              @Url String url);
+                                             @Url String url);
 
     @POST
     Observable<GenericOverall> requestAssistance(@Header("Authorization") String authorization,
-                                              @Url String url);
+                                                 @Url String url);
 
     @DELETE
     Observable<GenericOverall> withdrawRequestAssistance(@Header("Authorization") String authorization,
-                                             @Url String url);
+                                                         @Url String url);
 
     @POST
-    Observable<GenericOverall> sendCommonPostRequest(@Header("Authorization") String authorization,
-                                                 @Url String url);
+    Observable<GenericOverall> sendCommonPostRequest(@Header("Authorization") String authorization, @Url String url);
 
     @DELETE
-    Observable<GenericOverall> sendCommonDeleteRequest(@Header("Authorization") String authorization,
-                                                     @Url String url);
+    Observable<GenericOverall> sendCommonDeleteRequest(@Header("Authorization") String authorization, @Url String url);
 }

+ 33 - 5
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

@@ -88,6 +88,7 @@ import androidx.work.WorkManager
 import autodagger.AutoInjector
 import coil.imageLoader
 import coil.load
+import coil.request.CachePolicy
 import coil.request.ImageRequest
 import coil.target.Target
 import coil.transform.CircleCropTransformation
@@ -120,7 +121,7 @@ import com.nextcloud.talk.adapters.messages.VoiceMessageInterface
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.callbacks.MentionAutocompleteCallback
-import com.nextcloud.talk.conversation.info.ConversationInfoActivity
+import com.nextcloud.talk.conversationinfo.ConversationInfoActivity
 import com.nextcloud.talk.conversationlist.ConversationsListActivity
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ControllerChatBinding
@@ -159,6 +160,7 @@ import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.ContactUtils
 import com.nextcloud.talk.utils.DateConstants
 import com.nextcloud.talk.utils.DateUtils
+import com.nextcloud.talk.utils.DisplayUtils
 import com.nextcloud.talk.utils.FileUtils
 import com.nextcloud.talk.utils.ImageEmojiEditText
 import com.nextcloud.talk.utils.MagicCharPolicy
@@ -755,6 +757,7 @@ class ChatActivity :
                         downX = event.x
                         showRecordAudioUi(true)
                     }
+
                     MotionEvent.ACTION_CANCEL -> {
                         Log.d(TAG, "ACTION_CANCEL. same as for UP")
                         if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) {
@@ -765,6 +768,7 @@ class ChatActivity :
                         showRecordAudioUi(false)
                         binding?.messageInputView?.slideToCancelDescription?.x = sliderInitX
                     }
+
                     MotionEvent.ACTION_UP -> {
                         Log.d(TAG, "ACTION_UP. stop recording??")
                         if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) {
@@ -791,6 +795,7 @@ class ChatActivity :
 
                         binding?.messageInputView?.slideToCancelDescription?.x = sliderInitX
                     }
+
                     MotionEvent.ACTION_MOVE -> {
                         Log.d(TAG, "ACTION_MOVE.")
 
@@ -987,11 +992,16 @@ class ChatActivity :
 
     private fun loadAvatarForStatusBar() {
         if (isOneToOneConversation()) {
-            val url = ApiUtils.getUrlForAvatar(
+            var url = ApiUtils.getUrlForAvatar(
                 conversationUser!!.baseUrl,
                 currentConversation!!.name,
                 true
             )
+
+            if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext)) {
+                url = "$url/dark"
+            }
+
             val target = object : Target {
 
                 private fun setIcon(drawable: Drawable?) {
@@ -1022,18 +1032,24 @@ class ChatActivity :
                 ImageRequest.Builder(context)
                     .data(url)
                     .addHeader("Authorization", credentials)
-                    .placeholder(R.drawable.ic_user)
                     .transformations(CircleCropTransformation())
                     .crossfade(true)
                     .target(target)
+                    .memoryCachePolicy(CachePolicy.DISABLED)
+                    .diskCachePolicy(CachePolicy.DISABLED)
                     .build()
             )
         }
     }
 
     fun isOneToOneConversation() = currentConversation != null && currentConversation?.type != null &&
-        currentConversation?.type == Conversation.ConversationType
-        .ROOM_TYPE_ONE_TO_ONE_CALL
+        currentConversation?.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+
+    private fun isGroupConversation() = currentConversation != null && currentConversation?.type != null &&
+        currentConversation?.type == Conversation.ConversationType.ROOM_GROUP_CALL
+
+    private fun isPublicConversation() = currentConversation != null && currentConversation?.type != null &&
+        currentConversation?.type == Conversation.ConversationType.ROOM_PUBLIC_CALL
 
     private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) {
         if (conversationUser != null) {
@@ -1585,6 +1601,7 @@ class ChatActivity :
                         }
                 }
             }
+
             REQUEST_CODE_CHOOSE_FILE -> {
                 try {
                     checkNotNull(intent)
@@ -1612,6 +1629,7 @@ class ChatActivity :
                         1 -> context.resources?.getString(R.string.nc_upload_confirm_send_single)?.let {
                             String.format(it, title.trim())
                         }
+
                         else -> context.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let {
                             String.format(it, title.trim())
                         }
@@ -1651,6 +1669,7 @@ class ChatActivity :
                     Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
                 }
             }
+
             REQUEST_CODE_SELECT_CONTACT -> {
                 val contactUri = intent?.data ?: return
                 val cursor: Cursor? = contentResolver!!.query(contactUri, null, null, null, null)
@@ -1670,6 +1689,7 @@ class ChatActivity :
                 }
                 cursor?.close()
             }
+
             REQUEST_CODE_PICK_CAMERA -> {
                 if (resultCode == RESULT_OK) {
                     try {
@@ -1713,6 +1733,7 @@ class ChatActivity :
                     }
                 }
             }
+
             REQUEST_CODE_MESSAGE_SEARCH -> {
                 val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
                 messageId?.let { id ->
@@ -2340,6 +2361,7 @@ class ChatActivity :
                                 pullChatMessages(true, setReadMarker, xChatLastCommonRead)
                             }
                         }
+
                         HTTP_CODE_PRECONDITION_FAILED -> {
                             Log.d(TAG, "pullChatMessages - HTTP_CODE_PRECONDITION_FAILED.")
 
@@ -2349,6 +2371,7 @@ class ChatActivity :
                                 pastPreconditionFailed = true
                             }
                         }
+
                         HTTP_CODE_OK -> {
                             Log.d(TAG, "pullChatMessages - HTTP_CODE_OK.")
 
@@ -2763,22 +2786,27 @@ class ChatActivity :
                 startACall(false, false)
                 true
             }
+
             R.id.conversation_voice_call -> {
                 startACall(true, false)
                 true
             }
+
             R.id.conversation_info -> {
                 showConversationInfoScreen()
                 true
             }
+
             R.id.shared_items -> {
                 showSharedItems()
                 true
             }
+
             R.id.conversation_search -> {
                 startMessageSearch()
                 true
             }
+
             else -> super.onOptionsItemSelected(item)
         }
     }

+ 56 - 14
app/src/main/java/com/nextcloud/talk/conversation/info/ConversationInfoActivity.kt → app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt

@@ -24,7 +24,7 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-package com.nextcloud.talk.conversation.info
+package com.nextcloud.talk.conversationinfo
 
 import android.annotation.SuppressLint
 import android.content.Intent
@@ -33,6 +33,8 @@ import android.os.Bundle
 import android.os.Parcelable
 import android.text.TextUtils
 import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
 import android.view.View
 import android.view.View.GONE
 import android.view.View.VISIBLE
@@ -57,13 +59,13 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.contacts.ContactsActivity
 import com.nextcloud.talk.controllers.bottomsheet.items.BasicListItemWithImage
 import com.nextcloud.talk.controllers.bottomsheet.items.listItemsWithImage
+import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity
 import com.nextcloud.talk.data.user.model.User
-import com.nextcloud.talk.databinding.ControllerConversationInfoBinding
+import com.nextcloud.talk.databinding.ActivityConversationInfoBinding
 import com.nextcloud.talk.events.EventStatus
-import com.nextcloud.talk.extensions.loadAvatar
-import com.nextcloud.talk.extensions.loadGroupCallAvatar
-import com.nextcloud.talk.extensions.loadPublicCallAvatar
+import com.nextcloud.talk.extensions.loadConversationAvatar
 import com.nextcloud.talk.extensions.loadSystemAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.jobs.DeleteConversationWorker
 import com.nextcloud.talk.jobs.LeaveConversationWorker
 import com.nextcloud.talk.models.json.conversations.Conversation
@@ -91,6 +93,7 @@ import io.reactivex.disposables.Disposable
 import io.reactivex.schedulers.Schedulers
 import org.greenrobot.eventbus.Subscribe
 import org.greenrobot.eventbus.ThreadMode
+import org.parceler.Parcels
 import java.util.Calendar
 import java.util.Collections
 import java.util.Locale
@@ -101,7 +104,7 @@ class ConversationInfoActivity :
     BaseActivity(),
     FlexibleAdapter.OnItemClickListener {
 
-    private lateinit var binding: ControllerConversationInfoBinding
+    private lateinit var binding: ActivityConversationInfoBinding
 
     @Inject
     lateinit var ncApi: NcApi
@@ -125,6 +128,8 @@ class ConversationInfoActivity :
     private var adapter: FlexibleAdapter<ParticipantItem>? = null
     private var userItems: MutableList<ParticipantItem> = ArrayList()
 
+    private lateinit var optionsMenu: Menu
+
     private val workerData: Data?
         get() {
             if (!TextUtils.isEmpty(conversationToken) && conversationUser != null) {
@@ -141,7 +146,7 @@ class ConversationInfoActivity :
         super.onCreate(savedInstanceState)
         NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
 
-        binding = ControllerConversationInfoBinding.inflate(layoutInflater)
+        binding = ActivityConversationInfoBinding.inflate(layoutInflater)
         setupActionBar()
         setContentView(binding.root)
         setupSystemColors()
@@ -200,6 +205,43 @@ class ConversationInfoActivity :
         viewThemeUtils.material.themeToolbar(binding.conversationInfoToolbar)
     }
 
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        super.onCreateOptionsMenu(menu)
+        optionsMenu = menu
+        return true
+    }
+
+    fun showOptionsMenu() {
+        if (::optionsMenu.isInitialized) {
+            optionsMenu.clear()
+            if (CapabilitiesUtilNew.isConversationAvatarEndpointAvailable(conversationUser)) {
+                menuInflater.inflate(R.menu.menu_conversation_info, optionsMenu)
+            }
+        }
+    }
+
+    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+        super.onPrepareOptionsMenu(menu)
+        return true
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        if (item.itemId == R.id.edit) {
+            val bundle = Bundle()
+            bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser)
+            bundle.putParcelable(
+                BundleKeys.KEY_ACTIVE_CONVERSATION,
+                Parcels.wrap(conversation)
+            )
+            bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversationToken)
+
+            val intent = Intent(this, ConversationInfoEditActivity::class.java)
+            intent.putExtras(bundle)
+            startActivity(intent)
+        }
+        return true
+    }
+
     private fun themeSwitchPreferences() {
         binding.run {
             listOf(
@@ -628,6 +670,7 @@ class ConversationInfoActivity :
                         } else {
                             binding?.clearConversationHistory?.visibility = GONE
                         }
+                        showOptionsMenu()
                     } else {
                         binding?.addParticipantsAction?.visibility = GONE
                         binding?.clearConversationHistory?.visibility = GONE
@@ -760,16 +803,15 @@ class ConversationInfoActivity :
     private fun loadConversationAvatar() {
         when (conversation!!.type) {
             Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(conversation!!.name)) {
-                conversation!!.name?.let { binding?.avatarImage?.loadAvatar(conversationUser!!, it) }
-            }
-            Conversation.ConversationType.ROOM_GROUP_CALL -> {
-                binding?.avatarImage?.loadGroupCallAvatar(viewThemeUtils)
+                conversation!!.name?.let { binding.avatarImage.loadUserAvatar(conversationUser, it, true, false) }
             }
-            Conversation.ConversationType.ROOM_PUBLIC_CALL -> {
-                binding?.avatarImage?.loadPublicCallAvatar(viewThemeUtils)
+
+            Conversation.ConversationType.ROOM_GROUP_CALL, Conversation.ConversationType.ROOM_PUBLIC_CALL -> {
+                binding.avatarImage.loadConversationAvatar(conversationUser, conversation!!, false, viewThemeUtils)
             }
+
             Conversation.ConversationType.ROOM_SYSTEM -> {
-                binding?.avatarImage?.loadSystemAvatar()
+                binding.avatarImage.loadSystemAvatar()
             }
 
             else -> {

+ 3 - 3
app/src/main/java/com/nextcloud/talk/conversation/info/GuestAccessHelper.kt → app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt

@@ -1,4 +1,4 @@
-package com.nextcloud.talk.conversation.info
+package com.nextcloud.talk.conversationinfo
 
 import android.annotation.SuppressLint
 import android.content.Intent
@@ -11,7 +11,7 @@ import androidx.appcompat.widget.SwitchCompat
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.nextcloud.talk.R
 import com.nextcloud.talk.data.user.model.User
-import com.nextcloud.talk.databinding.ControllerConversationInfoBinding
+import com.nextcloud.talk.databinding.ActivityConversationInfoBinding
 import com.nextcloud.talk.databinding.DialogPasswordBinding
 import com.nextcloud.talk.models.json.conversations.Conversation
 import com.nextcloud.talk.repositories.conversations.ConversationsRepository
@@ -24,7 +24,7 @@ import io.reactivex.schedulers.Schedulers
 
 class GuestAccessHelper(
     private val activity: ConversationInfoActivity,
-    private val binding: ControllerConversationInfoBinding,
+    private val binding: ActivityConversationInfoBinding,
     private val conversation: Conversation,
     private val conversationUser: User
 ) {

+ 381 - 0
app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt

@@ -0,0 +1,381 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2023 Marcel Hibbe (dev@mhibbe.de)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.conversationinfoedit
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.core.net.toFile
+import androidx.core.view.ViewCompat
+import autodagger.AutoInjector
+import com.github.dhaval2404.imagepicker.ImagePicker
+import com.nextcloud.talk.R
+import com.nextcloud.talk.activities.BaseActivity
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.databinding.ActivityConversationInfoEditBinding
+import com.nextcloud.talk.extensions.loadConversationAvatar
+import com.nextcloud.talk.extensions.loadSystemAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
+import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.RoomOverall
+import com.nextcloud.talk.models.json.generic.GenericOverall
+import com.nextcloud.talk.repositories.conversations.ConversationsRepository
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.Mimetype
+import com.nextcloud.talk.utils.PickImage
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew
+import io.reactivex.Observer
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import org.parceler.Parcels
+import java.io.File
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class ConversationInfoEditActivity :
+    BaseActivity() {
+
+    private lateinit var binding: ActivityConversationInfoEditBinding
+
+    @Inject
+    lateinit var ncApi: NcApi
+
+    @Inject
+    lateinit var conversationsRepository: ConversationsRepository
+
+    private lateinit var conversationToken: String
+    private lateinit var conversationUser: User
+    private lateinit var credentials: String
+
+    private var conversation: Conversation? = null
+
+    private lateinit var pickImage: PickImage
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
+
+        binding = ActivityConversationInfoEditBinding.inflate(layoutInflater)
+        setupActionBar()
+        setContentView(binding.root)
+        setupSystemColors()
+
+        val extras: Bundle? = intent.extras
+
+        conversationUser = extras?.getParcelable(BundleKeys.KEY_USER_ENTITY)!!
+        conversationToken = extras.getString(BundleKeys.KEY_ROOM_TOKEN)!!
+
+        if (conversation == null && intent.hasExtra(BundleKeys.KEY_ACTIVE_CONVERSATION)) {
+            conversation = Parcels.unwrap<Conversation>(extras.getParcelable(BundleKeys.KEY_ACTIVE_CONVERSATION))
+        }
+
+        viewThemeUtils.material.colorTextInputLayout(binding.conversationNameInputLayout)
+        viewThemeUtils.material.colorTextInputLayout(binding.conversationDescriptionInputLayout)
+
+        credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token)
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        loadConversationAvatar()
+
+        binding.conversationName.setText(conversation!!.displayName)
+
+        if (conversation!!.description != null && conversation!!.description!!.isNotEmpty()) {
+            binding.conversationDescription.setText(conversation!!.description)
+        }
+
+        if (!CapabilitiesUtilNew.isConversationDescriptionEndpointAvailable(conversationUser)) {
+            binding.conversationDescription.isEnabled = false
+        }
+
+        setupAvatarOptions()
+    }
+
+    private fun setupAvatarOptions() {
+        pickImage = PickImage(this, conversationUser)
+        binding.avatarUpload.setOnClickListener { pickImage.selectLocal() }
+        binding.avatarChoose.setOnClickListener { pickImage.selectRemote() }
+        binding.avatarCamera.setOnClickListener { pickImage.takePicture() }
+        binding.avatarDelete.setOnClickListener { deleteAvatar() }
+        binding.avatarImage.let { ViewCompat.setTransitionName(it, "userAvatar.transitionTag") }
+
+        binding.let {
+            viewThemeUtils.material.themeFAB(it.avatarUpload)
+            viewThemeUtils.material.themeFAB(it.avatarChoose)
+            viewThemeUtils.material.themeFAB(it.avatarCamera)
+            viewThemeUtils.material.themeFAB(it.avatarDelete)
+        }
+    }
+
+    private fun setupActionBar() {
+        setSupportActionBar(binding.conversationInfoEditToolbar)
+        binding.conversationInfoEditToolbar.setNavigationOnClickListener {
+            onBackPressed()
+        }
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+        supportActionBar?.setDisplayShowHomeEnabled(true)
+        supportActionBar?.setIcon(ColorDrawable(resources!!.getColor(android.R.color.transparent)))
+        supportActionBar?.title = resources!!.getString(R.string.nc_conversation_menu_conversation_info)
+
+        viewThemeUtils.material.themeToolbar(binding.conversationInfoEditToolbar)
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        super.onCreateOptionsMenu(menu)
+        menuInflater.inflate(R.menu.menu_conversation_info_edit, menu)
+        return true
+    }
+
+    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+        super.onPrepareOptionsMenu(menu)
+        return true
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        if (item.itemId == R.id.save) {
+            saveConversationNameAndDescription()
+        }
+        return true
+    }
+
+    private fun saveConversationNameAndDescription() {
+        val apiVersion =
+            ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, ApiUtils.APIv1))
+
+        ncApi.renameRoom(
+            credentials,
+            ApiUtils.getUrlForRoom(
+                apiVersion,
+                conversationUser.baseUrl,
+                conversation!!.token
+            ),
+            binding.conversationName.text.toString()
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .retry(1)
+            .subscribe(object : Observer<GenericOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(genericOverall: GenericOverall) {
+                    if (CapabilitiesUtilNew.isConversationDescriptionEndpointAvailable(conversationUser)) {
+                        saveConversationDescription()
+                    } else {
+                        finish()
+                    }
+                }
+
+                override fun onError(e: Throwable) {
+                    Toast.makeText(
+                        applicationContext,
+                        context.getString(R.string.default_error_msg),
+                        Toast.LENGTH_LONG
+                    ).show()
+                    Log.e(TAG, "Error while saving conversation name", e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    fun saveConversationDescription() {
+        val apiVersion =
+            ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, ApiUtils.APIv1))
+
+        ncApi.setConversationDescription(
+            credentials,
+            ApiUtils.getUrlForConversationDescription(
+                apiVersion,
+                conversationUser.baseUrl,
+                conversation!!.token
+            ),
+            binding.conversationDescription.text.toString()
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .retry(1)
+            .subscribe(object : Observer<GenericOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(genericOverall: GenericOverall) {
+                    finish()
+                }
+
+                override fun onError(e: Throwable) {
+                    Toast.makeText(
+                        applicationContext,
+                        context.getString(R.string.default_error_msg),
+                        Toast.LENGTH_LONG
+                    ).show()
+                    Log.e(TAG, "Error while saving conversation description", e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        super.onActivityResult(requestCode, resultCode, data)
+        when (resultCode) {
+            Activity.RESULT_OK -> {
+                pickImage.handleActivityResult(
+                    requestCode,
+                    resultCode,
+                    data
+                ) { uploadAvatar(it.toFile()) }
+            }
+
+            ImagePicker.RESULT_ERROR -> {
+                Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
+            }
+
+            else -> {
+                Log.i(TAG, "Task Cancelled")
+            }
+        }
+    }
+
+    private fun uploadAvatar(file: File?) {
+        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())
+        )
+
+        // upload file
+        ncApi.uploadConversationAvatar(
+            credentials,
+            ApiUtils.getUrlForConversationAvatar(1, conversationUser.baseUrl, conversation!!.token),
+            filePart
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe(object : Observer<RoomOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(roomOverall: RoomOverall) {
+                    conversation = roomOverall.ocs!!.data
+                    loadConversationAvatar()
+                }
+
+                override fun onError(e: Throwable) {
+                    Toast.makeText(
+                        applicationContext,
+                        context.getString(R.string.default_error_msg),
+                        Toast.LENGTH_LONG
+                    ).show()
+                    Log.e(TAG, "Error uploading avatar", e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    private fun deleteAvatar() {
+        ncApi.deleteConversationAvatar(
+            credentials,
+            ApiUtils.getUrlForConversationAvatar(1, conversationUser.baseUrl, conversationToken)
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe(object : Observer<RoomOverall> {
+                override fun onSubscribe(d: Disposable) {
+                    // unused atm
+                }
+
+                override fun onNext(roomOverall: RoomOverall) {
+                    conversation = roomOverall.ocs!!.data
+                    loadConversationAvatar()
+                }
+
+                override fun onError(e: Throwable) {
+                    Toast.makeText(
+                        applicationContext,
+                        context.getString(R.string.default_error_msg),
+                        Toast.LENGTH_LONG
+                    ).show()
+                    Log.e(TAG, "Failed to delete avatar", e)
+                }
+
+                override fun onComplete() {
+                    // unused atm
+                }
+            })
+    }
+
+    private fun loadConversationAvatar() {
+        when (conversation!!.type) {
+            Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(conversation!!.name)) {
+                conversation!!.name?.let { binding.avatarImage.loadUserAvatar(conversationUser, it, true, false) }
+            }
+
+            Conversation.ConversationType.ROOM_GROUP_CALL, Conversation.ConversationType.ROOM_PUBLIC_CALL -> {
+                binding.avatarImage.loadConversationAvatar(conversationUser, conversation!!, false, viewThemeUtils)
+            }
+
+            Conversation.ConversationType.ROOM_SYSTEM -> {
+                binding.avatarImage.loadSystemAvatar()
+            }
+
+            else -> {
+                // unused atm
+            }
+        }
+    }
+
+    companion object {
+        private val TAG = ConversationInfoEditActivity::class.simpleName
+    }
+}

+ 74 - 25
app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt

@@ -2,7 +2,9 @@
  * Nextcloud Talk application
  *
  * @author Tim Krüger
+ * @author Marcel Hibbe
  * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+ * Copyright (C) 2023 Marcel Hibbe (dev@mhibbe.de)
  * Copyright (C) 2022 Nextcloud GmbH
  *
  * This program is free software: you can redistribute it and/or modify
@@ -33,6 +35,7 @@ import androidx.core.content.res.ResourcesCompat
 import coil.annotation.ExperimentalCoilApi
 import coil.imageLoader
 import coil.load
+import coil.request.CachePolicy
 import coil.request.ImageRequest
 import coil.request.SuccessResult
 import coil.result
@@ -41,50 +44,87 @@ import coil.transform.RoundedCornersTransformation
 import com.amulyakhare.textdrawable.TextDrawable
 import com.nextcloud.talk.R
 import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.conversations.Conversation
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.DisplayUtils
 
 private const val ROUNDING_PIXEL = 16f
 private const val TAG = "ImageViewExtensions"
 
-fun ImageView.loadAvatar(
+fun ImageView.loadConversationAvatar(
     user: User,
-    avatar: String,
-    requestBigSize: Boolean = true
+    conversation: Conversation,
+    ignoreCache: Boolean,
+    viewThemeUtils: ViewThemeUtils?
 ): io.reactivex.disposables
 .Disposable {
-    val imageRequestUri = ApiUtils.getUrlForAvatar(
+    val imageRequestUri = ApiUtils.getUrlForConversationAvatarWithVersion(
+        1,
         user.baseUrl,
-        avatar,
-        requestBigSize
+        conversation.token,
+        conversation.avatarVersion
     )
 
-    return loadAvatarInternal(user, imageRequestUri, false)
+    if (conversation.avatarVersion.isNullOrEmpty() && viewThemeUtils != null) {
+        when (conversation.type) {
+            Conversation.ConversationType.ROOM_GROUP_CALL ->
+                return loadDefaultGroupCallAvatar(viewThemeUtils)
+
+            Conversation.ConversationType.ROOM_PUBLIC_CALL ->
+                return loadDefaultPublicCallAvatar(viewThemeUtils)
+
+            else -> {}
+        }
+    }
+
+    // these placeholders are only used when the request fails completely. The server also return default avatars
+    // when no own images are set. (although these default avatars can not be themed for the android app..)
+    val errorPlaceholder =
+        when (conversation.type) {
+            Conversation.ConversationType.ROOM_GROUP_CALL ->
+                ContextCompat.getDrawable(context, R.drawable.ic_circular_group)
+
+            Conversation.ConversationType.ROOM_PUBLIC_CALL ->
+                ContextCompat.getDrawable(context, R.drawable.ic_circular_link)
+
+            else -> ContextCompat.getDrawable(context, R.drawable.account_circle_96dp)
+        }
+
+    return loadAvatarInternal(user, imageRequestUri, ignoreCache, errorPlaceholder)
 }
 
-fun ImageView.replaceAvatar(
+fun ImageView.loadUserAvatar(
     user: User,
-    avatar: String,
-    requestBigSize: Boolean = true
+    avatarId: String,
+    requestBigSize: Boolean = true,
+    ignoreCache: Boolean
 ): io.reactivex.disposables
 .Disposable {
     val imageRequestUri = ApiUtils.getUrlForAvatar(
         user.baseUrl,
-        avatar,
+        avatarId,
         requestBigSize
     )
 
-    return loadAvatarInternal(user, imageRequestUri, true)
+    return loadAvatarInternal(user, imageRequestUri, ignoreCache, null)
 }
 
 @OptIn(ExperimentalCoilApi::class)
 private fun ImageView.loadAvatarInternal(
     user: User?,
     url: String,
-    replace: Boolean
+    ignoreCache: Boolean,
+    errorPlaceholder: Drawable?
 ): io.reactivex.disposables
 .Disposable {
-    if (replace && this.result is SuccessResult) {
+    val cachePolicy = if (ignoreCache) {
+        CachePolicy.WRITE_ONLY
+    } else {
+        CachePolicy.ENABLED
+    }
+
+    if (ignoreCache && this.result is SuccessResult) {
         val result = this.result as SuccessResult
         val memoryCacheKey = result.memoryCacheKey
         val memoryCache = context.imageLoader.memoryCache
@@ -95,8 +135,14 @@ private fun ImageView.loadAvatarInternal(
         diskCacheKey?.let { diskCache?.remove(it) }
     }
 
+    val finalUrl = if (DisplayUtils.isDarkModeOn(this.context)) {
+        "$url/dark"
+    } else {
+        url
+    }
+
     return DisposableWrapper(
-        load(url) {
+        load(finalUrl) {
             user?.let {
                 addHeader(
                     "Authorization",
@@ -104,17 +150,20 @@ private fun ImageView.loadAvatarInternal(
                 )
             }
             transformations(CircleCropTransformation())
-            placeholder(R.drawable.account_circle_96dp)
+            error(errorPlaceholder ?: ContextCompat.getDrawable(context, R.drawable.account_circle_96dp))
+            fallback(errorPlaceholder ?: ContextCompat.getDrawable(context, R.drawable.account_circle_96dp))
             listener(onError = { _, result ->
                 Log.w(TAG, "Can't load avatar with URL: $url", result.throwable)
             })
+            memoryCachePolicy(cachePolicy)
+            diskCachePolicy(cachePolicy)
         }
     )
 }
 
 @Deprecated("Use function loadAvatar", level = DeprecationLevel.WARNING)
 fun ImageView.loadAvatarWithUrl(user: User? = null, url: String): io.reactivex.disposables.Disposable {
-    return loadAvatarInternal(user, url, false)
+    return loadAvatarInternal(user, url, false, null)
 }
 
 fun ImageView.loadThumbnail(url: String, user: User): io.reactivex.disposables.Disposable {
@@ -174,13 +223,13 @@ fun ImageView.loadImage(url: String, user: User, placeholder: Drawable? = null):
 fun ImageView.loadAvatarOrImagePreview(url: String, user: User, placeholder: Drawable? = null): io.reactivex
 .disposables.Disposable {
     return if (url.contains("/avatar/")) {
-        loadAvatarInternal(user, url, false)
+        loadAvatarInternal(user, url, false, null)
     } else {
         loadImage(url, user, placeholder)
     }
 }
 
-fun ImageView.loadAvatar(any: Any?): io.reactivex.disposables.Disposable {
+fun ImageView.loadUserAvatar(any: Any?): io.reactivex.disposables.Disposable {
     return DisposableWrapper(
         load(any) {
             transformations(CircleCropTransformation())
@@ -211,7 +260,7 @@ fun ImageView.loadChangelogBotAvatar(): io.reactivex.disposables.Disposable {
 }
 
 fun ImageView.loadBotsAvatar(): io.reactivex.disposables.Disposable {
-    return loadAvatar(
+    return loadUserAvatar(
         TextDrawable.builder()
             .beginConfig()
             .bold()
@@ -223,22 +272,22 @@ fun ImageView.loadBotsAvatar(): io.reactivex.disposables.Disposable {
     )
 }
 
-fun ImageView.loadGroupCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable {
+fun ImageView.loadDefaultGroupCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable {
     val data: Any = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
         viewThemeUtils.talk.themePlaceholderAvatar(this, R.drawable.ic_avatar_group) as Any
     } else {
         R.drawable.ic_circular_group
     }
-    return loadAvatar(data)
+    return loadUserAvatar(data)
 }
 
-fun ImageView.loadPublicCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable {
+fun ImageView.loadDefaultPublicCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable {
     val data: Any = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
         viewThemeUtils.talk.themePlaceholderAvatar(this, R.drawable.ic_avatar_link) as Any
     } else {
         R.drawable.ic_circular_link
     }
-    return loadAvatar(data)
+    return loadUserAvatar(data)
 }
 
 fun ImageView.loadMailAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable {
@@ -247,7 +296,7 @@ fun ImageView.loadMailAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.dispo
     } else {
         R.drawable.ic_circular_mail
     }
-    return loadAvatar(data)
+    return loadUserAvatar(data)
 }
 
 fun ImageView.loadGuestAvatar(user: User, name: String, big: Boolean): io.reactivex.disposables.Disposable {

+ 3 - 1
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt

@@ -536,7 +536,9 @@ data class ChatMessage(
         AUDIO_RECORDING_STOPPED,
         RECORDING_FAILED,
         BREAKOUT_ROOMS_STARTED,
-        BREAKOUT_ROOMS_STOPPED
+        BREAKOUT_ROOMS_STOPPED,
+        AVATAR_SET,
+        AVATAR_REMOVED
     }
 
     companion object {

+ 4 - 1
app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt

@@ -143,7 +143,10 @@ data class Conversation(
     var statusClearAt: Long? = 0,
 
     @JsonField(name = ["callRecording"])
-    var callRecording: Int = 0
+    var callRecording: Int = 0,
+
+    @JsonField(name = ["avatarVersion"])
+    var avatarVersion: String? = null
 
 ) : Parcelable {
     // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'

+ 6 - 0
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt

@@ -28,6 +28,8 @@ import com.bluelinelabs.logansquare.typeconverters.StringBasedTypeConverter
 import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STARTED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STOPPED
+import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AVATAR_REMOVED
+import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AVATAR_SET
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STARTED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STOPPED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_ENDED
@@ -147,6 +149,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
             "recording_failed" -> RECORDING_FAILED
             "breakout_rooms_started" -> BREAKOUT_ROOMS_STARTED
             "breakout_rooms_stopped" -> BREAKOUT_ROOMS_STOPPED
+            "avatar_set" -> AVATAR_SET
+            "avatar_removed" -> AVATAR_REMOVED
             else -> DUMMY
         }
     }
@@ -211,6 +215,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
             RECORDING_FAILED -> "recording_failed"
             BREAKOUT_ROOMS_STARTED -> "breakout_rooms_started"
             BREAKOUT_ROOMS_STOPPED -> "breakout_rooms_stopped"
+            AVATAR_SET -> "avatar_set"
+            AVATAR_REMOVED -> "avatar_removed"
             else -> ""
         }
     }

+ 2 - 2
app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt

@@ -27,8 +27,8 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.PollResultVoterItemBinding
-import com.nextcloud.talk.extensions.loadAvatar
 import com.nextcloud.talk.extensions.loadGuestAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.polls.model.PollDetails
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 
@@ -57,7 +57,7 @@ class PollResultVoterViewHolder(
             }
             avatar.loadGuestAvatar(user, displayName!!, false)
         } else if (pollDetail.actorType == "users") {
-            avatar.loadAvatar(user, pollDetail.actorId!!, false)
+            avatar.loadUserAvatar(user, pollDetail.actorId!!, false, false)
         }
     }
 }

+ 2 - 2
app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt

@@ -31,8 +31,8 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.PollResultVotersOverviewItemBinding
-import com.nextcloud.talk.extensions.loadAvatar
 import com.nextcloud.talk.extensions.loadGuestAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.polls.model.PollDetails
 
 class PollResultVotersOverviewViewHolder(
@@ -88,7 +88,7 @@ class PollResultVotersOverviewViewHolder(
             }
             avatar.loadGuestAvatar(user, displayName!!, false)
         } else if (pollDetail.actorType == "users") {
-            avatar.loadAvatar(user, pollDetail.actorId!!, false)
+            avatar.loadUserAvatar(user, pollDetail.actorId!!, false, false)
         }
     }
 

+ 31 - 144
app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt

@@ -3,6 +3,8 @@
  *
  * @author Tobias Kaminsky
  * @author Andy Scherzinger
+ * @author Tim Krüger
+ * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
  * Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
  * Copyright (C) 2021 Tobias Kaminsky <tobias.kaminsky@nextcloud.com>
  *
@@ -24,8 +26,6 @@ package com.nextcloud.talk.profile
 import android.app.Activity
 import android.content.Intent
 import android.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
 import android.graphics.drawable.ColorDrawable
 import android.net.Uri
 import android.os.Bundle
@@ -40,17 +40,15 @@ import android.view.View
 import android.view.ViewGroup
 import android.widget.Toast
 import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
 import androidx.core.net.toFile
 import androidx.core.view.ViewCompat
 import androidx.recyclerview.widget.RecyclerView
 import autodagger.AutoInjector
 import com.github.dhaval2404.imagepicker.ImagePicker
 import com.github.dhaval2404.imagepicker.ImagePicker.Companion.getError
-import com.github.dhaval2404.imagepicker.ImagePicker.Companion.with
-import com.github.dhaval2404.imagepicker.constant.ImageProvider
 import com.nextcloud.talk.R
 import com.nextcloud.talk.activities.BaseActivity
-import com.nextcloud.talk.activities.TakePhotoActivity
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.data.user.model.User
@@ -61,19 +59,16 @@ import com.nextcloud.talk.models.json.userprofile.Scope
 import com.nextcloud.talk.models.json.userprofile.UserProfileData
 import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall
 import com.nextcloud.talk.models.json.userprofile.UserProfileOverall
-import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
 import com.nextcloud.talk.ui.dialog.ScopeDialog
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.users.UserManager
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DisplayUtils
-import com.nextcloud.talk.utils.FileUtils
 import com.nextcloud.talk.utils.Mimetype.IMAGE_JPG
-import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX
 import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MIME_TYPE_FILTER
+import com.nextcloud.talk.utils.PickImage
+import com.nextcloud.talk.utils.PickImage.Companion.REQUEST_PERMISSION_CAMERA
 import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew
-import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
 import io.reactivex.Observer
 import io.reactivex.android.schedulers.AndroidSchedulers
 import io.reactivex.disposables.Disposable
@@ -81,13 +76,7 @@ import io.reactivex.schedulers.Schedulers
 import okhttp3.MediaType.Companion.toMediaTypeOrNull
 import okhttp3.MultipartBody
 import okhttp3.RequestBody.Companion.asRequestBody
-import okhttp3.ResponseBody
-import retrofit2.Call
-import retrofit2.Callback
-import retrofit2.Response
 import java.io.File
-import java.io.FileOutputStream
-import java.io.IOException
 import java.util.LinkedList
 import javax.inject.Inject
 
@@ -102,15 +91,14 @@ class ProfileActivity : BaseActivity() {
     @Inject
     lateinit var userManager: UserManager
 
-    @Inject
-    lateinit var permissionUtil: PlatformPermissionUtil
-
     private var currentUser: User? = null
     private var edit = false
     private var adapter: UserInfoAdapter? = null
     private var userInfo: UserProfileData? = null
     private var editableFields = ArrayList<String>()
 
+    private lateinit var pickImage: PickImage
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
@@ -128,9 +116,11 @@ class ProfileActivity : BaseActivity() {
         binding.userinfoList.setItemViewCacheSize(DEFAULT_CACHE_SIZE)
         currentUser = userManager.currentUser.blockingGet()
         val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token)
-        binding.avatarUpload.setOnClickListener { sendSelectLocalFileIntent() }
-        binding.avatarChoose.setOnClickListener { showBrowserScreen() }
-        binding.avatarCamera.setOnClickListener { checkPermissionAndTakePicture() }
+
+        pickImage = PickImage(this, currentUser)
+        binding.avatarUpload.setOnClickListener { pickImage.selectLocal() }
+        binding.avatarChoose.setOnClickListener { pickImage.selectRemote() }
+        binding.avatarCamera.setOnClickListener { pickImage.takePicture() }
         binding.avatarDelete.setOnClickListener {
             ncApi.deleteAvatar(
                 credentials,
@@ -214,8 +204,10 @@ class ProfileActivity : BaseActivity() {
         menu.findItem(R.id.edit).isVisible = editableFields.size > 0
         if (edit) {
             menu.findItem(R.id.edit).setTitle(R.string.save)
+            menu.findItem(R.id.edit).icon = ContextCompat.getDrawable(this, R.drawable.ic_check)
         } else {
             menu.findItem(R.id.edit).setTitle(R.string.edit)
+            menu.findItem(R.id.edit).icon = ContextCompat.getDrawable(this, R.drawable.ic_edit)
         }
         return true
     }
@@ -228,6 +220,7 @@ class ProfileActivity : BaseActivity() {
             edit = !edit
             if (edit) {
                 item.setTitle(R.string.save)
+                item.icon = ContextCompat.getDrawable(this, R.drawable.ic_check)
                 binding.emptyList.root.visibility = View.GONE
                 binding.userinfoList.visibility = View.VISIBLE
                 if (CapabilitiesUtilNew.isAvatarEndpointAvailable(currentUser!!)) {
@@ -261,6 +254,8 @@ class ProfileActivity : BaseActivity() {
                     })
             } else {
                 item.setTitle(R.string.edit)
+                item.icon = ContextCompat.getDrawable(this, R.drawable.ic_edit)
+
                 binding.avatarButtons.visibility = View.INVISIBLE
                 if (adapter!!.filteredDisplayList.isEmpty()) {
                     binding.emptyList.root.visibility = View.VISIBLE
@@ -487,61 +482,11 @@ class ProfileActivity : BaseActivity() {
         }
     }
 
-    private fun sendSelectLocalFileIntent() {
-        with(this)
-            .provider(ImageProvider.GALLERY)
-            .crop()
-            .cropSquare()
-            .compress(MAX_SIZE)
-            .maxResultSize(MAX_SIZE, MAX_SIZE)
-            .createIntent { intent -> startActivityForResult(intent, REQUEST_CODE_IMAGE_PICKER) }
-    }
-
-    private fun showBrowserScreen() {
-        val bundle = Bundle()
-        bundle.putString(KEY_MIME_TYPE_FILTER, IMAGE_PREFIX)
-
-        val avatarIntent = Intent(this, RemoteFileBrowserActivity::class.java)
-        avatarIntent.putExtras(bundle)
-
-        startActivityForResult(avatarIntent, REQUEST_CODE_SELECT_REMOTE_FILES)
-    }
-
-    private fun checkPermissionAndTakePicture() {
-        if (permissionUtil.isCameraPermissionGranted()) {
-            takePictureForAvatar()
-        } else {
-            requestPermissions(arrayOf(android.Manifest.permission.CAMERA), REQUEST_PERMISSION_CAMERA)
-        }
-    }
-
-    private fun takePictureForAvatar() {
-        startActivityForResult(TakePhotoActivity.createIntent(context), REQUEST_CODE_TAKE_PICTURE)
-    }
-
-    private fun handleAvatar(remotePath: String?) {
-        val uri = currentUser!!.baseUrl + "/index.php/apps/files/api/v1/thumbnail/512/512/" +
-            Uri.encode(remotePath, "/")
-        val downloadCall = ncApi.downloadResizedImage(
-            ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token),
-            uri
-        )
-        downloadCall.enqueue(object : Callback<ResponseBody> {
-            override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
-                saveBitmapAndPassToImagePicker(BitmapFactory.decodeStream(response.body()!!.byteStream()))
-            }
-
-            override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
-                // unused atm
-            }
-        })
-    }
-
     override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         if (requestCode == REQUEST_PERMISSION_CAMERA) {
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-                takePictureForAvatar()
+                pickImage.takePicture()
             } else {
                 Toast
                     .makeText(context, context.getString(R.string.take_photo_permission), Toast.LENGTH_LONG)
@@ -550,73 +495,22 @@ class ProfileActivity : BaseActivity() {
         }
     }
 
-    // only possible with API26
-    private fun saveBitmapAndPassToImagePicker(bitmap: Bitmap) {
-        val file: File = saveBitmapToTempFile(bitmap) ?: return
-        openImageWithPicker(file)
-    }
-
-    private fun saveBitmapToTempFile(bitmap: Bitmap): File? {
-        try {
-            val file = createTempFileForAvatar()
-            try {
-                FileOutputStream(file).use { out ->
-                    bitmap.compress(Bitmap.CompressFormat.PNG, FULL_QUALITY, out)
-                }
-                return file
-            } catch (e: IOException) {
-                Log.e(TAG, "Error compressing bitmap", e)
-            }
-        } catch (e: IOException) {
-            Log.e(TAG, "Error creating temporary avatar image", e)
-        }
-        return null
-    }
-
-    private fun createTempFileForAvatar(): File {
-        FileUtils.removeTempCacheFile(
-            this.context,
-            AVATAR_PATH
-        )
-        return FileUtils.getTempCacheFile(
-            context,
-            AVATAR_PATH
-        )
-    }
-
-    private fun openImageWithPicker(file: File) {
-        with(this)
-            .provider(ImageProvider.URI)
-            .crop()
-            .cropSquare()
-            .compress(MAX_SIZE)
-            .maxResultSize(MAX_SIZE, MAX_SIZE)
-            .setUri(Uri.fromFile(file))
-            .createIntent { intent -> startActivityForResult(intent, REQUEST_CODE_IMAGE_PICKER) }
-    }
-
     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
         super.onActivityResult(requestCode, resultCode, data)
-        if (resultCode == Activity.RESULT_OK) {
-            if (requestCode == REQUEST_CODE_IMAGE_PICKER) {
-                val uri: Uri = data?.data!!
-                uploadAvatar(uri.toFile())
-            } else if (requestCode == REQUEST_CODE_SELECT_REMOTE_FILES) {
-                val pathList = data?.getStringArrayListExtra(RemoteFileBrowserActivity.EXTRA_SELECTED_PATHS)
-                if (pathList?.size!! >= 1) {
-                    handleAvatar(pathList[0])
-                }
-            } else if (requestCode == REQUEST_CODE_TAKE_PICTURE) {
-                data?.data?.path?.let {
-                    openImageWithPicker(File(it))
-                }
-            } else {
-                Log.w(TAG, "Unknown intent request code")
+        when (resultCode) {
+            Activity.RESULT_OK -> {
+                pickImage.handleActivityResult(
+                    requestCode,
+                    resultCode,
+                    data
+                ) { uploadAvatar(it.toFile()) }
+            }
+            ImagePicker.RESULT_ERROR -> {
+                Toast.makeText(this, getError(data), Toast.LENGTH_SHORT).show()
+            }
+            else -> {
+                Log.i(TAG, "Task Cancelled")
             }
-        } else if (resultCode == ImagePicker.RESULT_ERROR) {
-            Toast.makeText(this, getError(data), Toast.LENGTH_SHORT).show()
-        } else {
-            Log.i(TAG, "Task Cancelled")
         }
     }
 
@@ -865,15 +759,8 @@ class ProfileActivity : BaseActivity() {
 
     companion object {
         private const val TAG: String = "ProfileController"
-        private const val AVATAR_PATH = "photos/avatar.png"
-        private const val REQUEST_CODE_SELECT_REMOTE_FILES = 22
         private const val DEFAULT_CACHE_SIZE: Int = 20
         private const val DEFAULT_RETRIES: Long = 3
-        private const val MAX_SIZE: Int = 1024
-        private const val REQUEST_CODE_IMAGE_PICKER: Int = 1
-        private const val REQUEST_CODE_TAKE_PICTURE: Int = 2
-        private const val REQUEST_PERMISSION_CAMERA: Int = 1
-        private const val FULL_QUALITY: Int = 100
         private const val HIGH_EMPHASIS_ALPHA: Float = 0.87f
         private const val MEDIUM_EMPHASIS_ALPHA: Float = 0.6f
     }

+ 2 - 1
app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountDialogFragment.java

@@ -136,7 +136,8 @@ public class ChooseAccountDialogFragment extends DialogFragment {
             if (user.getBaseUrl() != null &&
                 (user.getBaseUrl().startsWith("http://") || user.getBaseUrl().startsWith("https://"))) {
                 binding.currentAccount.userIcon.setVisibility(View.VISIBLE);
-                ImageViewExtensionsKt.loadAvatar(binding.currentAccount.userIcon, user, user.getUserId(), true);
+                ImageViewExtensionsKt.loadUserAvatar(binding.currentAccount.userIcon, user, user.getUserId(), true,
+                                                     false);
             } else {
                 binding.currentAccount.userIcon.setVisibility(View.INVISIBLE);
             }

+ 2 - 2
app/src/main/java/com/nextcloud/talk/ui/dialog/ChooseAccountShareToDialogFragment.kt

@@ -40,7 +40,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.DialogChooseAccountShareToBinding
-import com.nextcloud.talk.extensions.loadAvatar
+import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.models.json.participants.Participant
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.users.UserManager
@@ -95,7 +95,7 @@ class ChooseAccountShareToDialogFragment : DialogFragment() {
             if (user.baseUrl != null &&
                 (user.baseUrl!!.startsWith("http://") || user.baseUrl!!.startsWith("https://"))
             ) {
-                binding!!.currentAccount.userIcon.loadAvatar(user, user.userId!!)
+                binding!!.currentAccount.userIcon.loadUserAvatar(user, user.userId!!, true, false)
             } else {
                 binding!!.currentAccount.userIcon.visibility = View.INVISIBLE
             }

+ 13 - 0
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java

@@ -375,6 +375,15 @@ public class ApiUtils {
         return baseUrl + "/index.php/avatar/guest/" + Uri.encode(name) + "/" + avatarSize;
     }
 
+    public static String getUrlForConversationAvatar(int version, String baseUrl, String token) {
+        return getUrlForRoom(version, baseUrl, token) + "/avatar";
+    }
+
+    public static String getUrlForConversationAvatarWithVersion(int version, String baseUrl, String token,
+                                                       String avatarVersion) {
+        return getUrlForRoom(version, baseUrl, token) + "/avatar?avatarVersion=" + avatarVersion;
+    }
+
     public static String getCredentials(String username, String token) {
         if (TextUtils.isEmpty(username) && TextUtils.isEmpty(token)) {
             return null;
@@ -502,4 +511,8 @@ public class ApiUtils {
     public static String getUrlForRequestAssistance(int version, String baseUrl, String token) {
         return getUrlForApi(version, baseUrl) + "/breakout-rooms/" + token + "/request-assistance";
     }
+
+    public static String getUrlForConversationDescription(int version, String baseUrl, String token) {
+        return getUrlForRoom(version, baseUrl, token) + "/description";
+    }
 }

+ 6 - 5
app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java

@@ -108,6 +108,11 @@ public class DisplayUtils {
 
     private static final int DATE_TIME_PARTS_SIZE = 2;
 
+    public static Boolean isDarkModeOn(Context context) {
+        int currentNightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
+        return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
+    }
+
     public static void setClickableString(String string, String url, TextView textView) {
         SpannableString spannableString = new SpannableString(string);
         spannableString.setSpan(new ClickableSpan() {
@@ -463,11 +468,7 @@ public class DisplayUtils {
             }
 
             if (avatarId != null) {
-                if (deleteCache) {
-                    ImageViewExtensionsKt.replaceAvatar(avatarImageView, user, avatarId, true);
-                } else {
-                    ImageViewExtensionsKt.loadAvatar(avatarImageView, user, avatarId, true);
-                }
+                ImageViewExtensionsKt.loadUserAvatar(avatarImageView, user, avatarId, true, deleteCache);
             }
         }
     }

+ 203 - 0
app/src/main/java/com/nextcloud/talk/utils/PickImage.kt

@@ -0,0 +1,203 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Tim Krüger
+ * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.utils
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import autodagger.AutoInjector
+import com.github.dhaval2404.imagepicker.ImagePicker
+import com.github.dhaval2404.imagepicker.constant.ImageProvider
+import com.nextcloud.talk.activities.TakePhotoActivity
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
+import okhttp3.ResponseBody
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class PickImage(
+    private val activity: Activity,
+    private var currentUser: User?
+) {
+
+    @Inject
+    lateinit var ncApi: NcApi
+
+    @Inject
+    lateinit var permissionUtil: PlatformPermissionUtil
+
+    init {
+        NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
+    }
+
+    fun selectLocal() {
+        ImagePicker.Companion.with(activity)
+            .provider(ImageProvider.GALLERY)
+            .crop()
+            .cropSquare()
+            .compress(MAX_SIZE)
+            .maxResultSize(MAX_SIZE, MAX_SIZE)
+            .createIntent { intent -> this.activity.startActivityForResult(intent, REQUEST_CODE_IMAGE_PICKER) }
+    }
+
+    private fun selectLocal(file: File) {
+        ImagePicker.Companion.with(activity)
+            .provider(ImageProvider.URI)
+            .crop()
+            .cropSquare()
+            .compress(MAX_SIZE)
+            .maxResultSize(MAX_SIZE, MAX_SIZE)
+            .setUri(Uri.fromFile(file))
+            .createIntent { intent -> this.activity.startActivityForResult(intent, REQUEST_CODE_IMAGE_PICKER) }
+    }
+
+    fun selectRemote() {
+        val bundle = Bundle()
+        bundle.putString(BundleKeys.KEY_MIME_TYPE_FILTER, Mimetype.IMAGE_PREFIX)
+
+        val avatarIntent = Intent(activity, RemoteFileBrowserActivity::class.java)
+        avatarIntent.putExtras(bundle)
+
+        this.activity.startActivityForResult(avatarIntent, REQUEST_CODE_SELECT_REMOTE_FILES)
+    }
+
+    fun takePicture() {
+        if (permissionUtil.isCameraPermissionGranted()) {
+            activity.startActivityForResult(
+                TakePhotoActivity.createIntent(activity),
+                REQUEST_CODE_TAKE_PICTURE
+            )
+        } else {
+            activity.requestPermissions(
+                arrayOf(android.Manifest.permission.CAMERA),
+                REQUEST_PERMISSION_CAMERA
+            )
+        }
+    }
+
+    private fun handleAvatar(remotePath: String?) {
+        val uri = currentUser!!.baseUrl + "/index.php/apps/files/api/v1/thumbnail/512/512/" +
+            Uri.encode(remotePath, "/")
+        val downloadCall = ncApi.downloadResizedImage(
+            ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token),
+            uri
+        )
+        downloadCall.enqueue(object : Callback<ResponseBody> {
+            override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
+                saveBitmapAndPassToImagePicker(BitmapFactory.decodeStream(response.body()!!.byteStream()))
+            }
+
+            override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
+                // unused atm
+            }
+        })
+    }
+
+    // only possible with API26
+    private fun saveBitmapAndPassToImagePicker(bitmap: Bitmap) {
+        val file: File = saveBitmapToTempFile(bitmap) ?: return
+        selectLocal(file)
+    }
+
+    private fun saveBitmapToTempFile(bitmap: Bitmap): File? {
+        try {
+            val file = createTempFileForAvatar()
+            try {
+                FileOutputStream(file).use { out ->
+                    bitmap.compress(Bitmap.CompressFormat.PNG, FULL_QUALITY, out)
+                }
+                return file
+            } catch (e: IOException) {
+                Log.e(TAG, "Error compressing bitmap", e)
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating temporary avatar image", e)
+        }
+        return null
+    }
+
+    private fun createTempFileForAvatar(): File {
+        FileUtils.removeTempCacheFile(
+            activity,
+            AVATAR_PATH
+        )
+        return FileUtils.getTempCacheFile(
+            activity,
+            AVATAR_PATH
+        )
+    }
+
+    fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?, handleImage: (uri: Uri) -> Unit) {
+        if (resultCode != Activity.RESULT_OK) {
+            Log.w(
+                TAG,
+                "Check result code before calling " +
+                    "'PickImage#handleActivtyResult'. It should be ${Activity.RESULT_OK}, but it is $resultCode!"
+            )
+            return
+        }
+
+        when (requestCode) {
+            REQUEST_CODE_IMAGE_PICKER -> {
+                val uri: Uri = data?.data!!
+                handleImage(uri)
+            }
+            REQUEST_CODE_SELECT_REMOTE_FILES -> {
+                val pathList = data?.getStringArrayListExtra(RemoteFileBrowserActivity.EXTRA_SELECTED_PATHS)
+                if (pathList?.size!! >= 1) {
+                    handleAvatar(pathList[0])
+                }
+            }
+            REQUEST_CODE_TAKE_PICTURE -> {
+                data?.data?.path?.let {
+                    selectLocal(File(it))
+                }
+            }
+            else -> {
+                Log.w(TAG, "Unknown intent request code")
+            }
+        }
+    }
+
+    companion object {
+        private const val TAG: String = "PickImage"
+        private const val MAX_SIZE: Int = 1024
+        private const val AVATAR_PATH = "photos/avatar.png"
+        private const val FULL_QUALITY: Int = 100
+        const val REQUEST_CODE_IMAGE_PICKER: Int = 1
+        const val REQUEST_CODE_TAKE_PICTURE: Int = 2
+        const val REQUEST_PERMISSION_CAMERA: Int = 1
+        const val REQUEST_CODE_SELECT_REMOTE_FILES = 22
+    }
+}

+ 8 - 0
app/src/main/java/com/nextcloud/talk/utils/database/user/CapabilitiesUtilNew.kt

@@ -144,6 +144,14 @@ object CapabilitiesUtilNew {
         return user.capabilities?.spreedCapability?.features?.contains("temp-user-avatar-api") == true
     }
 
+    fun isConversationAvatarEndpointAvailable(user: User): Boolean {
+        return user.capabilities?.spreedCapability?.features?.contains("avatar") == true
+    }
+
+    fun isConversationDescriptionEndpointAvailable(user: User): Boolean {
+        return user.capabilities?.spreedCapability?.features?.contains("room-description") == true
+    }
+
     fun canEditScopes(user: User): Boolean {
         return user.capabilities?.provisioningCapability?.accountPropertyScopesVersion != null &&
             user.capabilities!!.provisioningCapability!!.accountPropertyScopesVersion!! > 1

+ 1 - 1
app/src/main/res/drawable/account_circle_96dp.xml

@@ -21,7 +21,7 @@
     android:viewportWidth="24"
     android:viewportHeight="24">
     <path
-        android:fillColor="#61000000"
+        android:fillColor="#DBDBDB"
         android:fillType="nonZero"
         android:pathData="M12,20.64C9,20.64 6.348,19.104 4.8,16.8C4.836,14.4 9.6,13.08 12,13.08C14.4,13.08 19.164,14.4 19.2,16.8C17.652,19.104 15,20.64 12,20.64M12,3.6C13.9752,3.6 15.6,5.2248 15.6,7.2C15.6,9.1752 13.9752,10.8 12,10.8C10.0248,10.8 8.4,9.1752 8.4,7.2C8.4,5.2248 10.0248,3.6 12,3.6M12,0C5.4168,0 0,5.4168 0,12C0,18.5832 5.4168,24 12,24C18.5832,24 24,18.5832 24,12C24,5.364 18.6,0 12,0Z" />
 </vector>

+ 1 - 1
app/src/main/res/drawable/ic_avatar_link.xml

@@ -28,4 +28,4 @@
         android:fillColor="#ffffff"
         android:fillType="nonZero"
         android:pathData="M13,5.921L9.818,9.105C9.111,9.812 8.781,10.723 8.83,11.562C8.88,12.401 9.263,13.146 9.818,13.701L11.23,12.285C10.663,11.717 10.686,11.065 11.232,10.519L14.414,7.337C14.939,6.812 15.664,6.814 16.186,7.335C16.668,7.891 16.713,8.574 16.182,9.105L15.362,9.925C15.917,10.71 16.007,11.291 15.955,12.16L17.596,10.519C18.833,9.282 18.833,7.154 17.596,5.917C16.36,4.681 14.254,4.706 13,5.921ZM13.707,9.806L12.293,11.224L12.297,11.224C12.847,11.774 12.804,12.482 12.293,12.994L9.111,16.175C8.415,16.767 7.813,16.646 7.342,16.175C6.715,15.549 6.842,14.907 7.342,14.407L8.192,13.56C7.636,12.777 7.543,12.195 7.594,11.328L5.928,12.994C4.689,14.233 4.692,16.354 5.928,17.589C7.163,18.825 9.29,18.825 10.526,17.589L13.707,14.407C14.416,13.699 14.747,12.789 14.698,11.949C14.65,11.109 14.266,10.362 13.709,9.808L13.707,9.806Z" />
-</vector>
+</vector>

+ 11 - 9
app/src/main/res/layout/controller_conversation_info.xml → app/src/main/res/layout/activity_conversation_info.xml

@@ -61,7 +61,8 @@
         android:layout_marginRight="@dimen/activity_horizontal_margin"
         android:indeterminate="true"
         android:indeterminateTint="@color/textColorMaxContrast"
-        android:indeterminateTintMode="src_in" />
+        android:indeterminateTintMode="src_in"
+        tools:visibility="gone"/>
 
     <ScrollView
         android:layout_width="match_parent"
@@ -86,6 +87,14 @@
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content">
 
+                    <ImageView
+                        android:id="@+id/avatar_image"
+                        android:layout_width="@dimen/avatar_size_big"
+                        android:layout_height="@dimen/avatar_size_big"
+                        android:layout_centerHorizontal="true"
+                        tools:src="@drawable/account_circle_48dp"
+                        android:contentDescription="@string/avatar" />
+
                     <androidx.emoji2.widget.EmojiTextView
                         android:id="@+id/display_name_text"
                         android:layout_width="wrap_content"
@@ -95,15 +104,8 @@
                         android:layout_marginTop="@dimen/margin_between_elements"
                         tools:text="Jane Doe" />
 
-                    <ImageView
-                        android:id="@+id/avatar_image"
-                        android:layout_width="@dimen/avatar_size_big"
-                        android:layout_height="@dimen/avatar_size_big"
-                        android:layout_centerHorizontal="true"
-                        tools:background="@color/hwSecurityRed"
-                        android:contentDescription="@string/avatar" />
-
                 </RelativeLayout>
+
             </com.yarolegovich.mp.MaterialPreferenceCategory>
 
             <com.yarolegovich.mp.MaterialPreferenceCategory

+ 170 - 0
app/src/main/res/layout/activity_conversation_info_edit.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Marcel Hibbe
+  ~ Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/parent_container"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/conversation_info_edit_appbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/conversation_info_edit_toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="?attr/actionBarSize"
+            android:background="@color/appbar"
+            android:theme="?attr/actionBarPopupTheme"
+            app:layout_scrollFlags="scroll|enterAlways"
+            app:navigationIconTint="@color/fontAppbar"
+            app:popupTheme="@style/appActionBarPopupMenu"
+            app:titleTextColor="@color/fontAppbar"
+            tools:title="@string/nc_app_product_name" />
+    </com.google.android.material.appbar.AppBarLayout>
+
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <ImageView
+            android:id="@+id/avatar_image"
+            android:layout_width="@dimen/avatar_size_big"
+            android:layout_height="@dimen/avatar_size_big"
+            android:layout_marginTop="@dimen/standard_margin"
+            android:layout_gravity="center"
+            android:contentDescription="@string/avatar"
+            tools:src="@drawable/account_circle_48dp" />
+
+        <LinearLayout
+            android:id="@+id/avatar_buttons"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/standard_margin"
+            android:gravity="center"
+            android:orientation="horizontal">
+
+            <com.google.android.material.floatingactionbutton.FloatingActionButton
+                android:id="@+id/avatar_upload"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/standard_quarter_margin"
+                android:layout_marginRight="@dimen/standard_quarter_margin"
+                android:contentDescription="@string/upload_new_avatar_from_device"
+                android:tint="@android:color/white"
+                app:elevation="0dp"
+                app:fabSize="mini"
+                app:srcCompat="@drawable/upload" />
+
+            <com.google.android.material.floatingactionbutton.FloatingActionButton
+                android:id="@+id/avatar_choose"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/standard_quarter_margin"
+                android:layout_marginRight="@dimen/standard_quarter_margin"
+                android:contentDescription="@string/choose_avatar_from_cloud"
+                android:tint="@android:color/white"
+                app:elevation="0dp"
+                app:fabSize="mini"
+                app:srcCompat="@drawable/ic_mimetype_folder" />
+
+            <com.google.android.material.floatingactionbutton.FloatingActionButton
+                android:id="@+id/avatar_camera"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/standard_quarter_margin"
+                android:layout_marginRight="@dimen/standard_quarter_margin"
+                android:contentDescription="@string/set_avatar_from_camera"
+                android:tint="@android:color/white"
+                app:elevation="0dp"
+                app:fabSize="mini"
+                app:srcCompat="@drawable/ic_baseline_photo_camera_24" />
+
+            <com.google.android.material.floatingactionbutton.FloatingActionButton
+                android:id="@+id/avatar_delete"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/standard_quarter_margin"
+                android:layout_marginRight="@dimen/standard_quarter_margin"
+                android:contentDescription="@string/delete_avatar"
+                android:tint="@android:color/white"
+                app:elevation="0dp"
+                app:fabSize="mini"
+                app:srcCompat="@drawable/trashbin" />
+        </LinearLayout>
+
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/conversation_name_input_layout"
+            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/standard_margin"
+            android:minHeight="@dimen/min_size_clickable_area"
+            app:boxStrokeColor="@color/colorPrimary"
+            app:errorTextAppearance="@style/ErrorAppearance"
+            app:hintTextColor="@color/colorPrimary"
+            android:hint="@string/nc_call_name">
+
+            <com.nextcloud.talk.utils.EmojiTextInputEditText
+                android:id="@+id/conversation_name"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:imeOptions="actionNext"
+                android:inputType="text"
+                android:singleLine="true"
+                android:maxLength="255"
+                android:textAlignment="viewStart"
+                tools:text="Our conversation name" />
+
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/conversation_description_input_layout"
+            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/standard_margin"
+            android:minHeight="@dimen/min_size_clickable_area"
+            app:boxStrokeColor="@color/colorPrimary"
+            app:errorTextAppearance="@style/ErrorAppearance"
+            app:hintTextColor="@color/colorPrimary"
+            android:hint="@string/nc_conversation_description">
+
+            <com.nextcloud.talk.utils.EmojiTextInputEditText
+                android:id="@+id/conversation_description"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:imeOptions="actionNext"
+                android:inputType="text|textMultiLine"
+                android:singleLine="true"
+                android:textAlignment="viewStart"
+                tools:text="Our conversation description" />
+
+        </com.google.android.material.textfield.TextInputLayout>
+
+    </LinearLayout>
+
+</LinearLayout>

+ 32 - 0
app/src/main/res/menu/menu_conversation_info.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Nextcloud Android Talk application
+
+  @author Tobias Kaminsky
+  @author Marcel Hibbe
+  Copyright (C) 2021 Tobias Kaminsky
+  Copyright (C) 2023 Marcel Hibbe (dev@mhibbe.de)
+  Copyright (C) 2021 Nextcloud GmbH
+ 
+  This program is free software: you can redistribute it and/or modify
+  it under the terms of the GNU Affero General Public License as published by
+  the Free Software Foundation, either version 3 of the License, or
+  (at your option) any later version.
+ 
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  GNU Affero General Public License for more details.
+ 
+  You should have received a copy of the GNU Affero General Public License
+  along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/edit"
+        android:icon="@drawable/ic_edit"
+        android:title="@string/edit"
+        app:showAsAction="ifRoom"
+        app:iconTint="@color/high_emphasis_text"/>
+</menu>

+ 32 - 0
app/src/main/res/menu/menu_conversation_info_edit.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Nextcloud Android Talk application
+
+  @author Tobias Kaminsky
+  @author Marcel Hibbe
+  Copyright (C) 2021 Tobias Kaminsky
+  Copyright (C) 2023 Marcel Hibbe (dev@mhibbe.de)
+  Copyright (C) 2021 Nextcloud GmbH
+ 
+  This program is free software: you can redistribute it and/or modify
+  it under the terms of the GNU Affero General Public License as published by
+  the Free Software Foundation, either version 3 of the License, or
+  (at your option) any later version.
+ 
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  GNU Affero General Public License for more details.
+ 
+  You should have received a copy of the GNU Affero General Public License
+  along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/save"
+        android:icon="@drawable/ic_check"
+        android:title="@string/save"
+        app:iconTint="@color/high_emphasis_text"
+        app:showAsAction="ifRoom" />
+</menu>

+ 4 - 1
app/src/main/res/menu/menu_profile.xml

@@ -21,8 +21,11 @@
 -->
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
+
     <item
         android:id="@+id/edit"
+        android:icon="@drawable/ic_edit"
         android:title="@string/edit"
-        app:showAsAction="ifRoom" />
+        app:showAsAction="ifRoom"
+        app:iconTint="@color/high_emphasis_text"/>
 </menu>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -637,6 +637,7 @@ How to translate with transifex:
     <string name="set_avatar_from_camera">Set avatar from camera</string>
 
     <string name="nc_conversation_settings">Conversation settings</string>
+    <string name="nc_conversation_description">Conversation description</string>
 
     <!-- Expiring messages -->
     <string name="nc_expire_messages">Expire chat messages</string>