From 4af87e4a7a4bf9a7b9cf427a19458004316bc31a Mon Sep 17 00:00:00 2001 From: rimdoo <> Date: Thu, 23 Feb 2023 14:14:37 +0900 Subject: [PATCH] Added 3.4.0 --- CHANGELOG.md | 13 +- gradle.properties | 2 +- .../uikit/customsample/BaseApplication.java | 2 + .../CustomMessageInputComponent.java | 51 +- .../fragments/CustomChannelFragment.java | 11 +- .../res/layout/view_custom_channel_input.xml | 15 +- .../BaseApplication.java | 4 +- uikit/build.gradle | 3 +- uikit/src/main/AndroidManifest.xml | 1 + .../com/sendbird/uikit/SendbirdUIKit.java | 23 + .../activities/viewholder/MessageType.java | 14 +- .../viewholder/MessageViewHolderFactory.java | 18 +- .../com/sendbird/uikit/consts/StringSet.java | 6 + .../fragments/BaseMessageListFragment.java | 118 ++++- .../uikit/fragments/ChannelFragment.java | 64 ++- .../fragments/MessageThreadFragment.java | 34 +- .../model/OnVoiceFileDownloadListener.kt | 8 + .../uikit/internal/model/VoicePlayer.kt | 286 ++++++++++++ .../internal/model/VoicePlayerManager.kt | 160 +++++++ .../uikit/internal/model/VoiceRecorder.kt | 205 ++++++++ .../internal/queries/BannedUserListQuery.kt | 8 +- .../internal/queries/MutedMemberListQuery.kt | 11 +- .../internal/ui/channels/ChannelPreview.kt | 6 +- .../internal/ui/messages/MessagePreview.kt | 48 +- .../ui/messages/MyQuotedMessageView.kt | 11 +- .../ui/messages/MyVoiceMessageView.kt | 142 ++++++ .../ui/messages/OtherFileMessageView.kt | 2 +- .../ui/messages/OtherImageFileMessageView.kt | 2 +- .../ui/messages/OtherQuotedMessageView.kt | 11 +- .../ui/messages/OtherUserMessageView.kt | 2 +- .../ui/messages/OtherVideoFileMessageView.kt | 2 +- .../ui/messages/OtherVoiceMessageView.kt | 150 ++++++ .../ui/messages/ParentMessageInfoView.kt | 68 ++- .../internal/ui/messages/ThreadInfoView.kt | 2 - .../internal/ui/messages/VoiceMessageView.kt | 178 +++++++ .../viewholders/MyVoiceMessageViewHolder.kt | 48 ++ .../OtherVoiceMessageViewHolder.kt | 49 ++ .../ui/widgets/MessageInputDialogWrapper.kt | 7 +- .../ui/widgets/VoiceMessageInputView.kt | 436 ++++++++++++++++++ .../internal/ui/widgets/VoiceProgressView.kt | 117 +++++ .../com/sendbird/uikit/model/FileInfo.java | 29 +- .../uikit/model/VoiceMessageInfo.java | 54 +++ .../components/MessageInputComponent.java | 25 + .../sendbird/uikit/utils/DrawableUtils.java | 8 +- .../com/sendbird/uikit/utils/FileUtils.java | 25 + .../sendbird/uikit/utils/MessageUtils.java | 60 +++ .../sendbird/uikit/utils/PermissionUtils.java | 5 + .../com/sendbird/uikit/utils/ViewUtils.java | 26 ++ .../uikit/vm/BaseMessageListViewModel.java | 1 - .../sendbird/uikit/vm/ChannelViewModel.java | 4 +- .../com/sendbird/uikit/vm/FileDownloader.java | 33 +- .../uikit/vm/MessageThreadViewModel.java | 30 +- .../uikit/vm/PendingMessageRepository.java | 19 + .../uikit/widgets/MessageInputView.kt | 68 ++- .../sb_selector_input_voice_color_dark.xml | 13 + .../sb_selector_input_voice_color_light.xml | 13 + ...e_recorder_oval_button_background_dark.xml | 5 + ..._recorder_oval_button_background_light.xml | 5 + ...sage_recorder_progress_background_dark.xml | 8 + ...age_recorder_progress_background_light.xml | 8 + ..._message_recorder_send_background_dark.xml | 6 + ...message_recorder_send_background_light.xml | 6 + ..._voice_message_recorder_send_icon_dark.xml | 8 + ...voice_message_recorder_send_icon_light.xml | 8 + ...b_voice_message_recorder_timeline_dark.xml | 8 + ..._voice_message_recorder_timeline_light.xml | 8 + .../src/main/res/drawable-hdpi/icon_pause.png | Bin 0 -> 529 bytes .../main/res/drawable-hdpi/icon_recording.png | Bin 0 -> 1075 bytes .../src/main/res/drawable-hdpi/icon_stop.png | Bin 0 -> 404 bytes .../drawable-hdpi/icon_voice_message_on.png | Bin 0 -> 1214 bytes .../src/main/res/drawable-mdpi/icon_pause.png | Bin 0 -> 368 bytes .../main/res/drawable-mdpi/icon_recording.png | Bin 0 -> 700 bytes .../src/main/res/drawable-mdpi/icon_stop.png | Bin 0 -> 280 bytes .../drawable-mdpi/icon_voice_message_on.png | Bin 0 -> 879 bytes .../main/res/drawable-xhdpi/icon_pause.png | Bin 0 -> 696 bytes .../res/drawable-xhdpi/icon_recording.png | Bin 0 -> 1497 bytes .../src/main/res/drawable-xhdpi/icon_stop.png | Bin 0 -> 539 bytes .../drawable-xhdpi/icon_voice_message_on.png | Bin 0 -> 1706 bytes .../main/res/drawable-xxhdpi/icon_pause.png | Bin 0 -> 1048 bytes .../res/drawable-xxhdpi/icon_recording.png | Bin 0 -> 2438 bytes .../main/res/drawable-xxhdpi/icon_stop.png | Bin 0 -> 823 bytes .../drawable-xxhdpi/icon_voice_message_on.png | Bin 0 -> 2430 bytes .../main/res/drawable-xxxhdpi/icon_pause.png | Bin 0 -> 1475 bytes .../res/drawable-xxxhdpi/icon_recording.png | Bin 0 -> 3345 bytes .../main/res/drawable-xxxhdpi/icon_stop.png | Bin 0 -> 1156 bytes .../icon_voice_message_on.png | Bin 0 -> 3507 bytes .../res/drawable/sb_shape_oval_button.xml | 3 + .../main/res/layout/sb_view_message_input.xml | 14 + .../res/layout/sb_view_my_voice_message.xml | 6 + .../sb_view_my_voice_message_component.xml | 145 ++++++ .../layout/sb_view_other_voice_message.xml | 6 + .../sb_view_other_voice_message_component.xml | 151 ++++++ .../layout/sb_view_parent_message_info.xml | 15 +- .../main/res/layout/sb_view_voice_message.xml | 59 +++ .../layout/sb_view_voice_message_input.xml | 114 +++++ uikit/src/main/res/values/attrs.xml | 46 ++ uikit/src/main/res/values/dimens.xml | 1 + uikit/src/main/res/values/strings.xml | 5 + uikit/src/main/res/values/styles.xml | 65 ++- uikit/src/main/res/values/styles_dark.xml | 65 +++ .../com/sendbird/uikit/ExampleUnitTest.java | 17 - 101 files changed, 3377 insertions(+), 156 deletions(-) create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/OnVoiceFileDownloadListener.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayerManager.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/VoiceRecorder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyVoiceMessageView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVoiceMessageView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/MyVoiceMessageViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/OtherVoiceMessageViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceMessageInputView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceProgressView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/model/VoiceMessageInfo.java create mode 100644 uikit/src/main/res/color/sb_selector_input_voice_color_dark.xml create mode 100644 uikit/src/main/res/color/sb_selector_input_voice_color_light.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_dark.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_light.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_progress_background_dark.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_progress_background_light.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_send_background_dark.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_send_background_light.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_send_icon_dark.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_send_icon_light.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_timeline_dark.xml create mode 100644 uikit/src/main/res/color/sb_voice_message_recorder_timeline_light.xml create mode 100644 uikit/src/main/res/drawable-hdpi/icon_pause.png create mode 100644 uikit/src/main/res/drawable-hdpi/icon_recording.png create mode 100644 uikit/src/main/res/drawable-hdpi/icon_stop.png create mode 100644 uikit/src/main/res/drawable-hdpi/icon_voice_message_on.png create mode 100644 uikit/src/main/res/drawable-mdpi/icon_pause.png create mode 100644 uikit/src/main/res/drawable-mdpi/icon_recording.png create mode 100644 uikit/src/main/res/drawable-mdpi/icon_stop.png create mode 100644 uikit/src/main/res/drawable-mdpi/icon_voice_message_on.png create mode 100644 uikit/src/main/res/drawable-xhdpi/icon_pause.png create mode 100644 uikit/src/main/res/drawable-xhdpi/icon_recording.png create mode 100644 uikit/src/main/res/drawable-xhdpi/icon_stop.png create mode 100644 uikit/src/main/res/drawable-xhdpi/icon_voice_message_on.png create mode 100644 uikit/src/main/res/drawable-xxhdpi/icon_pause.png create mode 100644 uikit/src/main/res/drawable-xxhdpi/icon_recording.png create mode 100644 uikit/src/main/res/drawable-xxhdpi/icon_stop.png create mode 100644 uikit/src/main/res/drawable-xxhdpi/icon_voice_message_on.png create mode 100644 uikit/src/main/res/drawable-xxxhdpi/icon_pause.png create mode 100644 uikit/src/main/res/drawable-xxxhdpi/icon_recording.png create mode 100644 uikit/src/main/res/drawable-xxxhdpi/icon_stop.png create mode 100644 uikit/src/main/res/drawable-xxxhdpi/icon_voice_message_on.png create mode 100644 uikit/src/main/res/drawable/sb_shape_oval_button.xml create mode 100644 uikit/src/main/res/layout/sb_view_my_voice_message.xml create mode 100644 uikit/src/main/res/layout/sb_view_my_voice_message_component.xml create mode 100644 uikit/src/main/res/layout/sb_view_other_voice_message.xml create mode 100644 uikit/src/main/res/layout/sb_view_other_voice_message_component.xml create mode 100644 uikit/src/main/res/layout/sb_view_voice_message.xml create mode 100644 uikit/src/main/res/layout/sb_view_voice_message_input.xml delete mode 100644 uikit/src/test/java/com/sendbird/uikit/ExampleUnitTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ece7aa94..94de6808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +### v3.4.0 (Feb 23, 2023) with Chat SDK `v4.4.0` +* Support voice message in GroupChannel + * Added `setUseVoiceMessage(boolean)` in `SendbirdUIKit` + * Added `isUsingVoiceMessage()` in `SendbirdUIKit` + * Added `VIEW_TYPE_VOICE_MESSAGE_ME`, `VIEW_TYPE_VOICE_MESSAGE_OTHER` in `MessageType` + * Added `takeVoiceRecorder(View, int, BaseMessage)` in `ChannelFragment`, `MessageThreadFragment` + * Added `sendVoiceFileMessage(VoiceMessageInfo)` in `ChannelFragment`, `MessageThreadFragment` + * Added `setOnVoiceRecorderButtonClickListener(OnClickListener)` in `ChannelFragment.Builder`, `MessageThreadFragment.Builder` + ### v3.3.3 (Jan 19, 2023) with Chat SDK `v4.2.1` * Improved stability @@ -18,8 +27,8 @@ * Support thread type in GroupChannel * Added `THREAD` in `ReplyType` * Added `enum ThreadReplySelectType { PARENT, THREAD }` - * Added `setThreadReplySelectType(threadReplySelectType)` in `SendBirdUIKit` - * Added `getThreadReplySelectType()` in `SendBirdUIKit` + * Added `setThreadReplySelectType(threadReplySelectType)` in `SendbirdUIKit` + * Added `getThreadReplySelectType()` in `SendbirdUIKit` * Added `MessageThreadActivity`, `MessageThreadFragment`, `MessageThreadModule`, `MessageThreadViewModel`, `MessageThreadHeaderComponent`, `ThreadListComponent`, `MessageThreadInputComponent`, and `ThreadListAdapter` * Added `newRedirectToMessageThreadIntent(Context, String, long)` in `ChannelActivity` * Added `VIEW_TYPE_PARENT_MESSAGE_INFO` in `MessageType` diff --git a/gradle.properties b/gradle.properties index 9697c2a1..a5019b07 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,5 +16,5 @@ org.gradle.jvmargs=-Xmx1536m # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -UIKIT_VERSION = 3.3.3 +UIKIT_VERSION = 3.4.0 UIKIT_VERSION_CODE = 1 diff --git a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/BaseApplication.java b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/BaseApplication.java index 7251dfa4..4ca31b7d 100644 --- a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/BaseApplication.java +++ b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/BaseApplication.java @@ -168,6 +168,8 @@ public void onBeforeCreateOpenChannel(@NonNull OpenChannelCreateParams params) { SendbirdUIKit.setUIKitFragmentFactory(new CustomFragmentFactory()); // set whether to use user mention SendbirdUIKit.setUseUserMention(true); + // set the voice message + SendbirdUIKit.setUseVoiceMessage(true); // set the mention configuration SendbirdUIKit.setMentionConfig(new UserMentionConfig.Builder() .setMaxMentionCount(5) diff --git a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/components/CustomMessageInputComponent.java b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/components/CustomMessageInputComponent.java index f900b26e..27fcb7fe 100644 --- a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/components/CustomMessageInputComponent.java +++ b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/components/CustomMessageInputComponent.java @@ -43,15 +43,17 @@ public class CustomMessageInputComponent extends MessageInputComponent { private ViewCustomChannelInputBinding binding; @Nullable - private CompoundButton.OnCheckedChangeListener highlightCheckedListener; + private CompoundButton.OnCheckedChangeListener onHighlightCheckedListener; @Nullable - private View.OnClickListener menuCameraClickListener; + private View.OnClickListener onMenuCameraClickListener; @Nullable - private View.OnClickListener menuPhotoClickListener; + private View.OnClickListener onMenuPhotoClickListener; @Nullable - private View.OnClickListener menuFileClickListener; + private View.OnClickListener onMenuFileClickListener; @Nullable - private OnItemClickListener emojiClickListener; + private View.OnClickListener onVoiceMessageClickListener; + @Nullable + private OnItemClickListener onEmojiClickListener; @NonNull private MessageInputView.Mode mode = MessageInputView.Mode.DEFAULT; @NonNull @@ -109,16 +111,19 @@ public View onCreateView(@NonNull Context context, @NonNull LayoutInflater infla } }); binding.highlightSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (highlightCheckedListener != null) highlightCheckedListener.onCheckedChanged(buttonView, isChecked); + if (onHighlightCheckedListener != null) onHighlightCheckedListener.onCheckedChanged(buttonView, isChecked); }); binding.camera.setOnClickListener(v -> { - if (menuCameraClickListener != null) menuCameraClickListener.onClick(v); + if (onMenuCameraClickListener != null) onMenuCameraClickListener.onClick(v); }); binding.photo.setOnClickListener(v -> { - if (menuPhotoClickListener != null) menuPhotoClickListener.onClick(v); + if (onMenuPhotoClickListener != null) onMenuPhotoClickListener.onClick(v); }); binding.file.setOnClickListener(v -> { - if (menuFileClickListener != null) menuFileClickListener.onClick(v); + if (onMenuFileClickListener != null) onMenuFileClickListener.onClick(v); + }); + binding.voiceMessageButton.setOnClickListener(v -> { + if (onVoiceMessageClickListener != null) onVoiceMessageClickListener.onClick(v); }); binding.input.addTextChangedListener(new TextWatcher() { @Override @@ -133,7 +138,7 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { public void afterTextChanged(Editable s) {} }); - adapter.emojiClickListener = emojiClickListener; + adapter.emojiClickListener = onEmojiClickListener; return binding.getRoot(); } @@ -204,25 +209,29 @@ public void requestInputMode(@NonNull String mode) { setLeftButton(true); } - public void setHighlightCheckedListener(@Nullable CompoundButton.OnCheckedChangeListener highlightCheckedListener) { - this.highlightCheckedListener = highlightCheckedListener; + public void setOnHighlightCheckedListener(@Nullable CompoundButton.OnCheckedChangeListener onHighlightCheckedListener) { + this.onHighlightCheckedListener = onHighlightCheckedListener; + } + + public void setOnMenuCameraClickListener(@Nullable View.OnClickListener onMenuCameraClickListener) { + this.onMenuCameraClickListener = onMenuCameraClickListener; } - public void setMenuCameraClickListener(@Nullable View.OnClickListener menuCameraClickListener) { - this.menuCameraClickListener = menuCameraClickListener; + public void setOnMenuPhotoClickListener(@Nullable View.OnClickListener onMenuPhotoClickListener) { + this.onMenuPhotoClickListener = onMenuPhotoClickListener; } - public void setMenuPhotoClickListener(@Nullable View.OnClickListener menuPhotoClickListener) { - this.menuPhotoClickListener = menuPhotoClickListener; + public void setOnMenuFileClickListener(@Nullable View.OnClickListener onMenuFileClickListener) { + this.onMenuFileClickListener = onMenuFileClickListener; } - public void setMenuFileClickListener(@Nullable View.OnClickListener menuFileClickListener) { - this.menuFileClickListener = menuFileClickListener; + public void setOnVoiceMessageClickListener(@Nullable View.OnClickListener onVoiceMessageClickListener) { + this.onVoiceMessageClickListener = onVoiceMessageClickListener; } - public void setEmojiClickListener(@Nullable OnItemClickListener emojiClickListener) { - this.emojiClickListener = emojiClickListener; - adapter.emojiClickListener = emojiClickListener; + public void setOnEmojiClickListener(@Nullable OnItemClickListener onEmojiClickListener) { + this.onEmojiClickListener = onEmojiClickListener; + adapter.emojiClickListener = onEmojiClickListener; } private void setLeftButton(final boolean isLeftClosed) { diff --git a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/fragments/CustomChannelFragment.java b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/fragments/CustomChannelFragment.java index 88d86bcc..6e184e88 100644 --- a/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/fragments/CustomChannelFragment.java +++ b/uikit-custom-sample/src/main/java/com/sendbird/uikit/customsample/groupchannel/fragments/CustomChannelFragment.java @@ -84,12 +84,13 @@ protected void onBindMessageInputComponent(@NonNull MessageInputComponent inputC if (inputComponent instanceof CustomMessageInputComponent) { CustomMessageInputComponent customInput = (CustomMessageInputComponent) getModule().getMessageInputComponent(); - customInput.setMenuCameraClickListener(v -> takeCamera()); - customInput.setMenuPhotoClickListener(v -> takePhoto()); - customInput.setMenuFileClickListener(v -> takeFile()); - customInput.setHighlightCheckedListener((buttonView, isChecked) -> + customInput.setOnMenuCameraClickListener(v -> takeCamera()); + customInput.setOnMenuPhotoClickListener(v -> takePhoto()); + customInput.setOnMenuFileClickListener(v -> takeFile()); + customInput.setOnVoiceMessageClickListener(v -> takeVoiceRecorder()); + customInput.setOnHighlightCheckedListener((buttonView, isChecked) -> customMessageType = isChecked ? CustomMessageType.HIGHLIGHT : CustomMessageType.NONE); - customInput.setEmojiClickListener((view, position, url) -> { + customInput.setOnEmojiClickListener((view, position, url) -> { final UserMessageCreateParams params = new UserMessageCreateParams(); params.setMessage(url); customMessageType = CustomMessageType.EMOJI; diff --git a/uikit-custom-sample/src/main/res/layout/view_custom_channel_input.xml b/uikit-custom-sample/src/main/res/layout/view_custom_channel_input.xml index de11fbc2..3dfe09fa 100644 --- a/uikit-custom-sample/src/main/res/layout/view_custom_channel_input.xml +++ b/uikit-custom-sample/src/main/res/layout/view_custom_channel_input.xml @@ -37,6 +37,17 @@ android:src="@drawable/icon_emoji_more" android:padding="@dimen/sb_size_8" app:layout_constraintLeft_toRightOf="@id/input" + app:layout_constraintRight_toLeftOf="@id/voiceMessageButton" + app:layout_constraintTop_toBottomOf="@id/topLine" + app:layout_constraintBottom_toTopOf="@id/menuPanel"/> + + @@ -47,7 +58,7 @@ android:layout_height="@dimen/sb_size_56" android:src="@drawable/icon_send" android:padding="@dimen/sb_size_8" - app:layout_constraintLeft_toRightOf="@id/emojiButton" + app:layout_constraintLeft_toRightOf="@id/voiceMessageButton" app:layout_constraintRight_toLeftOf="@id/highlightSwitch" app:layout_constraintTop_toBottomOf="@id/topLine" app:layout_constraintBottom_toTopOf="@id/menuPanel"/> @@ -129,4 +140,4 @@ - \ No newline at end of file + diff --git a/uikit-sample/src/main/java/com/sendbird/uikit_messaging_android/BaseApplication.java b/uikit-sample/src/main/java/com/sendbird/uikit_messaging_android/BaseApplication.java index b6a63c6d..d73fccce 100644 --- a/uikit-sample/src/main/java/com/sendbird/uikit_messaging_android/BaseApplication.java +++ b/uikit-sample/src/main/java/com/sendbird/uikit_messaging_android/BaseApplication.java @@ -10,8 +10,8 @@ import com.sendbird.android.params.OpenChannelCreateParams; import com.sendbird.uikit.SendbirdUIKit; import com.sendbird.uikit.adapter.SendbirdUIKitAdapter; -import com.sendbird.uikit.consts.ThreadReplySelectType; import com.sendbird.uikit.consts.ReplyType; +import com.sendbird.uikit.consts.ThreadReplySelectType; import com.sendbird.uikit.interfaces.CustomParamsHandler; import com.sendbird.uikit.interfaces.UserInfo; import com.sendbird.uikit_messaging_android.consts.InitState; @@ -112,6 +112,8 @@ public void onInitSucceed() { // set reply type SendbirdUIKit.setReplyType(ReplyType.THREAD); SendbirdUIKit.setThreadReplySelectType(ThreadReplySelectType.THREAD); + // set whether to use voice message + SendbirdUIKit.setUseVoiceMessage(true); // set custom params SendbirdUIKit.setCustomParamsHandler(new CustomParamsHandler() { diff --git a/uikit/build.gradle b/uikit/build.gradle index eb5be8f8..e7cc33de 100644 --- a/uikit/build.gradle +++ b/uikit/build.gradle @@ -65,7 +65,7 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) // Sendbird - api 'com.sendbird.sdk:sendbird-chat:4.2.1' + api 'com.sendbird.sdk:sendbird-chat:4.4.0' implementation 'com.github.bumptech.glide:glide:4.13.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0' @@ -81,5 +81,6 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + } diff --git a/uikit/src/main/AndroidManifest.xml b/uikit/src/main/AndroidManifest.xml index fe16803b..4ec50e68 100644 --- a/uikit/src/main/AndroidManifest.xml +++ b/uikit/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/uikit/src/main/java/com/sendbird/uikit/SendbirdUIKit.java b/uikit/src/main/java/com/sendbird/uikit/SendbirdUIKit.java index a03487e0..5fd4a563 100644 --- a/uikit/src/main/java/com/sendbird/uikit/SendbirdUIKit.java +++ b/uikit/src/main/java/com/sendbird/uikit/SendbirdUIKit.java @@ -217,6 +217,7 @@ public ColorStateList getErrorTintColorStateList(@NonNull Context context) { private static volatile boolean useMention = false; private static volatile boolean useUserIdForNickname = true; private static UserMentionConfig userMentionConfig = new UserMentionConfig.Builder().build(); + private static volatile boolean useVoiceMessage = false; static void clearAll() { SendbirdUIKit.customUserListQueryHandler = null; @@ -478,6 +479,28 @@ public static boolean isUsingUserIdForNickname() { return useUserIdForNickname; } + /** + * Sets whether the voice message is used on the channel screen and message thread screen. + * The voice message is only active in group channels. + * + * @param useVoiceMessage If true the voice message will be used, false other wise. + * @since 3.4.0 + */ + public static void setUseVoiceMessage(boolean useVoiceMessage) { + SendbirdUIKit.useVoiceMessage = useVoiceMessage; + } + + /** + * Returns set value whether the voice message is used on the channel screen and message thread screen. + * The voice message is only active in group channels. + * + * @return The value whether the voice message is used on the channel screen, message thread screen. + * @since 3.4.0 + */ + public static boolean isUsingVoiceMessage() { + return SendbirdUIKit.useVoiceMessage; + } + /** * Connects to Sendbird with given UserInfo. * diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java index db837169..8180b46a 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java @@ -60,7 +60,19 @@ public enum MessageType { * * @since 3.3.0 */ - VIEW_TYPE_PARENT_MESSAGE_INFO(12); + VIEW_TYPE_PARENT_MESSAGE_INFO(12), + /** + * Type of voice message sent by the current user. + * + * @since 3.4.0 + */ + VIEW_TYPE_VOICE_MESSAGE_ME(15), + /** + * Type of voice message sent by users other than the current user. + * + * @since 3.4.0 + */ + VIEW_TYPE_VOICE_MESSAGE_OTHER(16); final int value; MessageType(int value) { diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageViewHolderFactory.java b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageViewHolderFactory.java index 42afcbfb..4c0c9432 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageViewHolderFactory.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageViewHolderFactory.java @@ -15,6 +15,7 @@ import com.sendbird.uikit.databinding.SbViewMyFileMessageBinding; import com.sendbird.uikit.databinding.SbViewMyFileVideoMessageBinding; import com.sendbird.uikit.databinding.SbViewMyUserMessageBinding; +import com.sendbird.uikit.databinding.SbViewMyVoiceMessageBinding; import com.sendbird.uikit.databinding.SbViewOpenChannelAdminMessageBinding; import com.sendbird.uikit.databinding.SbViewOpenChannelFileImageMessageBinding; import com.sendbird.uikit.databinding.SbViewOpenChannelFileMessageBinding; @@ -24,6 +25,7 @@ import com.sendbird.uikit.databinding.SbViewOtherFileMessageBinding; import com.sendbird.uikit.databinding.SbViewOtherFileVideoMessageBinding; import com.sendbird.uikit.databinding.SbViewOtherUserMessageBinding; +import com.sendbird.uikit.databinding.SbViewOtherVoiceMessageBinding; import com.sendbird.uikit.databinding.SbViewParentMessageInfoHolderBinding; import com.sendbird.uikit.databinding.SbViewTimeLineMessageBinding; import com.sendbird.uikit.internal.ui.viewholders.AdminMessageViewHolder; @@ -31,6 +33,7 @@ import com.sendbird.uikit.internal.ui.viewholders.MyImageFileMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.MyUserMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.MyVideoFileMessageViewHolder; +import com.sendbird.uikit.internal.ui.viewholders.MyVoiceMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.OpenChannelAdminMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.OpenChannelFileMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.OpenChannelImageFileMessageViewHolder; @@ -40,6 +43,7 @@ import com.sendbird.uikit.internal.ui.viewholders.OtherImageFileMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.OtherUserMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.OtherVideoFileMessageViewHolder; +import com.sendbird.uikit.internal.ui.viewholders.OtherVoiceMessageViewHolder; import com.sendbird.uikit.internal.ui.viewholders.ParentMessageInfoViewHolder; import com.sendbird.uikit.internal.ui.viewholders.TimelineViewHolder; import com.sendbird.uikit.model.MessageListUIParams; @@ -192,6 +196,12 @@ public static MessageViewHolder createViewHolder(@NonNull LayoutInflater inflate case VIEW_TYPE_PARENT_MESSAGE_INFO: holder = new ParentMessageInfoViewHolder(SbViewParentMessageInfoHolderBinding.inflate(inflater, parent, false)); break; + case VIEW_TYPE_VOICE_MESSAGE_ME: + holder = new MyVoiceMessageViewHolder(SbViewMyVoiceMessageBinding.inflate(inflater, parent, false), messageListUIParams); + break; + case VIEW_TYPE_VOICE_MESSAGE_OTHER: + holder = new OtherVoiceMessageViewHolder(SbViewOtherVoiceMessageBinding.inflate(inflater, parent, false), messageListUIParams); + break; default: // unknown message type if (viewType == MessageType.VIEW_TYPE_UNKNOWN_MESSAGE_ME) { @@ -232,7 +242,13 @@ public static MessageType getMessageType(@NonNull BaseMessage message) { } else if (message instanceof FileMessage) { FileMessage fileMessage = (FileMessage) message; String mimeType = fileMessage.getType().toLowerCase(); - if (mimeType.startsWith(StringSet.image)) { + if (MessageUtils.isVoiceMessage(fileMessage)) { + if (MessageUtils.isMine(message)) { + type = MessageType.VIEW_TYPE_VOICE_MESSAGE_ME; + } else { + type = MessageType.VIEW_TYPE_VOICE_MESSAGE_OTHER; + } + } else if (mimeType.startsWith(StringSet.image)) { if (mimeType.contains(StringSet.svg)) { if (MessageUtils.isMine(message)) { type = MessageType.VIEW_TYPE_FILE_MESSAGE_ME; diff --git a/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java b/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java index 7c07750c..0142c6cb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java +++ b/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java @@ -79,16 +79,21 @@ public class StringSet { public final static String KEY_ERROR_TEXT_RES_ID = "KEY_ERROR_TEXT_RES_ID"; public final static String KEY_USE_SUGGESTED_MENTION_LIST_DIVIDER = "KEY_USE_SUGGESTED_MENTION_LIST_DIVIDER"; public final static String KEY_USE_REFRESH_LAYOUT = "KEY_USE_REFRESH_LAYOUT"; + public final static String KEY_VOICE_MESSAGE_DURATION = "KEY_VOICE_MESSAGE_DURATION"; + public final static String KEY_INTERNAL_MESSAGE_TYPE = "KEY_INTERNAL_MESSAGE_TYPE"; public final static String sb_uikit = "sb_uikit"; + public final static String sbu_type = "sbu_type"; public final static String audio = "audio"; public final static String image = "image"; public final static String video = "video"; + public final static String voice = "voice"; public final static String jpg = "jpg"; public final static String jpeg = "jpeg"; public final static String gif = "gif"; public final static String png = "png"; public final static String webp = "webp"; + public final static String m4a = "m4a"; public final static String svg = "svg"; public final static String size = "size"; public final static String name = "name"; @@ -98,6 +103,7 @@ public class StringSet { public final static String mCursorDrawableRes = "mCursorDrawableRes"; public final static String LABEL_COPY_TEXT = "COPY_TEXT"; public final static String DEFAULT_CHANNEL_COVER_URL = "https://static.sendbird.com/sample/cover/cover_"; + public final static String Voice_message = "Voice_message"; // attributes list public final static String reactions = "reactions"; diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/BaseMessageListFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/BaseMessageListFragment.java index 1318826f..478079e5 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/BaseMessageListFragment.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/BaseMessageListFragment.java @@ -21,10 +21,12 @@ import com.sendbird.android.SendbirdChat; import com.sendbird.android.channel.ChannelType; import com.sendbird.android.channel.GroupChannel; +import com.sendbird.android.channel.Role; import com.sendbird.android.exception.SendbirdException; import com.sendbird.android.message.BaseMessage; import com.sendbird.android.message.Emoji; import com.sendbird.android.message.FileMessage; +import com.sendbird.android.message.MessageMetaArray; import com.sendbird.android.message.Reaction; import com.sendbird.android.message.SendingStatus; import com.sendbird.android.message.UserMessage; @@ -32,6 +34,7 @@ import com.sendbird.android.params.UserMessageCreateParams; import com.sendbird.android.params.UserMessageUpdateParams; import com.sendbird.android.user.Member; +import com.sendbird.android.user.MutedState; import com.sendbird.android.user.Sender; import com.sendbird.android.user.User; import com.sendbird.uikit.R; @@ -50,13 +53,16 @@ import com.sendbird.uikit.interfaces.OnResultHandler; import com.sendbird.uikit.internal.tasks.JobResultTask; import com.sendbird.uikit.internal.tasks.TaskQueue; +import com.sendbird.uikit.internal.ui.messages.VoiceMessageView; import com.sendbird.uikit.internal.ui.reactions.EmojiListView; import com.sendbird.uikit.internal.ui.reactions.EmojiReactionUserListView; +import com.sendbird.uikit.internal.ui.widgets.VoiceMessageInputView; import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.model.DialogListItem; import com.sendbird.uikit.model.EmojiManager; import com.sendbird.uikit.model.FileInfo; import com.sendbird.uikit.model.ReadyStatus; +import com.sendbird.uikit.model.VoiceMessageInfo; import com.sendbird.uikit.modules.BaseMessageListModule; import com.sendbird.uikit.modules.components.BaseMessageListComponent; import com.sendbird.uikit.utils.ContextUtils; @@ -219,6 +225,12 @@ public void onError(@Nullable SendbirdException e) { } }); break; + case VIEW_TYPE_VOICE_MESSAGE_ME: + case VIEW_TYPE_VOICE_MESSAGE_OTHER: + if (view instanceof VoiceMessageView) { + ((VoiceMessageView) view).callOnPlayerButtonClick(); + } + break; default: } } else { @@ -354,20 +366,22 @@ void showEmojiActionsDialog(@NonNull BaseMessage message, @NonNull DialogListIte final Context contextThemeWrapper = ContextUtils.extractModuleThemeContext(getContext(), getModule().getParams().getTheme(), R.attr.sb_component_list); final EmojiListView emojiListView = EmojiListView.create(contextThemeWrapper, emojiList, message.getReactions(), showMoreButton); hideKeyboard(); - final AlertDialog dialog = DialogUtils.showContentViewAndListDialog(requireContext(), emojiListView, actions, createMessageActionListener(message)); - - emojiListView.setEmojiClickListener((view, position, emojiKey) -> { - dialog.dismiss(); - getViewModel().toggleReaction(view, message, emojiKey, e -> { - if (e != null) - toastError(view.isSelected() ? R.string.sb_text_error_delete_reaction : R.string.sb_text_error_add_reaction); + if (actions.length > 0 || emojiList.size() > 0) { + final AlertDialog dialog = DialogUtils.showContentViewAndListDialog(requireContext(), emojiListView, actions, createMessageActionListener(message)); + + emojiListView.setEmojiClickListener((view, position, emojiKey) -> { + dialog.dismiss(); + getViewModel().toggleReaction(view, message, emojiKey, e -> { + if (e != null) + toastError(view.isSelected() ? R.string.sb_text_error_delete_reaction : R.string.sb_text_error_add_reaction); + }); }); - }); - emojiListView.setMoreButtonClickListener(v -> { - dialog.dismiss(); - showEmojiListDialog(message); - }); + emojiListView.setMoreButtonClickListener(v -> { + dialog.dismiss(); + showEmojiListDialog(message); + }); + } } private void showUserProfile(@NonNull User sender) { @@ -602,6 +616,29 @@ public void takeFile() { } } + /** + * Call taking voice recorder. + * + * @since 3.4.0 + */ + public void takeVoiceRecorder() { + requestPermission(PermissionUtils.RECORD_AUDIO_PERMISSION, () -> { + if (getContext() == null) return; + final Context contextThemeWrapper = ContextUtils.extractModuleThemeContext(getContext(), getModule().getParams().getTheme(), R.attr.sb_component_channel_message_input); + final VoiceMessageInputView recorderView = new VoiceMessageInputView(contextThemeWrapper); + hideKeyboard(); + final AlertDialog dialog = DialogUtils.showContentDialog(contextThemeWrapper, recorderView); + dialog.setCanceledOnTouchOutside(false); + recorderView.setOnSendButtonClickListener((sendButton, position, voiceMessageInfo) -> { + sendVoiceFileMessage(voiceMessageInfo); + dialog.dismiss(); + }); + recorderView.setOnCancelButtonClickListener(cancelButton -> { + dialog.dismiss(); + }); + }); + } + /** * It will be called when the loading dialog needs displaying. * @@ -678,13 +715,7 @@ protected void sendFileMessage(@NonNull Uri uri) { @Override public void onResult(@NonNull FileInfo info) { BaseMessageListFragment.this.mediaUri = null; - final FileMessageCreateParams params = info.toFileParams(); - final CustomParamsHandler customHandler = SendbirdUIKit.getCustomParamsHandler(); - if (customHandler != null) { - customHandler.onBeforeSendFileMessage(params); - } - onBeforeSendFileMessage(params); - sendFileMessageInternal(info, params); + sendFileMessage(info, info.toFileParams()); } @Override @@ -697,6 +728,55 @@ public void onError(@Nullable SendbirdException e) { } } + /** + * Sends a voice message with given file information. + * + * @param info A voice file information + * @since 3.4.0 + */ + protected void sendVoiceFileMessage(@NonNull VoiceMessageInfo info) { + final GroupChannel channel = getViewModel().getChannel(); + if (channel == null) return; + boolean isOperator = channel.getMyRole() == Role.OPERATOR; + boolean isMuted = channel.getMyMutedState() == MutedState.MUTED; + boolean isFrozen = channel.isFrozen() && !isOperator; + if (isMuted || isFrozen) { + if (isMuted) { + toastError(R.string.sb_text_error_user_muted); + } else { + toastError(R.string.sb_text_error_channel_frozen); + } + final File voiceFile = new File(info.getPath()); + voiceFile.delete(); + return; + } + + if (getContext() != null) { + final FileInfo fileInfo = FileInfo.fromVoiceFileInfo(info, + FileUtils.getChannelFileCacheDir(getContext(), getViewModel().getChannelUrl())); + final FileMessageCreateParams params = fileInfo.toFileParams(); + final List metaArrays = new ArrayList<>(); + final List duration = new ArrayList<>(); + duration.add(String.valueOf(info.getDuration())); + metaArrays.add(new MessageMetaArray(StringSet.KEY_VOICE_MESSAGE_DURATION, duration)); + final List type = new ArrayList<>(); + type.add(StringSet.voice + "/" + StringSet.m4a); + metaArrays.add(new MessageMetaArray(StringSet.KEY_INTERNAL_MESSAGE_TYPE, type)); + params.setMetaArrays(metaArrays); + params.setFileName(StringSet.Voice_message + "." + StringSet.m4a); + sendFileMessage(fileInfo, params); + } + } + + private void sendFileMessage(@NonNull FileInfo info, @NonNull FileMessageCreateParams params) { + final CustomParamsHandler customHandler = SendbirdUIKit.getCustomParamsHandler(); + if (customHandler != null) { + customHandler.onBeforeSendFileMessage(params); + } + onBeforeSendFileMessage(params); + sendFileMessageInternal(info, params); + } + /** * Updates a UserMessage that was previously sent in the channel. * diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java index ace17ed6..52d84754 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java @@ -49,6 +49,7 @@ import com.sendbird.uikit.interfaces.OnInputTextChangedListener; import com.sendbird.uikit.interfaces.OnItemClickListener; import com.sendbird.uikit.interfaces.OnItemLongClickListener; +import com.sendbird.uikit.internal.model.VoicePlayerManager; import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.model.DialogListItem; import com.sendbird.uikit.model.ReadyStatus; @@ -118,6 +119,8 @@ public class ChannelFragment extends BaseMessageListFragment { + for (final BaseMessage deletedMessage : deletedMessages) { + if (deletedMessage instanceof FileMessage && MessageUtils.isVoiceMessage((FileMessage) deletedMessage)) { + final String key = MessageUtils.getVoiceMessageKey((FileMessage) deletedMessage); + if (key.equals(VoicePlayerManager.getCurrentKey())) { + VoicePlayerManager.pause(); + } + } + } + }); } /** @@ -384,6 +398,7 @@ protected void onBindMessageInputComponent(@NonNull MessageInputComponent inputC inputComponent.setOnInputTextChangedListener(inputTextChangedListener != null ? inputTextChangedListener : (s, start, before, count) -> viewModel.setTyping(s.length() > 0)); inputComponent.setOnInputModeChangedListener(inputModeChangedListener != null ? inputModeChangedListener : this::onInputModeChanged); inputComponent.setOnQuoteReplyModeCloseButtonClickListener(replyModeCloseButtonClickListener != null ? replyModeCloseButtonClickListener : v -> inputComponent.requestInputMode(MessageInputView.Mode.DEFAULT)); + inputComponent.setOnVoiceRecorderButtonClickListener((onVoiceRecorderButtonClickListener != null) ? onVoiceRecorderButtonClickListener : v -> takeVoiceRecorder()); if (SendbirdUIKit.isUsingUserMention()) { inputComponent.bindUserMention(SendbirdUIKit.getUserMentionConfig(), text -> viewModel.loadMemberList(text != null ? text.toString() : null)); @@ -584,6 +599,22 @@ protected List makeMessageContextMenu(@NonNull BaseMessage messa actions = new DialogListItem[]{save, reply}; } break; + case VIEW_TYPE_VOICE_MESSAGE_ME: + if (MessageUtils.isFailed(message)) { + actions = new DialogListItem[]{retry, deleteFailed}; + } else { + if (replyType == ReplyType.NONE) { + actions = new DialogListItem[]{delete}; + } else { + actions = new DialogListItem[]{delete, reply}; + } + } + break; + case VIEW_TYPE_VOICE_MESSAGE_OTHER: + if (replyType != ReplyType.NONE) { + actions = new DialogListItem[]{reply}; + } + break; case VIEW_TYPE_UNKNOWN_MESSAGE_ME: actions = new DialogListItem[]{delete}; default: @@ -640,16 +671,15 @@ void showMessageContextMenu(@NonNull View anchorView, @NonNull BaseMessage messa final DialogListItem[] actions = items.toArray(new DialogListItem[size]); if (!ReactionUtils.canSendReaction(getViewModel().getChannel())) { final RecyclerView messageListView = getModule().getMessageListComponent().getRecyclerView(); - if (getContext() != null && messageListView != null) { - MessageAnchorDialog messageAnchorDialog = new MessageAnchorDialog.Builder(anchorView, messageListView, actions) - .setOnItemClickListener(createMessageActionListener(message)) - .setOnDismissListener(() -> anchorDialogShowing.set(false)) - .build(); - messageAnchorDialog.show(); - anchorDialogShowing.set(true); - } + if (getContext() == null || messageListView == null || size <= 0) return; + MessageAnchorDialog messageAnchorDialog = new MessageAnchorDialog.Builder(anchorView, messageListView, actions) + .setOnItemClickListener(createMessageActionListener(message)) + .setOnDismissListener(() -> anchorDialogShowing.set(false)) + .build(); + messageAnchorDialog.show(); + anchorDialogShowing.set(true); } else if (MessageUtils.isUnknownType(message)) { - if (getContext() == null) return; + if (getContext() == null || size <= 0) return; DialogUtils.showListBottomDialog(requireContext(), actions, createMessageActionListener(message)); } else { showEmojiActionsDialog(message, actions); @@ -787,6 +817,8 @@ public static class Builder { @Nullable private OnItemClickListener threadInfoClickListener; @Nullable + private View.OnClickListener voiceRecorderButtonClickListener; + @Nullable private ChannelFragment customFragment; @@ -1729,6 +1761,19 @@ public Builder setUseMessageListBanner(boolean useBanner) { return this; } + /** + * Register a callback to be invoked when the button to show voice recorder is clicked. + * + * @param voiceRecorderButtonClickListener The callback that will run + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.4.0 + */ + @NonNull + public Builder setOnVoiceRecorderButtonClickListener(@Nullable View.OnClickListener voiceRecorderButtonClickListener) { + this.voiceRecorderButtonClickListener = voiceRecorderButtonClickListener; + return this; + } + /** * Creates an {@link ChannelFragment} with the arguments supplied to this * builder. @@ -1766,6 +1811,7 @@ public ChannelFragment build() { fragment.setAdapter(adapter); fragment.params = params; fragment.threadInfoClickListener = threadInfoClickListener; + fragment.onVoiceRecorderButtonClickListener = voiceRecorderButtonClickListener; // set animation flag to TRUE to animate searched text. if (bundle.containsKey(StringSet.KEY_TRY_ANIMATE_WHEN_MESSAGE_LOADED)) { diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/MessageThreadFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/MessageThreadFragment.java index 7b54c1a5..60da0bf8 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/MessageThreadFragment.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/MessageThreadFragment.java @@ -42,6 +42,7 @@ import com.sendbird.uikit.interfaces.OnInputTextChangedListener; import com.sendbird.uikit.interfaces.OnItemClickListener; import com.sendbird.uikit.interfaces.OnItemLongClickListener; +import com.sendbird.uikit.internal.model.VoicePlayerManager; import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.model.DialogListItem; import com.sendbird.uikit.model.FileInfo; @@ -105,6 +106,8 @@ public class MessageThreadFragment extends BaseMessageListFragment { + if (String.valueOf(deletedMessageId).equals(VoicePlayerManager.getCurrentKey())) { + VoicePlayerManager.pause(); + } + }); } /** @@ -323,6 +331,7 @@ protected void onBindMessageInputComponent(@NonNull MessageInputComponent inputC inputComponent.setOnEditModeCancelButtonClickListener(editModeCancelButtonClickListener != null ? editModeCancelButtonClickListener : v -> inputComponent.requestInputMode(MessageInputView.Mode.DEFAULT)); inputComponent.setOnInputTextChangedListener(inputTextChangedListener != null ? inputTextChangedListener : (s, start, before, count) -> viewModel.setTyping(s.length() > 0)); inputComponent.setOnInputModeChangedListener(inputModeChangedListener != null ? inputModeChangedListener : this::onInputModeChanged); + inputComponent.setOnVoiceRecorderButtonClickListener((voiceRecorderButtonClickListener != null) ? voiceRecorderButtonClickListener : v -> takeVoiceRecorder()); if (SendbirdUIKit.isUsingUserMention()) { inputComponent.bindUserMention(SendbirdUIKit.getUserMentionConfig(), text -> viewModel.loadMemberList(text != null ? text.toString() : null)); @@ -446,6 +455,13 @@ protected List makeMessageContextMenu(@NonNull BaseMessage messa case VIEW_TYPE_FILE_MESSAGE_OTHER: actions = new DialogListItem[]{save}; break; + case VIEW_TYPE_VOICE_MESSAGE_ME: + if (MessageUtils.isFailed(message)) { + actions = new DialogListItem[]{retry, deleteFailed}; + } else { + actions = new DialogListItem[]{delete}; + } + break; case VIEW_TYPE_UNKNOWN_MESSAGE_ME: actions = new DialogListItem[]{delete}; default: @@ -494,7 +510,7 @@ void showMessageContextMenu(@NonNull View anchorView, @NonNull BaseMessage messa int size = items.size(); final DialogListItem[] actions = items.toArray(new DialogListItem[size]); if (!ReactionUtils.canSendReaction(getViewModel().getChannel()) || MessageUtils.isUnknownType(message)) { - if (getContext() == null) return; + if (getContext() == null || size <= 0) return; DialogUtils.showListBottomDialog(requireContext(), actions, createMessageActionListener(message)); } else { showEmojiActionsDialog(message, actions); @@ -578,6 +594,8 @@ public static class Builder { @Nullable private OnInputModeChangedListener inputModeChangedListener; @Nullable + private View.OnClickListener voiceRecorderButtonClickListener; + @Nullable private MessageThreadFragment customFragment; @@ -1416,6 +1434,19 @@ public Builder setUseMessageListBanner(boolean useBanner) { return this; } + /** + * Register a callback to be invoked when the button to show voice recorder is clicked. + * + * @param voiceRecorderButtonClickListener The callback that will run + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.4.0 + */ + @NonNull + public Builder setOnVoiceRecorderButtonClickListener(@Nullable View.OnClickListener voiceRecorderButtonClickListener) { + this.voiceRecorderButtonClickListener = voiceRecorderButtonClickListener; + return this; + } + /** * Creates an {@link MessageThreadFragment} with the arguments supplied to this * builder. @@ -1447,6 +1478,7 @@ public MessageThreadFragment build() { fragment.editModeCancelButtonClickListener = editModeCancelButtonClickListener; fragment.editModeSaveButtonClickListener = editModeSaveButtonClickListener; fragment.inputModeChangedListener = inputModeChangedListener; + fragment.voiceRecorderButtonClickListener = voiceRecorderButtonClickListener; fragment.setAdapter(adapter); fragment.params = params; return fragment; diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/OnVoiceFileDownloadListener.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/OnVoiceFileDownloadListener.kt new file mode 100644 index 00000000..358ec755 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/OnVoiceFileDownloadListener.kt @@ -0,0 +1,8 @@ +package com.sendbird.uikit.internal.model + +import com.sendbird.android.exception.SendbirdException +import java.io.File + +internal interface OnVoiceFileDownloadListener { + fun onVoiceFileDownloaded(voiceFile: File?, e: SendbirdException?) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt new file mode 100644 index 00000000..740c343f --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt @@ -0,0 +1,286 @@ +package com.sendbird.uikit.internal.model + +import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.AnyThread +import androidx.annotation.UiThread +import com.sendbird.android.exception.SendbirdException +import com.sendbird.android.message.FileMessage +import com.sendbird.uikit.interfaces.OnResultHandler +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.ClearableScheduledExecutorService +import com.sendbird.uikit.utils.FileUtils +import com.sendbird.uikit.vm.FileDownloader +import java.io.File +import java.util.concurrent.TimeUnit + +internal class VoicePlayer(val key: String) { + enum class Status { + STOPPED, PREPARING, PLAYING, PAUSED + } + + interface OnUpdateListener { + @UiThread + fun onUpdated(key: String, status: Status) + } + + interface OnProgressUpdateListener { + @UiThread + fun onProgressUpdated(key: String, status: Status, milliseconds: Int, duration: Int) + } + + private val onUpdateListenerSet: MutableSet = hashSetOf() + private val onProgressUpdateListenerSet: MutableSet = hashSetOf() + var duration: Int = 0 + var status: Status = Status.STOPPED + private set + + private val player: MediaPlayer = MediaPlayer() + private val progressExecutor by lazy { ClearableScheduledExecutorService() } + private val uiThreadHandler by lazy { Handler(Looper.getMainLooper()) } + + @UiThread + @Synchronized + fun play( + context: Context, + message: FileMessage, + duration: Int, + onUpdateListener: OnUpdateListener, + onProgressUpdateListener: OnProgressUpdateListener + ) { + Logger.i("VoicePlayer::play()") + val voiceFile: File? = getData(context, message) + if (voiceFile != null) { + play(context, voiceFile, duration, onUpdateListener, onProgressUpdateListener) + return + } + + addOnUpdateListener(onUpdateListener) + addOnProgressUpdateListener(onProgressUpdateListener) + updateStatus(Status.PREPARING) + downloadFile( + context, + message, + object : OnVoiceFileDownloadListener { + override fun onVoiceFileDownloaded( + voiceFile: File?, + e: SendbirdException? + ) { + Logger.i(">> VoicePlayer::onVoiceFileDownloaded, status=$status") + if (e != null || status != Status.PREPARING || voiceFile == null) { + stop() + return + } + play(context, voiceFile, duration, onUpdateListener, onProgressUpdateListener) + } + } + ) + } + + @UiThread + @Synchronized + fun play( + context: Context, + voiceFile: File, + duration: Int, + onUpdateListener: OnUpdateListener, + onProgressUpdateListener: OnProgressUpdateListener + ) { + Logger.i("VoicePlayer::play(), status=%s", status) + if (status == Status.PLAYING) return + + addOnUpdateListener(onUpdateListener) + addOnProgressUpdateListener(onProgressUpdateListener) + prepare(context, voiceFile.absolutePath, duration) + player.start() + updateStatus(Status.PLAYING) + startProgressExecutor() + } + + private fun prepare(context: Context, filePath: String, duration: Int) { + Logger.i("VoicePlayer::prepare()") + if (status == Status.PAUSED) return + updateStatus(Status.PREPARING) + this.duration = duration + player.run { + try { + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + setDataSource(context, Uri.parse(filePath)) + setOnErrorListener { _, _, _ -> + this@VoicePlayer.stop() + true + } + setOnCompletionListener { + this@VoicePlayer.stop() + } + prepare() + } catch (e: Throwable) { + Logger.w(e) + this@VoicePlayer.stop() + } + } + } + + @UiThread + @Synchronized + fun pause() { + if (status == Status.STOPPED || status == Status.PAUSED) return + Logger.i("VoicePlayer::pause(), seekTo=${getSeekTo()}") + + progressExecutor.cancelAllJobs(true) + updateStatus(Status.PAUSED) + updateProgress(getSeekTo()) + player.pause() + } + + @UiThread + @Synchronized + fun stop() { + if (status == Status.STOPPED) return + Logger.i("VoicePlayer::stop()") + + progressExecutor.cancelAllJobs(true) + updateStatus(Status.STOPPED) + updateProgress(0) + player.reset() + } + + @UiThread + @Synchronized + private fun updateStatus(status: Status) { + if (this.status == status) return + Logger.i("VoicePlayer::updateProgress(), status : $status") + + this.status = status + onUpdateListenerSet.forEach { + it.onUpdated(key, status) + } + } + + @UiThread + @Synchronized + private fun updateProgress(currentPosition: Int) { + Logger.i("VoicePlayer::updateProgress(), currentPosition : $currentPosition") + + onProgressUpdateListenerSet.forEach { + it.onProgressUpdated( + key, + status, + currentPosition, + duration + ) + } + } + + @Synchronized + fun getSeekTo(): Int = player.currentPosition + + private fun startProgressExecutor() { + Logger.i("VoicePlayer::startProgressExecutor()") + progressExecutor.cancelAllJobs(true) + progressExecutor.scheduleAtFixedRate({ + runOnUiThread { + try { + Logger.i("VoicePlayer >> onProgress, current pos : ${getSeekTo()}, status : $status") + if (status == Status.PLAYING) { + updateProgress(getSeekTo()) + } + } catch (ignore: Throwable) { + } + } + }, 0, 100, TimeUnit.MILLISECONDS) + } + + private fun T?.runOnUiThread(block: (T) -> Unit) { + if (this != null) { + uiThreadHandler.post { block(this) } + } + } + + @Synchronized + fun dispose() { + Logger.i("VoicePlayer::dispose()") + player.release() + progressExecutor.shutdownNow() + onUpdateListenerSet.clear() + onProgressUpdateListenerSet.clear() + status = Status.STOPPED + } + + @AnyThread + @Synchronized + fun addOnUpdateListener(onUpdateListener: OnUpdateListener) { + onUpdateListenerSet.add(onUpdateListener) + } + + @AnyThread + @Synchronized + fun removeOnUpdateListener(onUpdateListener: OnUpdateListener) { + onUpdateListenerSet.remove(onUpdateListener) + } + + @AnyThread + @Synchronized + fun addOnProgressUpdateListener(onProgressUpdateListener: OnProgressUpdateListener) { + onProgressUpdateListenerSet.add(onProgressUpdateListener) + } + + @AnyThread + @Synchronized + fun removeOnProgressListener(onProgressUpdateListener: OnProgressUpdateListener) { + onProgressUpdateListenerSet.remove(onProgressUpdateListener) + } + + private fun getData(context: Context, fileMessage: FileMessage): File? { + val voiceFile = FileUtils.getVoiceFile(context, fileMessage) + if (voiceFile.exists()) { + if (voiceFile.length().toInt() == fileMessage.size) { + Logger.dev("__ return exist voice file") + return voiceFile + } + } + return null + } + + private fun downloadFile( + context: Context, + fileMessage: FileMessage, + listener: OnVoiceFileDownloadListener? = null + ) { + FileDownloader.downloadFile( + context, + fileMessage, + object : OnResultHandler { + override fun onResult(file: File) { + listener?.onVoiceFileDownloaded(file, null) + } + + override fun onError(e: SendbirdException?) { + listener?.onVoiceFileDownloaded(null, e) + } + } + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is VoicePlayer) return false + + if (key != other.key) return false + + return true + } + + override fun hashCode(): Int { + return key.hashCode() + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayerManager.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayerManager.kt new file mode 100644 index 00000000..8ffe3b74 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayerManager.kt @@ -0,0 +1,160 @@ +package com.sendbird.uikit.internal.model + +import android.content.Context +import androidx.annotation.AnyThread +import androidx.annotation.UiThread +import com.sendbird.android.message.FileMessage +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.MessageUtils +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +internal object VoicePlayerManager { + + private val cache: MutableMap = ConcurrentHashMap() + private var currentPlayer: VoicePlayer? = null + + @UiThread + @Synchronized + @JvmStatic + fun play( + context: Context, + key: String, + fileMessage: FileMessage, + onUpdateListener: VoicePlayer.OnUpdateListener, + onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener + ) { + swap(key).apply { + play( + context, + fileMessage, + MessageUtils.extractDuration(fileMessage), + onUpdateListener, + onProgressUpdateListener + ) + } + } + + @UiThread + @Synchronized + @JvmStatic + fun play( + context: Context, + key: String, + file: File, + duration: Int, + onUpdateListener: VoicePlayer.OnUpdateListener, + onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener + ) { + swap(key).apply { + play(context, file, duration, onUpdateListener, onProgressUpdateListener) + } + } + + @Synchronized + private fun swap(key: String): VoicePlayer { + if (currentPlayer?.key == key) { + return requireNotNull(currentPlayer) + } + // reset previous player + currentPlayer?.pause() + + // set the new player + if (!cache.contains(key)) { + cache[key] = VoicePlayer(key) + } + currentPlayer = cache[key] + return requireNotNull(currentPlayer) + } + + @UiThread + @Synchronized + @JvmStatic + fun pause() { + Logger.i("VoicePlayerManager::pause") + currentPlayer?.pause() + } + + @AnyThread + @Synchronized + @JvmStatic + fun dispose(key: String) { + Logger.i("VoicePlayerManager::dispose, key=$key") + val player = cache.remove(key) + player?.dispose() + if (player == currentPlayer) currentPlayer = null + } + + @AnyThread + @Synchronized + @JvmStatic + fun disposeAll() { + Logger.i("VoicePlayerManager::disposeAll") + cache.forEach { + it.value.dispose() + } + currentPlayer = null + cache.clear() + } + + @AnyThread + @Synchronized + @JvmStatic + fun getSeekTo(key: String): Int { + Logger.i("VoicePlayerManager::getSeekTo, key=$key") + return try { + cache[key]?.getSeekTo() ?: 0 + } catch (e: Throwable) { + 0 + } + } + + @AnyThread + @Synchronized + @JvmStatic + fun getStatus(key: String): VoicePlayer.Status? { + Logger.i("VoicePlayerManager::getStatus, key=$key") + return cache[key]?.status + } + + @AnyThread + @Synchronized + @JvmStatic + fun getCurrentKey(): String? { + Logger.i("VoicePlayerManager::getCurrentKey") + return currentPlayer?.key + } + + @AnyThread + @Synchronized + @JvmStatic + fun addOnUpdateListener(key: String, onUpdateListener: VoicePlayer.OnUpdateListener) { + Logger.i("VoicePlayerManager::addOnUpdateListener, key=$key") + cache[key]?.addOnUpdateListener(onUpdateListener) + } + + @AnyThread + @Synchronized + @JvmStatic + fun addOnProgressUpdateListener( + key: String, + onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener + ) { + Logger.i("VoicePlayerManager::addOnProgressUpdateListener, key=$key") + cache[key]?.addOnProgressUpdateListener(onProgressUpdateListener) + } + + @AnyThread + @Synchronized + @JvmStatic + fun removeOnUpdateListener(key: String, onUpdateListener: VoicePlayer.OnUpdateListener) { + cache[key]?.removeOnUpdateListener(onUpdateListener) + } + + @AnyThread + @Synchronized + @JvmStatic + fun removeOnProgressListener(key: String, onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener) { + cache[key]?.removeOnProgressListener(onProgressUpdateListener) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/VoiceRecorder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoiceRecorder.kt new file mode 100644 index 00000000..59f77a32 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoiceRecorder.kt @@ -0,0 +1,205 @@ +package com.sendbird.uikit.internal.model + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import com.sendbird.android.SendbirdChat +import com.sendbird.uikit.consts.StringSet +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.ClearableScheduledExecutorService +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +internal class VoiceRecorder( + context: Context, + private val onUpdateListener: OnUpdateListener? = null, + private val onProgressUpdateListener: OnProgressUpdateListener? = null +) { + + enum class Status { + IDLE, COMPLETED, PREPARING, RECORDING + } + + interface OnUpdateListener { + @UiThread + fun onUpdated(status: Status) + } + + interface OnProgressUpdateListener { + @UiThread + fun onProgressUpdated(status: Status, milliseconds: Int, amplitude: Int) + } + + companion object { + const val maxDurationMillis = 60000 + } + + private val recorder: MediaRecorder + val recordFilePath: String + var status: Status = Status.IDLE + private set + var seekTo: Int = 0 + private set + + private var isRunningOnTest: Boolean = false + private val progressExecutor by lazy { ClearableScheduledExecutorService() } + private val uiThreadHandler by lazy { Handler(Looper.getMainLooper()) } + + init { + recorder = createRecorder(context) + recordFilePath = createRecordFilePath(context) + } + + @UiThread + @Synchronized + fun record() { + if (status == Status.RECORDING || status == Status.COMPLETED) { + Logger.w("Recording already started") + return + } + updateStatus(Status.PREPARING) + val file = File(recordFilePath) + if (file.exists() && file.length() > 0) { + file.delete() + } + recorder.run { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(1) + setAudioSamplingRate(12000) + setAudioEncodingBitRate(96) + setOutputFile(recordFilePath) + setMaxDuration(maxDurationMillis) + SendbirdChat.appInfo?.let { + setMaxFileSize(it.uploadSizeLimit) + } + setOnInfoListener { _, what, _ -> + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { + Logger.i("VoiceRecorder >> MEDIA_RECORDER_INFO_MAX_DURATION_REACHED") + this@VoiceRecorder.complete() + } + + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + Logger.i("VoiceRecorder >> MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED") + this@VoiceRecorder.complete() + } + + if (what == MediaRecorder.MEDIA_RECORDER_ERROR_UNKNOWN || + what == MediaRecorder.MEDIA_ERROR_SERVER_DIED + ) { + Logger.i("VoiceRecorder >> MEDIA_RECORDER_ERROR") + this@VoiceRecorder.complete() + } + } + try { + prepare() + start() + updateStatus(Status.RECORDING) + startRecordTimer() + } catch (e: Throwable) { + Logger.w(e) + this@VoiceRecorder.cancel(true) + return@run + } + } + } + + @UiThread + @Synchronized + fun complete() { + if (status == Status.COMPLETED) return + if (status == Status.PREPARING) { + seekTo = 0 + File(recordFilePath).delete() + } + progressExecutor.shutdownNow() + try { + recorder.reset() + recorder.release() + } catch (e: Throwable) { + Logger.w(e) + } + updateStatus(Status.COMPLETED) + } + + @UiThread + @Synchronized + fun cancel(reset: Boolean = false) { + if (status == Status.COMPLETED) return + seekTo = 0 + File(recordFilePath).delete() + if (!reset) { + complete() + } else { + progressExecutor.cancelAllJobs(true) + + try { + recorder.reset() + } catch (e: Throwable) { + Logger.w(e) + } + updateStatus(Status.IDLE) + } + } + + @UiThread + @Synchronized + private fun updateStatus(status: Status) { + if (this.status == status) return + this.status = status + onUpdateListener?.onUpdated(this.status) + } + + @UiThread + @Synchronized + private fun updateProgress(milliseconds: Int) { + onProgressUpdateListener?.onProgressUpdated( + status, + milliseconds, + maxDurationMillis + ) + } + + private fun createRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else MediaRecorder() + + private fun createRecordFilePath(context: Context): String { + return "${context.cacheDir?.absolutePath}/record-${System.currentTimeMillis()}.${StringSet.m4a}" + } + + private fun startRecordTimer() { + progressExecutor.cancelAllJobs(true) + progressExecutor.scheduleAtFixedRate({ + runOnUiThread { + updateProgress(seekTo) + seekTo += 100 + } + }, 0, 100, TimeUnit.MILLISECONDS) + } + + private fun T?.runOnUiThread(block: (T) -> Unit) { + if (this != null) { + if (isRunningOnTest) { + thread { block(this) } + } else { + uiThreadHandler.post { block(this) } + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + constructor( + context: Context, + isRunningOnTest: Boolean, + onUpdateListener: OnUpdateListener? = null, + onProgressUpdateListener: OnProgressUpdateListener? = null + ) : this(context, onUpdateListener, onProgressUpdateListener) { + this.isRunningOnTest = isRunningOnTest + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/queries/BannedUserListQuery.kt b/uikit/src/main/java/com/sendbird/uikit/internal/queries/BannedUserListQuery.kt index 5861dcb0..681be662 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/queries/BannedUserListQuery.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/queries/BannedUserListQuery.kt @@ -16,9 +16,11 @@ internal class BannedUserListQuery( ) : PagedQueryHandler { private var query: BannedUserListQuery? = null override fun loadInitial(handler: OnListResultHandler) { - query = createBannedUserListQuery(BannedUserListQueryParams(channelType, channelUrl).apply { - limit = 30 - }) + query = createBannedUserListQuery( + BannedUserListQueryParams(channelType, channelUrl).apply { + limit = 30 + } + ) loadMore(handler) } diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/queries/MutedMemberListQuery.kt b/uikit/src/main/java/com/sendbird/uikit/internal/queries/MutedMemberListQuery.kt index 2962bc50..5e347e1b 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/queries/MutedMemberListQuery.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/queries/MutedMemberListQuery.kt @@ -11,10 +11,13 @@ import com.sendbird.uikit.interfaces.PagedQueryHandler internal class MutedMemberListQuery(private val channelUrl: String) : PagedQueryHandler { private var query: MemberListQuery? = null override fun loadInitial(handler: OnListResultHandler) { - query = GroupChannel.createMemberListQuery(channelUrl, MemberListQueryParams().apply { - limit = 30 - mutedMemberFilter = MutedMemberFilter.MUTED - }) + query = GroupChannel.createMemberListQuery( + channelUrl, + MemberListQueryParams().apply { + limit = 30 + mutedMemberFilter = MutedMemberFilter.MUTED + } + ) loadMore(handler) } diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt index 1a62dddd..783d5a2e 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt @@ -207,7 +207,11 @@ internal class ChannelPreview @JvmOverloads constructor( is FileMessage -> { textView.maxLines = 1 textView.ellipsize = TextUtils.TruncateAt.MIDDLE - message = it.name + message = if (MessageUtils.isVoiceMessage(it)) { + textView.context.getString(R.string.sb_text_voice_message) + } else { + it.name + } } } } diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MessagePreview.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MessagePreview.kt index 4824d830..7eb55c82 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MessagePreview.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MessagePreview.kt @@ -17,6 +17,7 @@ import com.sendbird.uikit.databinding.SbViewMessagePreviewBinding import com.sendbird.uikit.internal.extensions.setAppearance import com.sendbird.uikit.utils.DateUtils import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.MessageUtils import com.sendbird.uikit.utils.ViewUtils import java.util.Locale @@ -80,25 +81,36 @@ internal class MessagePreview @JvmOverloads constructor( binding.tvUserName.text = message.sender?.nickname ?: "" binding.tvSentAt.text = DateUtils.formatDateTime(context, message.createdAt) if (message is FileMessage) { - val icon = getIconDrawable(message.type) - binding.tvMessage.apply { - isSingleLine = true - maxLines = 1 - ellipsize = TextUtils.TruncateAt.MIDDLE - setAppearance(context, messageFileTextAppearance) - } - metaphorTintColor?.let { - binding.ivIcon.setImageDrawable( - DrawableUtils.setTintList( - binding.ivIcon.context, - icon, - metaphorTintColor + if (MessageUtils.isVoiceMessage(message)) { + binding.tvMessage.apply { + isSingleLine = true + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setAppearance(context, messageTextAppearance) + text = context.getString(R.string.sb_text_voice_message) + } + binding.ivIcon.visibility = GONE + } else { + val icon = getIconDrawable(message.type) + binding.tvMessage.apply { + isSingleLine = true + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + setAppearance(context, messageFileTextAppearance) + } + metaphorTintColor?.let { + binding.ivIcon.setImageDrawable( + DrawableUtils.setTintList( + binding.ivIcon.context, + icon, + metaphorTintColor + ) ) - ) - } ?: binding.ivIcon.setImageDrawable(AppCompatResources.getDrawable(binding.ivIcon.context, icon)) - binding.ivIcon.setImageResource(icon) - binding.ivIcon.visibility = VISIBLE - binding.tvMessage.text = message.name + } ?: binding.ivIcon.setImageDrawable(AppCompatResources.getDrawable(binding.ivIcon.context, icon)) + binding.ivIcon.setImageResource(icon) + binding.ivIcon.visibility = VISIBLE + binding.tvMessage.text = message.name + } } else { binding.tvMessage.apply { isSingleLine = false diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyQuotedMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyQuotedMessageView.kt index f3a20faa..f4f4982e 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyQuotedMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyQuotedMessageView.kt @@ -22,6 +22,7 @@ import com.sendbird.uikit.internal.extensions.hasParentMessage import com.sendbird.uikit.internal.extensions.setAppearance import com.sendbird.uikit.model.TextUIConfig import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.MessageUtils import com.sendbird.uikit.utils.UserUtils import com.sendbird.uikit.utils.ViewUtils import java.util.Locale @@ -127,7 +128,15 @@ internal class MyQuotedMessageView @JvmOverloads constructor( binding.ivQuoteReplyThumbnail.radius = resources.getDimensionPixelSize(R.dimen.sb_size_16).toFloat() binding.tvQuoteReplyMessage.isSingleLine = true binding.tvQuoteReplyMessage.ellipsize = TextUtils.TruncateAt.MIDDLE - if (type.lowercase(Locale.getDefault()).contains(StringSet.gif)) { + + if (MessageUtils.isVoiceMessage(parentMessage)) { + val text = context.getString(R.string.sb_text_voice_message) + binding.quoteReplyMessagePanel.visibility = VISIBLE + binding.tvQuoteReplyMessage.text = textUIConfig?.apply(context, text) ?: text + binding.tvQuoteReplyMessage.isSingleLine = true + binding.tvQuoteReplyMessage.maxLines = 1 + binding.tvQuoteReplyMessage.ellipsize = TextUtils.TruncateAt.END + } else if (type.lowercase(Locale.getDefault()).contains(StringSet.gif)) { binding.quoteReplyThumbnailPanel.visibility = VISIBLE binding.ivQuoteReplyThumbnailIcon.setImageDrawable( DrawableUtils.createOvalIcon( diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyVoiceMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyVoiceMessageView.kt new file mode 100644 index 00000000..46818148 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/MyVoiceMessageView.kt @@ -0,0 +1,142 @@ +package com.sendbird.uikit.internal.ui.messages + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.android.message.FileMessage +import com.sendbird.android.message.SendingStatus +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.consts.MessageGroupType +import com.sendbird.uikit.databinding.SbViewMyVoiceMessageComponentBinding +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.model.MessageListUIParams +import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.ViewUtils + +internal class MyVoiceMessageView @JvmOverloads internal constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.attr.sb_widget_my_voice_message +) : GroupChannelMessageView(context, attrs, defStyle) { + override val binding: SbViewMyVoiceMessageComponentBinding + override val layout: View + get() = binding.root + + private val sentAtAppearance: Int + + init { + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageView_File, defStyle, 0) + try { + binding = SbViewMyVoiceMessageComponentBinding.inflate(LayoutInflater.from(context), this, true) + sentAtAppearance = a.getResourceId( + R.styleable.MessageView_File_sb_message_time_text_appearance, + R.style.SendbirdCaption4OnLight03 + ) + val messageBackground = + a.getResourceId(R.styleable.MessageView_File_sb_message_me_background, R.drawable.sb_shape_chat_bubble) + val messageBackgroundTint = a.getColorStateList(R.styleable.MessageView_File_sb_message_me_background_tint) + val emojiReactionListBackground = a.getResourceId( + R.styleable.MessageView_File_sb_message_emoji_reaction_list_background, + R.drawable.sb_shape_chat_bubble_reactions_light + ) + val progressColor = + a.getResourceId(R.styleable.MessageView_File_sb_voice_message_progress_color, R.color.onlight_03) + val progressTrackColor = + a.getResourceId(R.styleable.MessageView_File_sb_voice_message_progress_track_color, R.color.primary_300) + val timelineTextAppearance = + a.getResourceId( + R.styleable.MessageView_File_sb_voice_message_timeline_text_appearance, + R.style.SendbirdBody3OnDark01 + ) + + binding.tvSentAt.setAppearance(context, sentAtAppearance) + binding.contentPanelWithReactions.background = + DrawableUtils.setTintList(context, messageBackground, messageBackgroundTint) + binding.emojiReactionListBackground.setBackgroundResource(emojiReactionListBackground) + binding.voiceMessage.setProgressCornerRadius(context.resources.getDimension(R.dimen.sb_size_16)) + binding.voiceMessage.setProgressTrackColor( + AppCompatResources.getColorStateList( + context, + progressTrackColor + ) + ) + binding.voiceMessage.setProgressProgressColor(AppCompatResources.getColorStateList(context, progressColor)) + binding.voiceMessage.setTimelineTextAppearance(timelineTextAppearance) + val loadingTint = if (SendbirdUIKit.isDarkMode()) R.color.primary_300 else R.color.primary_200 + val loading = DrawableUtils.setTintList(context, R.drawable.sb_progress, loadingTint) + binding.voiceMessage.setLoadingDrawable(loading) + val buttonBackgroundTint = if (SendbirdUIKit.isDarkMode()) R.color.background_600 else R.color.background_50 + val buttonTint = if (SendbirdUIKit.isDarkMode()) R.color.primary_200 else R.color.primary_300 + val inset = context.resources.getDimension(R.dimen.sb_size_12).toInt() + val playIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_play, + buttonTint, + inset + ) + binding.voiceMessage.setPlayButtonImageDrawable(playIcon) + val pauseIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_pause, + buttonTint, + inset + ) + binding.voiceMessage.setPauseButtonImageDrawable(pauseIcon) + } finally { + a.recycle() + } + } + + override fun drawMessage(channel: GroupChannel, message: BaseMessage, params: MessageListUIParams) { + val fileMessage = message as FileMessage + val isSent = message.sendingStatus == SendingStatus.SUCCEEDED + val hasReaction = message.reactions.isNotEmpty() + val messageGroupType = params.messageGroupType + + binding.emojiReactionListBackground.visibility = if (hasReaction) VISIBLE else GONE + binding.rvEmojiReactionList.visibility = if (hasReaction) VISIBLE else GONE + binding.tvSentAt.visibility = + if (isSent && (messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL || messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE)) VISIBLE else GONE + binding.ivStatus.drawStatus(message, channel, params.shouldUseMessageReceipt()) + + messageUIConfig?.let { + it.mySentAtTextUIConfig.mergeFromTextAppearance(context, sentAtAppearance) + it.myMessageBackground?.let { background -> binding.contentPanel.background = background } + it.myReactionListBackground?.let { reactionListBackground -> + binding.emojiReactionListBackground.background = reactionListBackground + } + } + + ViewUtils.drawSentAt(binding.tvSentAt, message, messageUIConfig) + ViewUtils.drawReactionEnabled(binding.rvEmojiReactionList, channel) + + val paddingTop = + resources.getDimensionPixelSize(if (messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL || messageGroupType == MessageGroupType.GROUPING_TYPE_BODY) R.dimen.sb_size_1 else R.dimen.sb_size_8) + val paddingBottom = + resources.getDimensionPixelSize(if (messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD || messageGroupType == MessageGroupType.GROUPING_TYPE_BODY) R.dimen.sb_size_1 else R.dimen.sb_size_8) + binding.root.setPadding(binding.root.paddingLeft, paddingTop, binding.root.paddingRight, paddingBottom) + if (params.shouldUseQuotedView()) { + ViewUtils.drawQuotedMessage( + binding.quoteReplyPanel, + channel, + message, + messageUIConfig?.repliedMessageTextUIConfig + ) + } else { + binding.quoteReplyPanel.visibility = GONE + } + ViewUtils.drawThreadInfo(binding.threadInfo, message) + ViewUtils.drawVoiceMessage(binding.voiceMessage, fileMessage) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherFileMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherFileMessageView.kt index 80fef375..26056273 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherFileMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherFileMessageView.kt @@ -37,7 +37,7 @@ internal class OtherFileMessageView @JvmOverloads constructor( messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL val showNickname = (messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD) && - (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) + (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) binding.ivProfileView.visibility = if (showProfile) VISIBLE else INVISIBLE binding.tvNickname.visibility = if (showNickname) VISIBLE else GONE diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherImageFileMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherImageFileMessageView.kt index c9a66e50..68e21be9 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherImageFileMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherImageFileMessageView.kt @@ -36,7 +36,7 @@ internal class OtherImageFileMessageView @JvmOverloads constructor( messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL val showNickname = (messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD) && - (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) + (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) binding.ivProfileView.visibility = if (showProfile) VISIBLE else INVISIBLE binding.tvNickname.visibility = if (showNickname) VISIBLE else GONE diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherQuotedMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherQuotedMessageView.kt index eaf21cc5..fb83b386 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherQuotedMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherQuotedMessageView.kt @@ -23,6 +23,7 @@ import com.sendbird.uikit.internal.extensions.hasParentMessage import com.sendbird.uikit.internal.extensions.setAppearance import com.sendbird.uikit.model.TextUIConfig import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.MessageUtils import com.sendbird.uikit.utils.UserUtils import com.sendbird.uikit.utils.ViewUtils import java.util.Locale @@ -148,7 +149,15 @@ internal class OtherQuotedMessageView @JvmOverloads constructor( binding.ivQuoteReplyThumbnail.radius = resources.getDimensionPixelSize(R.dimen.sb_size_16).toFloat() binding.tvQuoteReplyMessage.isSingleLine = true binding.tvQuoteReplyMessage.ellipsize = TextUtils.TruncateAt.MIDDLE - if (type.lowercase(Locale.getDefault()).contains(StringSet.gif)) { + + if (MessageUtils.isVoiceMessage(parentMessage)) { + val text = context.getString(R.string.sb_text_voice_message) + binding.quoteReplyMessagePanel.visibility = VISIBLE + binding.tvQuoteReplyMessage.text = textUIConfig?.apply(context, text) ?: text + binding.tvQuoteReplyMessage.isSingleLine = true + binding.tvQuoteReplyMessage.maxLines = 1 + binding.tvQuoteReplyMessage.ellipsize = TextUtils.TruncateAt.END + } else if (type.lowercase(Locale.getDefault()).contains(StringSet.gif)) { binding.quoteReplyThumbnailPanel.visibility = VISIBLE binding.ivQuoteReplyThumbnailIcon.setImageDrawable( DrawableUtils.createOvalIcon( diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherUserMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherUserMessageView.kt index 3260390e..2f914320 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherUserMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherUserMessageView.kt @@ -122,7 +122,7 @@ internal class OtherUserMessageView @JvmOverloads internal constructor( messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL val showNickname = (messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD) && - (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) + (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) binding.ivProfileView.visibility = if (showProfile) VISIBLE else INVISIBLE binding.tvNickname.visibility = if (showNickname) VISIBLE else GONE diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVideoFileMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVideoFileMessageView.kt index 05f1525c..bed6d61d 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVideoFileMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVideoFileMessageView.kt @@ -36,7 +36,7 @@ internal class OtherVideoFileMessageView @JvmOverloads constructor( messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL val showNickname = (messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD) && - (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) + (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) binding.ivProfileView.visibility = if (showProfile) VISIBLE else INVISIBLE binding.tvNickname.visibility = if (showNickname) VISIBLE else GONE diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVoiceMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVoiceMessageView.kt new file mode 100644 index 00000000..e019cf1d --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/OtherVoiceMessageView.kt @@ -0,0 +1,150 @@ +package com.sendbird.uikit.internal.ui.messages + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.appcompat.content.res.AppCompatResources +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.android.message.FileMessage +import com.sendbird.android.message.SendingStatus +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.consts.MessageGroupType +import com.sendbird.uikit.databinding.SbViewOtherVoiceMessageComponentBinding +import com.sendbird.uikit.model.MessageListUIParams +import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.MessageUtils +import com.sendbird.uikit.utils.ViewUtils + +internal class OtherVoiceMessageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.attr.sb_widget_other_voice_message +) : GroupChannelMessageView(context, attrs, defStyle) { + override val binding: SbViewOtherVoiceMessageComponentBinding + override val layout: OtherVoiceMessageView + get() = this + private val sentAtAppearance: Int + private val nicknameAppearance: Int + + override fun drawMessage(channel: GroupChannel, message: BaseMessage, params: MessageListUIParams) { + val messageGroupType = params.messageGroupType + val fileMessage = message as FileMessage + val isSent = message.sendingStatus == SendingStatus.SUCCEEDED + val hasReaction = message.reactions.isNotEmpty() + val showProfile = + messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL + val showNickname = + (messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE || messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD) && + (!params.shouldUseQuotedView() || !MessageUtils.hasParentMessage(message)) + + binding.ivProfileView.visibility = if (showProfile) VISIBLE else INVISIBLE + binding.tvNickname.visibility = if (showNickname) VISIBLE else GONE + binding.emojiReactionListBackground.visibility = if (hasReaction) VISIBLE else GONE + binding.rvEmojiReactionList.visibility = if (hasReaction) VISIBLE else GONE + binding.tvSentAt.visibility = + if (isSent && (messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL || messageGroupType == MessageGroupType.GROUPING_TYPE_SINGLE)) VISIBLE else INVISIBLE + messageUIConfig?.let { + it.otherSentAtTextUIConfig.mergeFromTextAppearance(context, sentAtAppearance) + it.otherNicknameTextUIConfig.mergeFromTextAppearance(context, nicknameAppearance) + val background = it.otherMessageBackground + val reactionBackground = it.otherReactionListBackground + if (background != null) binding.contentPanel.background = background + if (reactionBackground != null) binding.emojiReactionListBackground.background = reactionBackground + } + ViewUtils.drawNickname(binding.tvNickname, message, messageUIConfig, false) + ViewUtils.drawReactionEnabled(binding.rvEmojiReactionList, channel) + ViewUtils.drawProfile(binding.ivProfileView, message) + ViewUtils.drawSentAt(binding.tvSentAt, message, messageUIConfig) + val paddingTop = + resources.getDimensionPixelSize(if (messageGroupType == MessageGroupType.GROUPING_TYPE_TAIL || messageGroupType == MessageGroupType.GROUPING_TYPE_BODY) R.dimen.sb_size_1 else R.dimen.sb_size_8) + val paddingBottom = + resources.getDimensionPixelSize(if (messageGroupType == MessageGroupType.GROUPING_TYPE_HEAD || messageGroupType == MessageGroupType.GROUPING_TYPE_BODY) R.dimen.sb_size_1 else R.dimen.sb_size_8) + binding.root.setPadding(binding.root.paddingLeft, paddingTop, binding.root.paddingRight, paddingBottom) + if (params.shouldUseQuotedView()) { + ViewUtils.drawQuotedMessage( + binding.quoteReplyPanel, + channel, + message, + messageUIConfig?.repliedMessageTextUIConfig + ) + } else { + binding.quoteReplyPanel.visibility = GONE + } + ViewUtils.drawThreadInfo(binding.threadInfo, message) + ViewUtils.drawVoiceMessage(binding.voiceMessage, fileMessage) + } + + init { + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageView_File, defStyle, 0) + try { + binding = SbViewOtherVoiceMessageComponentBinding.inflate(LayoutInflater.from(getContext()), this, true) + sentAtAppearance = a.getResourceId( + R.styleable.MessageView_File_sb_message_time_text_appearance, + R.style.SendbirdCaption4OnLight03 + ) + nicknameAppearance = a.getResourceId( + R.styleable.MessageView_File_sb_message_sender_name_text_appearance, + R.style.SendbirdCaption1OnLight02 + ) + val messageBackground = a.getResourceId( + R.styleable.MessageView_File_sb_message_other_background, + R.drawable.sb_shape_chat_bubble + ) + val messageBackgroundTint = + a.getColorStateList(R.styleable.MessageView_File_sb_message_other_background_tint) + val emojiReactionListBackground = a.getResourceId( + R.styleable.MessageView_File_sb_message_emoji_reaction_list_background, + R.drawable.sb_shape_chat_bubble_reactions_light + ) + val progressColor = + a.getResourceId(R.styleable.MessageView_File_sb_voice_message_progress_color, R.color.ondark_03) + val progressTrackColor = + a.getResourceId(R.styleable.MessageView_File_sb_voice_message_progress_track_color, R.color.background_100) + val timelineTextAppearance = + a.getResourceId( + R.styleable.MessageView_File_sb_voice_message_timeline_text_appearance, + R.style.SendbirdBody3OnLight01 + ) + + binding.contentPanelWithReactions.background = + DrawableUtils.setTintList(context, messageBackground, messageBackgroundTint) + binding.emojiReactionListBackground.setBackgroundResource(emojiReactionListBackground) + binding.voiceMessage.setProgressCornerRadius(context.resources.getDimension(R.dimen.sb_size_16)) + binding.voiceMessage.setProgressTrackColor( + AppCompatResources.getColorStateList( + context, + progressTrackColor + ) + ) + binding.voiceMessage.setProgressProgressColor(AppCompatResources.getColorStateList(context, progressColor)) + binding.voiceMessage.setTimelineTextAppearance(timelineTextAppearance) + val buttonBackgroundTint = if (SendbirdUIKit.isDarkMode()) R.color.background_600 else R.color.background_50 + val buttonTint = if (SendbirdUIKit.isDarkMode()) R.color.primary_200 else R.color.primary_300 + val inset = context.resources.getDimension(R.dimen.sb_size_12).toInt() + val playIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_play, + buttonTint, + inset + ) + binding.voiceMessage.setPlayButtonImageDrawable(playIcon) + val pauseIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_pause, + buttonTint, + inset + ) + binding.voiceMessage.setPauseButtonImageDrawable(pauseIcon) + } finally { + a.recycle() + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ParentMessageInfoView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ParentMessageInfoView.kt index 391f85ba..d9983451 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ParentMessageInfoView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ParentMessageInfoView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import com.sendbird.android.channel.GroupChannel import com.sendbird.android.message.BaseMessage @@ -16,8 +17,8 @@ import com.sendbird.uikit.databinding.SbViewParentMessageInfoBinding import com.sendbird.uikit.internal.extensions.setAppearance import com.sendbird.uikit.model.MessageListUIParams import com.sendbird.uikit.model.MessageUIConfig -import com.sendbird.uikit.utils.Available import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.MessageUtils import com.sendbird.uikit.utils.ReactionUtils import com.sendbird.uikit.utils.ViewUtils import java.util.Locale @@ -60,7 +61,9 @@ internal class ParentMessageInfoView @JvmOverloads constructor( is UserMessage -> drawUserMessage(message) is FileMessage -> { val mimeType: String = message.type.lowercase(Locale.getDefault()) - if (mimeType.startsWith(StringSet.image)) { + if (MessageUtils.isVoiceMessage(message)) { + drawVoiceMessage(channel, message) + } else if (mimeType.startsWith(StringSet.image)) { if (mimeType.contains(StringSet.svg)) { drawFileMessage(message) } else { @@ -83,13 +86,26 @@ internal class ParentMessageInfoView @JvmOverloads constructor( binding.tvTextMessage.visibility = VISIBLE binding.fileGroup.visibility = GONE binding.imageGroup.visibility = GONE + binding.voiceMessage.visibility = GONE ViewUtils.drawTextMessage(binding.tvTextMessage, message, parentMessageInfoUIConfig, null) } + private fun drawVoiceMessage(channel: GroupChannel, message: FileMessage) { + binding.tvTextMessage.visibility = GONE + binding.fileGroup.visibility = GONE + binding.imageGroup.visibility = GONE + binding.voiceMessage.visibility = VISIBLE + binding.voiceMessage.setOnClickListener { + binding.voiceMessage.callOnPlayerButtonClick() + } + ViewUtils.drawVoiceMessage(binding.voiceMessage, message) + } + private fun drawFileMessage(message: FileMessage) { binding.tvTextMessage.visibility = GONE binding.fileGroup.visibility = VISIBLE binding.imageGroup.visibility = GONE + binding.voiceMessage.visibility = GONE ViewUtils.drawFilename(binding.tvFileName, message, parentMessageInfoUIConfig) ViewUtils.drawFileIcon(binding.ivFileIcon, message) } @@ -98,6 +114,7 @@ internal class ParentMessageInfoView @JvmOverloads constructor( binding.tvTextMessage.visibility = GONE binding.fileGroup.visibility = GONE binding.imageGroup.visibility = VISIBLE + binding.voiceMessage.visibility = GONE ViewUtils.drawThumbnail(binding.ivThumbnail, message) ViewUtils.drawThumbnailIcon(binding.ivThumbnailIcon, message) } @@ -146,6 +163,21 @@ internal class ParentMessageInfoView @JvmOverloads constructor( R.styleable.ParentMessageInfoView_sb_parent_message_info_divider_line_color, R.color.onlight_04 ) + val progressColor = + a.getResourceId( + R.styleable.ParentMessageInfoView_sb_parent_message_info_voice_message_progress_color, + R.color.ondark_03 + ) + val progressTrackColor = + a.getResourceId( + R.styleable.ParentMessageInfoView_sb_parent_message_info_voice_message_progress_track_color, + R.color.background_100 + ) + val timelineTextAppearance = + a.getResourceId( + R.styleable.ParentMessageInfoView_sb_parent_message_info_voice_message_timeline_text_appearance, + R.style.SendbirdBody3OnLight01 + ) binding.tvNickname.setAppearance(context, nicknameAppearance) binding.tvSentAt.setAppearance(context, sentAtAppearance) @@ -187,6 +219,38 @@ internal class ParentMessageInfoView @JvmOverloads constructor( R.color.ondark_02 ) else ContextCompat.getColor(context, R.color.onlight_02) ) + binding.voiceMessage.setProgressCornerRadius(context.resources.getDimension(R.dimen.sb_size_16)) + binding.voiceMessage.setProgressTrackColor( + AppCompatResources.getColorStateList( + context, + progressTrackColor + ) + ) + binding.voiceMessage.setProgressProgressColor(AppCompatResources.getColorStateList(context, progressColor)) + binding.voiceMessage.setTimelineTextAppearance(timelineTextAppearance) + val buttonBackgroundTint = if (SendbirdUIKit.isDarkMode()) R.color.background_600 else R.color.background_50 + val buttonTint = if (SendbirdUIKit.isDarkMode()) R.color.primary_200 else R.color.primary_300 + val inset = context.resources.getDimension(R.dimen.sb_size_12).toInt() + val playIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_play, + buttonTint, + inset + ) + binding.voiceMessage.setPlayButtonImageDrawable(playIcon) + val pauseIcon = + DrawableUtils.createOvalIcon( + context, + buttonBackgroundTint, + 224, + R.drawable.icon_pause, + buttonTint, + inset + ) + binding.voiceMessage.setPauseButtonImageDrawable(pauseIcon) } finally { a.recycle() } diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ThreadInfoView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ThreadInfoView.kt index 75b5da53..7088d96c 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ThreadInfoView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ThreadInfoView.kt @@ -29,7 +29,6 @@ import java.nio.charset.Charset import java.security.MessageDigest import kotlin.math.min - internal class ThreadInfoView @JvmOverloads internal constructor( context: Context, attrs: AttributeSet? = null, @@ -153,7 +152,6 @@ internal class ThreadInfoView @JvmOverloads internal constructor( return DrawableUtils.toBitmap(layerDrawable) ?: toTransform } - override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(ID_BYTES) diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt new file mode 100644 index 00000000..f62ca7bf --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt @@ -0,0 +1,178 @@ +package com.sendbird.uikit.internal.ui.messages + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import com.sendbird.android.message.FileMessage +import com.sendbird.android.message.SendingStatus +import com.sendbird.uikit.R +import com.sendbird.uikit.databinding.SbViewVoiceMessageBinding +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.model.VoicePlayer +import com.sendbird.uikit.internal.model.VoicePlayerManager +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.MessageUtils +import com.sendbird.uikit.utils.ViewUtils + +internal class VoiceMessageView @JvmOverloads internal constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.attr.sb_widget_my_voice_message +) : FrameLayout(context, attrs, defStyle) { + private val binding: SbViewVoiceMessageBinding + private val onUpdateListener: VoicePlayer.OnUpdateListener + private val onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener + private var key: String? = null + private var duration: Int = 0 + + init { + Logger.i("_________init() this=$this") + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageView_File, defStyle, 0) + try { + binding = SbViewVoiceMessageBinding.inflate(LayoutInflater.from(context), this, true) + onUpdateListener = object : VoicePlayer.OnUpdateListener { + override fun onUpdated(key: String, status: VoicePlayer.Status) { + if (this@VoiceMessageView.key == key) drawPlayerStatus(status) + } + } + onProgressUpdateListener = object : VoicePlayer.OnProgressUpdateListener { + override fun onProgressUpdated( + key: String, + status: VoicePlayer.Status, + milliseconds: Int, + duration: Int + ) { + Logger.i("VoiceMessageView >> OnProgressUpdateListener status : $status, millis : $milliseconds") + if (this@VoiceMessageView.key != key) return + if (duration == 0) return + ViewUtils.drawTimeline( + binding.timelineView, + if (status == VoicePlayer.Status.STOPPED) duration else duration - milliseconds + ) + ViewUtils.drawVoicePlayerProgress(binding.voiceProgressView, milliseconds, duration) + } + } + } finally { + a.recycle() + } + } + + fun setLoadingDrawable(drawable: Drawable?) { + binding.loading.indeterminateDrawable = drawable + } + + fun setProgressCornerRadius(radius: Float) { + binding.voiceProgressView.cornerRadius = radius + } + + fun setProgressTrackColor(trackColor: ColorStateList?) { + binding.voiceProgressView.trackColor = trackColor + } + + fun setProgressProgressColor(progressColor: ColorStateList?) { + binding.voiceProgressView.progressColor = progressColor + } + + fun setTimelineTextAppearance(textAppearance: Int) { + binding.timelineView.setAppearance(context, textAppearance) + } + + fun setPlayButtonImageDrawable(drawable: Drawable?) { + binding.ibtnPlay.setImageDrawable(drawable) + } + + fun setPauseButtonImageDrawable(drawable: Drawable?) { + binding.ibtnPause.setImageDrawable(drawable) + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + Logger.i("_________VoiceMessageView::onVisibilityChanged()") + super.onVisibilityChanged(changedView, visibility) + if (visibility != VISIBLE) VoicePlayerManager.pause() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + Logger.i("_________VoiceMessageView::onAttachedToWindow()") + key?.let { + drawVoiceMessage(it) + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + Logger.i("_________VoiceMessageView::onDetachedFromWindow()") + key?.let { + VoicePlayerManager.removeOnUpdateListener(it, onUpdateListener) + VoicePlayerManager.removeOnProgressListener(it, onProgressUpdateListener) + } + } + + fun drawVoiceMessage(fileMessage: FileMessage) { + Logger.i("_________VoiceMessageView::drawVoiceMessage()") + val key = MessageUtils.getVoiceMessageKey(fileMessage) + this@VoiceMessageView.key = key + duration = MessageUtils.extractDuration(fileMessage) + binding.ibtnPlay.setOnClickListener { + VoicePlayerManager.play(context, key, fileMessage, onUpdateListener, onProgressUpdateListener) + } + binding.ibtnPause.setOnClickListener { + VoicePlayerManager.pause() + } + drawVoiceMessage(key) + } + + private fun drawVoiceMessage(key: String) { + if (VoicePlayerManager.getCurrentKey() == key) { + VoicePlayerManager.addOnUpdateListener(key, onUpdateListener) + VoicePlayerManager.addOnProgressUpdateListener(key, onProgressUpdateListener) + } + val seekTo = VoicePlayerManager.getSeekTo(key) + Logger.i("VoiceMessageView::drawMessage key : $key, seekTo : $seekTo, duration : $duration") + drawPlayerStatus(VoicePlayerManager.getStatus(key) ?: VoicePlayer.Status.STOPPED) + ViewUtils.drawTimeline( + binding.timelineView, + if (seekTo == 0) duration else duration - seekTo + ) + val progress = if (duration != 0) seekTo * 1000 / duration else 0 + binding.voiceProgressView.drawProgress(progress) + } + + private fun drawPlayerStatus(status: VoicePlayer.Status) { + Logger.i("_________VoiceMessageView::drawPlayerStatus, status : $status") + when (status) { + VoicePlayer.Status.STOPPED -> { + binding.ibtnPlay.visibility = VISIBLE + binding.loading.visibility = GONE + binding.ibtnPause.visibility = GONE + } + VoicePlayer.Status.PREPARING -> { + binding.ibtnPlay.visibility = GONE + binding.loading.visibility = VISIBLE + binding.ibtnPause.visibility = GONE + } + VoicePlayer.Status.PLAYING -> { + binding.ibtnPlay.visibility = GONE + binding.loading.visibility = GONE + binding.ibtnPause.visibility = VISIBLE + } + VoicePlayer.Status.PAUSED -> { + binding.ibtnPlay.visibility = VISIBLE + binding.loading.visibility = GONE + binding.ibtnPause.visibility = GONE + } + } + } + + fun callOnPlayerButtonClick() { + if (binding.ibtnPlay.visibility == VISIBLE) { + binding.ibtnPlay.callOnClick() + } else if (binding.ibtnPause.visibility == VISIBLE) { + binding.ibtnPause.callOnClick() + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/MyVoiceMessageViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/MyVoiceMessageViewHolder.kt new file mode 100644 index 00000000..8888fb4b --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/MyVoiceMessageViewHolder.kt @@ -0,0 +1,48 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import android.view.View +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.android.message.Reaction +import com.sendbird.uikit.activities.viewholder.GroupChannelMessageViewHolder +import com.sendbird.uikit.consts.ClickableViewIdentifier +import com.sendbird.uikit.databinding.SbViewMyVoiceMessageBinding +import com.sendbird.uikit.interfaces.OnItemClickListener +import com.sendbird.uikit.interfaces.OnItemLongClickListener +import com.sendbird.uikit.model.MessageListUIParams + +internal class MyVoiceMessageViewHolder internal constructor( + val binding: SbViewMyVoiceMessageBinding, + messageListUIParams: MessageListUIParams +) : GroupChannelMessageViewHolder(binding.root, messageListUIParams) { + + override fun bind(channel: BaseChannel, message: BaseMessage, messageListUIParams: MessageListUIParams) { + binding.myVoiceMessageView.messageUIConfig = messageUIConfig + if (channel is GroupChannel) { + binding.myVoiceMessageView.drawMessage(channel, message, messageListUIParams) + } + } + + override fun setEmojiReaction( + reactionList: List, + emojiReactionClickListener: OnItemClickListener?, + emojiReactionLongClickListener: OnItemLongClickListener?, + moreButtonClickListener: View.OnClickListener? + ) { + binding.myVoiceMessageView.binding.rvEmojiReactionList.apply { + setReactionList(reactionList) + setEmojiReactionClickListener(emojiReactionClickListener) + setEmojiReactionLongClickListener(emojiReactionLongClickListener) + setMoreButtonClickListener(moreButtonClickListener) + } + } + + override fun getClickableViewMap(): Map { + return mapOf( + ClickableViewIdentifier.Chat.name to binding.myVoiceMessageView.binding.voiceMessage, + ClickableViewIdentifier.QuoteReply.name to binding.myVoiceMessageView.binding.quoteReplyPanel, + ClickableViewIdentifier.ThreadInfo.name to binding.myVoiceMessageView.binding.threadInfo + ) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/OtherVoiceMessageViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/OtherVoiceMessageViewHolder.kt new file mode 100644 index 00000000..224284ff --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/OtherVoiceMessageViewHolder.kt @@ -0,0 +1,49 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import android.view.View +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.android.message.Reaction +import com.sendbird.uikit.activities.viewholder.GroupChannelMessageViewHolder +import com.sendbird.uikit.consts.ClickableViewIdentifier +import com.sendbird.uikit.databinding.SbViewOtherVoiceMessageBinding +import com.sendbird.uikit.interfaces.OnItemClickListener +import com.sendbird.uikit.interfaces.OnItemLongClickListener +import com.sendbird.uikit.model.MessageListUIParams + +internal class OtherVoiceMessageViewHolder internal constructor( + val binding: SbViewOtherVoiceMessageBinding, + messageListUIParams: MessageListUIParams +) : GroupChannelMessageViewHolder(binding.root, messageListUIParams) { + + override fun bind(channel: BaseChannel, message: BaseMessage, messageListUIParams: MessageListUIParams) { + binding.otherVoiceMessageView.messageUIConfig = messageUIConfig + if (channel is GroupChannel) { + binding.otherVoiceMessageView.drawMessage(channel, message, messageListUIParams) + } + } + + override fun setEmojiReaction( + reactionList: List, + emojiReactionClickListener: OnItemClickListener?, + emojiReactionLongClickListener: OnItemLongClickListener?, + moreButtonClickListener: View.OnClickListener? + ) { + binding.otherVoiceMessageView.binding.rvEmojiReactionList.apply { + setReactionList(reactionList) + setEmojiReactionClickListener(emojiReactionClickListener) + setEmojiReactionLongClickListener(emojiReactionLongClickListener) + setMoreButtonClickListener(moreButtonClickListener) + } + } + + override fun getClickableViewMap(): Map { + return mapOf( + ClickableViewIdentifier.Chat.name to binding.otherVoiceMessageView.binding.voiceMessage, + ClickableViewIdentifier.Profile.name to binding.otherVoiceMessageView.binding.ivProfileView, + ClickableViewIdentifier.QuoteReply.name to binding.otherVoiceMessageView.binding.quoteReplyPanel, + ClickableViewIdentifier.ThreadInfo.name to binding.otherVoiceMessageView.binding.threadInfo + ) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageInputDialogWrapper.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageInputDialogWrapper.kt index 7085de07..618a02cf 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageInputDialogWrapper.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageInputDialogWrapper.kt @@ -16,7 +16,6 @@ import com.sendbird.uikit.log.Logger import com.sendbird.uikit.utils.SoftInputUtils import com.sendbird.uikit.widgets.MessageInputView - internal class MessageInputDialogWrapper @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -105,7 +104,8 @@ internal class MessageInputDialogWrapper @JvmOverloads constructor( private fun attachInputViewToDialog(messageInputView: MessageInputView) { dialogCustomView.addView( - messageInputView, ViewGroup.LayoutParams( + messageInputView, + ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) @@ -115,7 +115,8 @@ internal class MessageInputDialogWrapper @JvmOverloads constructor( private fun attachInputViewToDisplayView(messageInputView: MessageInputView) { binding.contentView.addView( - messageInputView, ViewGroup.LayoutParams( + messageInputView, + ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceMessageInputView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceMessageInputView.kt new file mode 100644 index 00000000..759a13bd --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceMessageInputView.kt @@ -0,0 +1,436 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.consts.StringSet +import com.sendbird.uikit.databinding.SbViewVoiceMessageInputBinding +import com.sendbird.uikit.interfaces.OnItemClickListener +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.model.VoicePlayer +import com.sendbird.uikit.internal.model.VoicePlayerManager +import com.sendbird.uikit.internal.model.VoiceRecorder +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.model.VoiceMessageInfo +import com.sendbird.uikit.utils.ClearableScheduledExecutorService +import com.sendbird.uikit.utils.DrawableUtils +import com.sendbird.uikit.utils.ViewUtils +import java.io.File +import java.util.concurrent.TimeUnit + +internal class VoiceMessageInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.sb_widget_voice_message_input_view +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binding: SbViewVoiceMessageInputBinding + + private val recorder: VoiceRecorder + private val recordingIconExecutor by lazy { ClearableScheduledExecutorService() } + private val onRecorderUpdateListener: VoiceRecorder.OnUpdateListener + private val onRecorderProgressUpdateListener: VoiceRecorder.OnProgressUpdateListener + private val onUpdateListener: VoicePlayer.OnUpdateListener + private val onProgressUpdateListener: VoicePlayer.OnProgressUpdateListener + + var onCancelButtonClickListener: OnClickListener? = null + var onSendButtonClickListener: OnItemClickListener? = null + + init { + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.VoiceMessageInputView, defStyleAttr, 0) + try { + binding = SbViewVoiceMessageInputBinding.inflate(LayoutInflater.from(getContext()), this, true) + + val background = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_background, + android.R.color.transparent + ) + val backgroundColor = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_background_color, + android.R.color.transparent + ) + val progressColor = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_progress_color, + android.R.color.transparent + ) + val progressBackgroundColor = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_progress_track_color, + android.R.color.transparent + ) + val timelineTextAppearance = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_timeline_text_appearance, + R.style.SendbirdCaption1OnDark01 + ) + val timelineTextColor = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_timeline_text_color, + android.R.color.transparent + ) + val recordButtonIcon = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_record_button_icon, + R.drawable.icon_recording + ) + val recordButtonTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_record_button_tint, + R.color.error_300 + ) + val recordButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_record_button_background, + android.R.color.transparent + ) + val recordButtonBackgroundTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_record_button_background_tint, + android.R.color.transparent + ) + val playButtonIcon = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_play_button_icon, + R.drawable.icon_play + ) + val playButtonTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_play_button_tint, + R.color.onlight_01 + ) + val playButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_play_button_background, + android.R.color.transparent + ) + val playButtonBackgroundTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_play_button_background_tint, + android.R.color.transparent + ) + val pauseButtonIcon = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_pause_button_icon, + R.drawable.icon_pause + ) + val pauseButtonTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_pause_button_tint, + R.color.onlight_01 + ) + val pauseButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_pause_button_background, + android.R.color.transparent + ) + val pauseButtonBackgroundTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_pause_button_background_tint, + android.R.color.transparent + ) + val stopButtonIcon = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_stop_button_icon, + R.drawable.icon_stop + ) + val stopButtonTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_stop_button_tint, + R.color.onlight_01 + ) + val stopButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_stop_button_background, + android.R.color.transparent + ) + val stopButtonBackgroundTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_stop_button_background_tint, + android.R.color.transparent + ) + val sendButtonIcon = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_send_button_icon, + R.drawable.icon_send + ) + val sendButtonTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_send_button_tint, + R.color.primary_300 + ) + val sendButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_send_button_background, + android.R.color.transparent + ) + val sendButtonBackgroundTint = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_send_button_background_tint, + android.R.color.transparent + ) + val cancelButtonTextAppearance = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_cancel_button_text_appearance, + R.style.SendbirdButtonPrimary300 + ) + val cancelButtonTextColor = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_cancel_button_text_color, + R.color.sb_button_uncontained_text_color_cancel_light + ) + val cancelButtonBackground = a.getResourceId( + R.styleable.VoiceMessageInputView_sb_voice_message_input_cancel_button_background, + android.R.color.transparent + ) + + binding.root.setBackgroundResource(background) + binding.root.setBackgroundColor(ContextCompat.getColor(context, backgroundColor)) + binding.progress.isEnabled = false + binding.progress.progressColor = AppCompatResources.getColorStateList(context, progressColor) + binding.progress.trackColor = AppCompatResources.getColorStateList(context, progressBackgroundColor) + binding.tvTimeline.setAppearance(context, timelineTextAppearance) + binding.tvTimeline.setTextColor(AppCompatResources.getColorStateList(context, timelineTextColor)) + binding.ibtnSend.background = + DrawableUtils.setTintList( + context, + sendButtonBackground, + sendButtonBackgroundTint + ) + binding.ibtnSend.setImageDrawable(DrawableUtils.setTintList(context, sendButtonIcon, sendButtonTint)) + binding.btnCancel.setBackgroundResource(cancelButtonBackground) + binding.btnCancel.setAppearance(context, cancelButtonTextAppearance) + binding.btnCancel.setTextColor(AppCompatResources.getColorStateList(context, cancelButtonTextColor)) + binding.recordingIcon.imageTintList = AppCompatResources.getColorStateList( + context, + if (SendbirdUIKit.isDarkMode()) R.color.error_200 else R.color.error_300 + ) + binding.ibtnRecord.background = + DrawableUtils.setTintList( + context, + recordButtonBackground, + recordButtonBackgroundTint + ) + binding.ibtnRecord.setImageDrawable( + DrawableUtils.setTintList( + context, + recordButtonIcon, + recordButtonTint + ) + ) + binding.ibtnPlay.background = + DrawableUtils.setTintList( + context, + playButtonBackground, + playButtonBackgroundTint + ) + binding.ibtnPlay.setImageDrawable( + DrawableUtils.setTintList( + context, + playButtonIcon, + playButtonTint + ) + ) + binding.ibtnStop.background = + DrawableUtils.setTintList( + context, + stopButtonBackground, + stopButtonBackgroundTint + ) + binding.ibtnStop.setImageDrawable( + DrawableUtils.setTintList( + context, + stopButtonIcon, + stopButtonTint + ) + ) + binding.ibtnPause.background = + DrawableUtils.setTintList( + context, + pauseButtonBackground, + pauseButtonBackgroundTint + ) + binding.ibtnPause.setImageDrawable( + DrawableUtils.setTintList( + context, + pauseButtonIcon, + pauseButtonTint + ) + ) + + ViewUtils.drawTimeline(binding.tvTimeline, 0) + drawIdle() + onRecorderUpdateListener = object : VoiceRecorder.OnUpdateListener { + override fun onUpdated(status: VoiceRecorder.Status) { + drawRecordingStatus(status) + } + } + onRecorderProgressUpdateListener = object : VoiceRecorder.OnProgressUpdateListener { + override fun onProgressUpdated(status: VoiceRecorder.Status, milliseconds: Int, amplitude: Int) { + if (status == VoiceRecorder.Status.COMPLETED) return + if (milliseconds >= 1000) { + binding.ibtnSend.isEnabled = true + } + ViewUtils.drawTimeline(binding.tvTimeline, milliseconds) + ViewUtils.drawVoicePlayerProgress(binding.progress, milliseconds, VoiceRecorder.maxDurationMillis) + } + } + recorder = VoiceRecorder(context, onRecorderUpdateListener, onRecorderProgressUpdateListener) + onUpdateListener = object : VoicePlayer.OnUpdateListener { + override fun onUpdated(key: String, status: VoicePlayer.Status) { + Logger.i("VoiceMessageRecorderView >> onUpdateListener, status: $status") + drawPlayerStatus(status) + } + } + onProgressUpdateListener = object : VoicePlayer.OnProgressUpdateListener { + override fun onProgressUpdated(key: String, status: VoicePlayer.Status, milliseconds: Int, duration: Int) { + Logger.i("VoiceMessageRecorderView >> onProgressUpdateListener, milliseconds: $milliseconds") + if (duration == 0) return + ViewUtils.drawTimeline( + binding.tvTimeline, + if (status == VoicePlayer.Status.STOPPED) duration else duration - milliseconds + ) + ViewUtils.drawVoicePlayerProgress(binding.progress, milliseconds, duration) + } + } + initControlButton() + binding.btnCancel.setOnClickListener { view -> + Logger.i("Cancel button is clicked") + shutdownRecordingIconExecutor() + VoicePlayerManager.dispose(recorder.recordFilePath) + recorder.cancel() + onCancelButtonClickListener?.onClick(view) + } + binding.ibtnSend.setOnClickListener { view -> + Logger.i("Send button is clicked") + shutdownRecordingIconExecutor() + VoicePlayerManager.dispose(recorder.recordFilePath) + recorder.complete() + val voiceMimeType = StringSet.audio + "/" + StringSet.m4a + ";" + StringSet.sbu_type + "=" + StringSet.voice + val duration = recorder.seekTo + Logger.i("VoiceMessageRecorderView: mimeType : $voiceMimeType, duration : $duration") + onSendButtonClickListener?.onItemClick( + view, + 0, + VoiceMessageInfo( + recorder.recordFilePath, + voiceMimeType, + duration + ) + ) + } + } finally { + a.recycle() + } + } + + private fun initControlButton() { + binding.ibtnRecord.setOnClickListener { + VoicePlayerManager.pause() + recorder.record() + } + binding.ibtnPlay.setOnClickListener { + VoicePlayerManager.play( + context, + recorder.recordFilePath, + File(recorder.recordFilePath), + recorder.seekTo, + onUpdateListener, + onProgressUpdateListener + ) + } + binding.ibtnPause.setOnClickListener { + VoicePlayerManager.pause() + } + binding.ibtnStop.setOnClickListener { + if (binding.ibtnSend.isEnabled) { + recorder.complete() + } else { + recorder.cancel(true) + } + } + } + + override fun onWindowFocusChanged(hasWindowFocus: Boolean) { + super.onWindowFocusChanged(hasWindowFocus) + if (!hasWindowFocus) { + pauseVoiceMessageInput() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + Logger.i("_________VoiceMessageView::onDetachedFromWindow()") + VoicePlayerManager.removeOnUpdateListener(recorder.recordFilePath, onUpdateListener) + VoicePlayerManager.removeOnProgressListener(recorder.recordFilePath, onProgressUpdateListener) + } + + private fun pauseVoiceMessageInput() { + if (binding.ibtnPause.visibility == VISIBLE) { + binding.ibtnPause.callOnClick() + } else if (binding.ibtnStop.visibility == VISIBLE) { + if (binding.ibtnSend.isEnabled) { + recorder.complete() + } else { + recorder.cancel(true) + } + } + } + + private fun drawRecordingStatus(status: VoiceRecorder.Status) { + when (status) { + VoiceRecorder.Status.IDLE -> { + drawIdle() + } + VoiceRecorder.Status.RECORDING -> { + binding.tvTimeline.isEnabled = true + binding.progress.isEnabled = true + showRecordingIcon() + binding.ibtnRecord.visibility = GONE + binding.ibtnStop.visibility = VISIBLE + } + VoiceRecorder.Status.COMPLETED -> { + binding.progress.drawProgress(0) + File(recorder.recordFilePath) + dismissRecordingIcon() + shutdownRecordingIconExecutor() + binding.ibtnStop.visibility = GONE + binding.ibtnPlay.visibility = VISIBLE + } + VoiceRecorder.Status.PREPARING -> {} + } + } + + private fun drawPlayerStatus(status: VoicePlayer.Status) { + when (status) { + VoicePlayer.Status.PLAYING -> { + binding.ibtnPlay.visibility = GONE + binding.ibtnPause.visibility = VISIBLE + } + VoicePlayer.Status.PAUSED -> { + binding.ibtnPlay.visibility = VISIBLE + binding.ibtnPause.visibility = GONE + } + VoicePlayer.Status.PREPARING -> {} + VoicePlayer.Status.STOPPED -> { + binding.ibtnPlay.visibility = VISIBLE + binding.ibtnPause.visibility = GONE + } + } + } + + private fun showRecordingIcon() { + recordingIconExecutor.scheduleAtFixedRate({ + Handler(Looper.getMainLooper()).post { + if (recorder.status == VoiceRecorder.Status.RECORDING) { + binding.recordingIcon.visibility = + if (binding.recordingIcon.visibility == GONE) VISIBLE else GONE + } + } + }, 0, 500, TimeUnit.MILLISECONDS) + } + + private fun dismissRecordingIcon() { + recordingIconExecutor.cancelAllJobs(true) + binding.recordingIcon.visibility = GONE + } + + private fun shutdownRecordingIconExecutor() { + if (recordingIconExecutor.isShutdown) return + recordingIconExecutor.shutdownNow() + } + + private fun drawIdle() { + ViewUtils.drawTimeline(binding.tvTimeline, 0) + binding.progress.drawProgress(0) + binding.ibtnSend.isEnabled = false + binding.tvTimeline.isEnabled = false + binding.progress.isEnabled = false + dismissRecordingIcon() + binding.ibtnRecord.visibility = VISIBLE + binding.ibtnPlay.visibility = GONE + binding.ibtnStop.visibility = GONE + binding.ibtnPause.visibility = GONE + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceProgressView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceProgressView.kt new file mode 100644 index 00000000..d1165cf8 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/VoiceProgressView.kt @@ -0,0 +1,117 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.animation.PropertyValuesHolder +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import kotlin.math.min + +internal class VoiceProgressView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + var cornerRadius: Float = 200F + var progress: Int = 0 + var max: Int = 100 * 10 + var trackColor: ColorStateList? = null + set(value) { + field = value + value?.let { + trackPaint.color = it.getColorForState(drawableState, 0) + } ?: run { + trackPaint.color = 0 + } + invalidate() + } + var progressColor: ColorStateList? = null + set(value) { + field = value + value?.let { + progressPaint.color = it.getColorForState(drawableState, 0) + } ?: run { + progressPaint.color = 0 + } + invalidate() + } + var animationDuration: Long = 100 + private val trackPaint: Paint = Paint() + private var trackRectF: RectF = RectF() + private val trackRectPath = Path() + private val progressPaint: Paint = Paint() + private var progressRectF: RectF = RectF() + private var animator: ValueAnimator? = null + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + trackRectF.set(0F, 0F, w.toFloat(), h.toFloat()) + trackRectPath.reset() + trackRectPath.addRoundRect(trackRectF, cornerRadius, cornerRadius, Path.Direction.CW) + progressRectF.set(0F, 0F, progress * w.toFloat() / max, h.toFloat()) + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + canvas?.apply { + clipPath(trackRectPath) + drawRect(trackRectF, trackPaint) + drawRect(progressRectF, progressPaint) + } + } + + override fun drawableStateChanged() { + super.drawableStateChanged() + progressColor?.let { + val progressColor: Int = it.getColorForState(drawableState, 0) + progressPaint.color = progressColor + } + trackColor?.let { + val trackColor: Int = it.getColorForState(drawableState, 0) + trackPaint.color = trackColor + } + } + + fun drawProgressWithAnimation(progress: Int) { + cancelAnimator() + val valuesHolder = PropertyValuesHolder.ofInt( + "percentage", + this.progress, + progress + ) + animator = ValueAnimator().apply { + setValues(valuesHolder) + duration = animationDuration + interpolator = LinearInterpolator() + addUpdateListener { + val percentage = it.getAnimatedValue("percentage") as Int + this@VoiceProgressView.progress = percentage + progressRectF.set(0F, 0F, calculateProgressWidth(), height.toFloat()) + postInvalidate() + } + } + animator?.start() + } + + fun drawProgress(progress: Int) { + cancelAnimator() + this.progress = progress + progressRectF.set(0F, 0F, calculateProgressWidth(), height.toFloat()) + postInvalidate() + } + + private fun cancelAnimator() { + animator?.cancel() + animator = null + } + + private fun calculateProgressWidth(): Float { + return min(progress, max) * width.toFloat() / max + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/model/FileInfo.java b/uikit/src/main/java/com/sendbird/uikit/model/FileInfo.java index 8d2fa23a..da81786b 100644 --- a/uikit/src/main/java/com/sendbird/uikit/model/FileInfo.java +++ b/uikit/src/main/java/com/sendbird/uikit/model/FileInfo.java @@ -48,6 +48,8 @@ final public class FileInfo { private final String thumbnailPath; @NonNull private final File file; + @Nullable + private final File cacheDir; public FileInfo(@NonNull String path, int size, @@ -56,7 +58,8 @@ public FileInfo(@NonNull String path, @NonNull Uri uri, int thumbnailWidth, int thumbnailHeight, - @Nullable String thumbnailPath) { + @Nullable String thumbnailPath, + @Nullable File cacheDir) { this.path = path; this.size = size; this.mimeType = mimeType; @@ -66,6 +69,7 @@ public FileInfo(@NonNull String path, this.thumbnailHeight = thumbnailHeight; this.thumbnailPath = thumbnailPath; this.file = new File(path); + this.cacheDir = cacheDir; } @NonNull @@ -105,6 +109,11 @@ public String getThumbnailPath() { return thumbnailPath; } + @Nullable + public File getCacheDir() { + return cacheDir; + } + @Nullable public File getThumbnailFile() { File file = null; @@ -148,6 +157,22 @@ private static boolean isCompressible(@NonNull String mimeType) { && (mimeType.endsWith(StringSet.jpeg) || mimeType.endsWith(StringSet.jpg) || mimeType.endsWith(StringSet.png)); } + @NonNull + public static FileInfo fromVoiceFileInfo(@NonNull VoiceMessageInfo voiceMessageInfo, @NonNull File cacheDir) { + File file = new File(voiceMessageInfo.getPath()); + int fileSize = Integer.parseInt(String.valueOf(file.length()/1024)); + return new FileInfo( + voiceMessageInfo.getPath(), + fileSize, + voiceMessageInfo.getMimeType(), + "Voice message", + Uri.parse(voiceMessageInfo.getPath()), + 0, + 0, + null, + cacheDir); + } + @SuppressWarnings("UnusedReturnValue") @NonNull public static Future fromUri(@NonNull final Context context, @@ -211,7 +236,7 @@ public FileInfo call() throws IOException { Logger.d("++ THUMBNAIL HEIGHT : %s", thumbnailWidth); Logger.d("++ THUMBNAIL HEIGHT : %s", thumbnailHeight); Logger.d("=============================================================================="); - fileInfo = new FileInfo(path, size, mimeType, name, uri, thumbnailWidth, thumbnailHeight, thumbnailPath); + fileInfo = new FileInfo(path, size, mimeType, name, uri, thumbnailWidth, thumbnailHeight, thumbnailPath, null); } } } diff --git a/uikit/src/main/java/com/sendbird/uikit/model/VoiceMessageInfo.java b/uikit/src/main/java/com/sendbird/uikit/model/VoiceMessageInfo.java new file mode 100644 index 00000000..18c097a2 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/model/VoiceMessageInfo.java @@ -0,0 +1,54 @@ +package com.sendbird.uikit.model; + +import androidx.annotation.NonNull; + + +final public class VoiceMessageInfo { + @NonNull + private final String path; + @NonNull + private final String mimeType; + private final int duration; + + public VoiceMessageInfo(@NonNull String path, + @NonNull String mimeType, + int duration) { + this.path = path; + this.mimeType = mimeType; + this.duration = duration; + } + + @NonNull + public String getPath() { + return path; + } + + @NonNull + public String getMimeType() { + return mimeType; + } + + public int getDuration() { + return duration; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof VoiceMessageInfo)) return false; + + VoiceMessageInfo that = (VoiceMessageInfo) o; + + if (duration != that.duration) return false; + if (!path.equals(that.path)) return false; + return mimeType.equals(that.mimeType); + } + + @Override + public int hashCode() { + int result = path.hashCode(); + result = 31 * result + mimeType.hashCode(); + result = 31 * result + duration; + return result; + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageInputComponent.java b/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageInputComponent.java index 1c0b8f88..2fdb266a 100644 --- a/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageInputComponent.java +++ b/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageInputComponent.java @@ -20,6 +20,7 @@ import com.sendbird.android.user.MutedState; import com.sendbird.android.user.User; import com.sendbird.uikit.R; +import com.sendbird.uikit.SendbirdUIKit; import com.sendbird.uikit.activities.adapter.SuggestedMentionListAdapter; import com.sendbird.uikit.consts.KeyboardDisplayType; import com.sendbird.uikit.consts.StringSet; @@ -57,6 +58,8 @@ public class MessageInputComponent { @Nullable private View.OnClickListener editModeSaveButtonClickListener; @Nullable + private View.OnClickListener voiceRecorderButtonClickListener; + @Nullable private View.OnClickListener replyModeCloseButtonClickListener; @Nullable private OnInputTextChangedListener inputTextChangedListener; @@ -163,6 +166,8 @@ public View onCreateView(@NonNull Context context, @NonNull LayoutInflater infla this.messageInputView.setOnEditModeTextChangedListener(this::onEditModeTextChanged); this.messageInputView.setOnReplyCloseClickListener(this::onQuoteReplyModeCloseButtonClicked); this.messageInputView.setOnInputModeChangedListener(this::onInputModeChanged); + this.messageInputView.setOnVoiceRecorderButtonClickListener(this::onVoiceRecorderButtonClicked); + this.messageInputView.setUseVoiceButton(SendbirdUIKit.isUsingVoiceMessage()); this.setUseSuggestedMentionListDivider(params.useSuggestedMentionListDivider); if (params.keyboardDisplayType == KeyboardDisplayType.Dialog) { final MessageInputDialogWrapper messageInputDialogWrapper = new MessageInputDialogWrapper(context); @@ -293,6 +298,16 @@ public void setOnInputModeChangedListener(@Nullable OnInputModeChangedListener i this.inputModeChangedListener = inputModeChangedListener; } + /** + * Register a callback to be invoked when the voice recorder button is clicked. + * + * @param voiceRecorderButtonClickListener The callback that will run + * @since 3.4.0 + */ + public void setOnVoiceRecorderButtonClickListener(@Nullable View.OnClickListener voiceRecorderButtonClickListener) { + this.voiceRecorderButtonClickListener = voiceRecorderButtonClickListener; + } + /** * Called when the left button of the input is clicked. * @@ -392,6 +407,16 @@ protected void onEditModeSaveButtonClicked(@NonNull View view) { if (editModeSaveButtonClickListener != null) editModeSaveButtonClickListener.onClick(view); } + /** + * Called when the voice recorder button is clicked. + * + * @param view The View clicked + * @since 3.4.0 + */ + protected void onVoiceRecorderButtonClicked(@NonNull View view) { + if (voiceRecorderButtonClickListener != null) voiceRecorderButtonClickListener.onClick(view); + } + /** * Notifies this component that the channel data has changed. * diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java index dd5c8099..8df36bfa 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java @@ -81,11 +81,17 @@ public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int ba @NonNull public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int backgroundColor, int backgroundAlpha, @DrawableRes int iconRes, @ColorRes int iconTint) { + int inset = (int) context.getResources().getDimension(R.dimen.sb_size_24); + return createOvalIcon(context, backgroundColor, 255, iconRes, iconTint, inset); + } + + @NonNull + public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int backgroundColor, int backgroundAlpha, + @DrawableRes int iconRes, @ColorRes int iconTint, int inset) { ShapeDrawable ovalBackground = new ShapeDrawable(new OvalShape()); ovalBackground.getPaint().setColor(context.getResources().getColor(backgroundColor)); ovalBackground.getPaint().setAlpha(backgroundAlpha); Drawable icon = setTintList(context, iconRes, iconTint); - int inset = (int) context.getResources().getDimension(R.dimen.sb_size_24); return createLayerIcon(ovalBackground, icon, inset); } diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/FileUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/FileUtils.java index 926bde0c..1e046403 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/FileUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/FileUtils.java @@ -265,6 +265,26 @@ public static File getDeletableDir(@NonNull Context context) { return file; } + @SuppressWarnings("ResultOfMethodCallIgnored") + @NonNull + public static File getChannelFileCacheDir(@NonNull Context context, @NonNull String channelUrl) { + File dir = context.getCacheDir(); + File file = new File(dir, channelUrl); + if (!file.exists()) { + file.mkdir(); + } + return file; + } + + @NonNull + public static File getVoiceFile(@NonNull Context context, @NonNull FileMessage message) { + return FileUtils.createChannelCacheFile( + context.getApplicationContext(), + message.getChannelUrl(), + MessageUtils.getVoiceFilename(message) + ); + } + @NonNull public static File createDeletableFile(@NonNull Context context, @NonNull String fileName) { return new File(getDeletableDir(context), fileName); @@ -276,6 +296,11 @@ public static File createCachedDirFile(@NonNull Context context, @NonNull String return new File(dir, fileName); } + @NonNull + public static File createChannelCacheFile(@NonNull Context context, @NonNull String channelUrl, @NonNull String fileName) { + return new File(getChannelFileCacheDir(context, channelUrl), fileName); + } + public static void copyFile(@NonNull File src, @NonNull File dst) throws IOException { try (FileChannel inChannel = new FileInputStream(src).getChannel(); FileChannel outChannel = new FileOutputStream(dst).getChannel()) { diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/MessageUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/MessageUtils.java index d6c40c38..4e4f5efb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/MessageUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/MessageUtils.java @@ -8,6 +8,7 @@ import com.sendbird.android.message.BaseMessage; import com.sendbird.android.message.CustomizableMessage; import com.sendbird.android.message.FileMessage; +import com.sendbird.android.message.MessageMetaArray; import com.sendbird.android.message.SendingStatus; import com.sendbird.android.message.UserMessage; import com.sendbird.android.user.User; @@ -16,9 +17,14 @@ import com.sendbird.uikit.activities.viewholder.MessageViewHolderFactory; import com.sendbird.uikit.consts.MessageGroupType; import com.sendbird.uikit.consts.ReplyType; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.model.MessageListUIParams; import com.sendbird.uikit.model.TimelineMessage; +import java.util.ArrayList; +import java.util.List; + public class MessageUtils { public static boolean isMine(@NonNull BaseMessage message) { if (message.getSender() == null) { @@ -119,4 +125,58 @@ public static boolean hasThread(@NonNull BaseMessage message) { if (message instanceof CustomizableMessage) return false; return message.getThreadInfo().getReplyCount() > 0; } + + public static boolean isVoiceMessage(@Nullable FileMessage fileMessage) { + if (fileMessage == null) return false; + final String[] typeParts = fileMessage.getType().split(";"); + if (typeParts.length > 1) { + for (final String typePart : typeParts) { + if (typePart.startsWith(StringSet.sbu_type)) { + final String[] paramKeyValue = typePart.split("="); + if (paramKeyValue.length > 1) { + if (paramKeyValue[1].equals(StringSet.voice)) { + return true; + } + } + } + } + } + + final List typeArrayKeys = new ArrayList<>(); + typeArrayKeys.add(StringSet.KEY_INTERNAL_MESSAGE_TYPE); + final List typeArray = fileMessage.getMetaArrays(typeArrayKeys); + final String type = typeArray.isEmpty() ? "" : typeArray.get(0).getValue().get(0); + return type.startsWith(StringSet.voice); + } + + @NonNull + public static String getVoiceMessageKey(@NonNull FileMessage fileMessage) { + if (fileMessage.getSendingStatus() == SendingStatus.PENDING) { + return fileMessage.getRequestId(); + } else { + return String.valueOf(fileMessage.getMessageId()); + } + } + + @NonNull + public static String getVoiceFilename(@NonNull FileMessage message) { + String key = message.getRequestId(); + if (key.isEmpty() || key.equals("0")) { + key = String.valueOf(message.getMessageId()); + } + return "Voice_file_" + key + "." + StringSet.m4a; + } + + public static int extractDuration(@NonNull FileMessage message) { + final List durationArrayKeys = new ArrayList<>(); + durationArrayKeys.add(StringSet.KEY_VOICE_MESSAGE_DURATION); + final List durationArray = message.getMetaArrays(durationArrayKeys); + final String duration = durationArray.isEmpty() ? "" : durationArray.get(0).getValue().get(0); + try { + return Integer.parseInt(duration); + } catch (NumberFormatException e) { + Logger.w(e); + } + return 0; + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/PermissionUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/PermissionUtils.java index a4d8377e..58f1c1c7 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/PermissionUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/PermissionUtils.java @@ -23,10 +23,15 @@ public class PermissionUtils { public final static String[] CAMERA_PERMISSION = getCameraPermission(); public final static String[] GET_CONTENT_PERMISSION = getGetContentPermission(); + public final static String[] RECORD_AUDIO_PERMISSION = getRecordAudioPermission(); private PermissionUtils() { } + private static String[] getRecordAudioPermission() { + return new String[]{Manifest.permission.RECORD_AUDIO}; + } + private static String[] getCameraPermission() { String[] permissions = new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE, diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java index 08f9e601..cf54aaa6 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java @@ -46,8 +46,10 @@ import com.sendbird.uikit.internal.ui.messages.BaseQuotedMessageView; import com.sendbird.uikit.internal.ui.messages.OgtagView; import com.sendbird.uikit.internal.ui.messages.ThreadInfoView; +import com.sendbird.uikit.internal.ui.messages.VoiceMessageView; import com.sendbird.uikit.internal.ui.reactions.EmojiReactionListView; import com.sendbird.uikit.internal.ui.widgets.RoundCornerView; +import com.sendbird.uikit.internal.ui.widgets.VoiceProgressView; import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.model.FileInfo; import com.sendbird.uikit.model.MentionSpan; @@ -57,6 +59,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -479,4 +483,26 @@ public static void drawThreadInfo(@NonNull ThreadInfoView threadInfoView, @NonNu } threadInfoView.drawThreadInfo(message.getThreadInfo()); } + + public static void drawVoiceMessage(@NonNull VoiceMessageView voiceMessageView, @NonNull FileMessage message) { + voiceMessageView.drawVoiceMessage(message); + } + + public static void drawTimeline(@NonNull TextView timelineView, int milliseconds) { + long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds); + long sec = TimeUnit.MILLISECONDS.toSeconds(milliseconds) - TimeUnit.MINUTES.toSeconds(minutes); + timelineView.setText(String.format(Locale.getDefault(), "%02d:%02d", minutes, sec)); + } + + public static void drawVoicePlayerProgress(@NonNull VoiceProgressView progressView, int milliseconds, int duration) { + if (duration == 0) return; + int progress = milliseconds * 1000 / duration; + int prevProgress = progressView.getProgress(); + + if (prevProgress <= progress) { + progressView.drawProgressWithAnimation(progress); + } else { + progressView.drawProgress(progress); + } + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/BaseMessageListViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/BaseMessageListViewModel.java index eeba53e6..8165177c 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/BaseMessageListViewModel.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/BaseMessageListViewModel.java @@ -164,7 +164,6 @@ public void sendFileMessage(@NonNull FileMessageCreateParams params, @NonNull Fi PendingMessageRepository.getInstance().addFileInfo(pendingFileMessage, fileInfo); } } - } /** diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/ChannelViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/ChannelViewModel.java index 6de57a79..546b0759 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/ChannelViewModel.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/ChannelViewModel.java @@ -699,10 +699,10 @@ public MessageListParams createMessageListParams() { messageListParams.setReverse(true); if (SendbirdUIKit.getReplyType() != ReplyType.NONE) { messageListParams.setReplyType(com.sendbird.android.message.ReplyType.ONLY_REPLY_TO_CHANNEL); - messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(false, Available.isSupportReaction(), true, true)); + messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(true, Available.isSupportReaction(), true, true)); } else { messageListParams.setReplyType(com.sendbird.android.message.ReplyType.NONE); - messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(false, Available.isSupportReaction(), false, true)); + messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(true, Available.isSupportReaction(), false, true)); } return messageListParams; } diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/FileDownloader.java b/uikit/src/main/java/com/sendbird/uikit/vm/FileDownloader.java index 7dfee359..35f7d4e4 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/FileDownloader.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/FileDownloader.java @@ -16,6 +16,7 @@ import com.sendbird.uikit.internal.tasks.TaskQueue; import com.sendbird.uikit.log.Logger; import com.sendbird.uikit.utils.FileUtils; +import com.sendbird.uikit.utils.MessageUtils; import java.io.File; import java.io.IOException; @@ -35,29 +36,25 @@ public static FileDownloader getInstance() { } private final Set downloadingFileSet = new HashSet<>(); - @NonNull - private File getDownloadFile(@NonNull Context context, @NonNull FileMessage message) { - String newFileName = "Downloaded_file_" + message.getMessageId() + "_" + message.getName(); - return FileUtils.createCachedDirFile(context.getApplicationContext(), newFileName); + @SuppressWarnings("ResultOfMethodCallIgnored") + @Nullable + public File downloadVoiceFileToCache(@NonNull Context context, @NonNull FileMessage message) throws ExecutionException, InterruptedException, IOException { + final File destFile = FileUtils.getVoiceFile(context, message); + return downloadToCache(context, message, destFile); } - public boolean hasFile(@NonNull Context context, @NonNull FileMessage message) { - File file = getDownloadFile(context, message); - if (file.exists()) { - if (file.length() == message.getSize()) { - Logger.dev("__ return exist file"); - return true; - } - } - return false; + @SuppressWarnings("ResultOfMethodCallIgnored") + @Nullable + public File downloadToCache(@NonNull Context context, @NonNull FileMessage message) throws ExecutionException, InterruptedException, IOException { + final File destFile = FileUtils.createCachedDirFile(context, System.currentTimeMillis() + "_" + message.getName()); + return downloadToCache(context, message, destFile); } @SuppressWarnings("ResultOfMethodCallIgnored") @Nullable - public File downloadToCache(@NonNull Context context, @NonNull FileMessage message) throws ExecutionException, InterruptedException, IOException { + public File downloadToCache(@NonNull Context context, @NonNull FileMessage message, @NonNull final File destFile) throws ExecutionException, InterruptedException, IOException { final String url = message.getUrl(); final String plainUrl = message.getPlainUrl(); - final File destFile = FileUtils.createDeletableFile(context, message.getName()); if (isFileValid(destFile, message)) { Logger.dev("__ return exist file"); @@ -131,7 +128,11 @@ public static boolean downloadFile(@NonNull Context context, @NonNull FileMessag TaskQueue.addTask(new JobResultTask() { @Override public File call() throws ExecutionException, InterruptedException, IOException { - return FileDownloader.getInstance().downloadToCache(context, message); + if (MessageUtils.isVoiceMessage(message)) { + return FileDownloader.getInstance().downloadVoiceFileToCache(context, message); + } else { + return FileDownloader.getInstance().downloadToCache(context, message); + } } @Override diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/MessageThreadViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/MessageThreadViewModel.java index 277f484b..98aed4af 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/MessageThreadViewModel.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/MessageThreadViewModel.java @@ -69,6 +69,8 @@ public class MessageThreadViewModel extends BaseMessageListViewModel { @NonNull private final MutableLiveData parentMessageDeleted = new MutableLiveData<>(); @NonNull + private final MutableLiveData threadMessageDeleted = new MutableLiveData<>(); + @NonNull private final MutableLiveData statusFrame = new MutableLiveData<>(); @NonNull private final MutableLiveData onReconnected = new MutableLiveData<>(); @@ -302,6 +304,17 @@ public LiveData onParentMessageDeleted() { return parentMessageDeleted; } + /** + * Returns LiveData that can be observed if the thread message has been deleted. + * + * @return LiveData holding whether the thread message has been deleted + * @since 3.4.0 + */ + @NonNull + public LiveData onThreadMessageDeleted() { + return threadMessageDeleted; + } + /** * Returns LiveData that can be observed for the status of the result of fetching the thread list. * When the thread list is fetched successfully, the status is {@link StatusFrameView.Status#NONE}. @@ -366,7 +379,7 @@ public void deleteMessage(@NonNull BaseMessage message, @Nullable OnCompleteHand public ThreadMessageListParams createMessageListParams() { final ThreadMessageListParams messageListParams = new ThreadMessageListParams(); messageListParams.setReverse(true); - messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(false, Available.isSupportReaction(), false, false)); + messageListParams.setMessagePayloadFilter(new MessagePayloadFilter(true, Available.isSupportReaction(), false, false)); return messageListParams; } @@ -500,7 +513,12 @@ private synchronized MessageCollection createParentMessageCollection(@NonNull Gr params.setInclusive(true); params.setPreviousResultSize(1); params.setNextResultSize(1); - params.setMessagePayloadFilter(new MessagePayloadFilter(false, Available.isSupportReaction(), true, true)); + if (messageListParams != null) { + params.setMessagePayloadFilter(new MessagePayloadFilter(messageListParams.getMessagePayloadFilter().getIncludeMetaArray(), + messageListParams.getMessagePayloadFilter().getIncludeReactions(), true, true)); + } else { + params.setMessagePayloadFilter(new MessagePayloadFilter(true, Available.isSupportReaction(), true, true)); + } return SendbirdChat.createMessageCollection(new MessageCollectionCreateParams(channel, params, parentMessage.getCreatedAt(), new MessageCollectionHandler() { @Override public void onMessagesAdded(@NonNull MessageContext context, @NonNull GroupChannel groupChannel, @NonNull List messages) { @@ -565,7 +583,12 @@ private synchronized MessageCollection createCollection(@NonNull GroupChannel ch params.setReplyType(com.sendbird.android.message.ReplyType.ONLY_REPLY_TO_CHANNEL); params.setPreviousResultSize(1); params.setNextResultSize(1); - params.setMessagePayloadFilter(new MessagePayloadFilter(false, Available.isSupportReaction(), true, true)); + if (messageListParams != null) { + params.setMessagePayloadFilter(new MessagePayloadFilter(messageListParams.getMessagePayloadFilter().getIncludeMetaArray(), + messageListParams.getMessagePayloadFilter().getIncludeReactions(), true, true)); + } else { + params.setMessagePayloadFilter(new MessagePayloadFilter(true, Available.isSupportReaction(), true, true)); + } return SendbirdChat.createMessageCollection(new MessageCollectionCreateParams(channel, params, Long.MAX_VALUE, new MessageCollectionHandler() { @Override public void onMessagesAdded(@NonNull MessageContext context, @NonNull GroupChannel groupChannel, @NonNull List messages) { @@ -663,6 +686,7 @@ public void onMessageUpdated(@NonNull BaseChannel channel, @NonNull BaseMessage public void onMessageDeleted(@NonNull BaseChannel channel, long msgId) { Logger.d(">> MessageThreadViewModel::onMessageDeleted()"); if (!isCurrentChannel(channel.getUrl())) return; + threadMessageDeleted.postValue(msgId); final BaseMessage deletedMessage = cachedMessages.getById(msgId); if (deletedMessage != null) { cachedMessages.deleteByMessageId(msgId); diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/PendingMessageRepository.java b/uikit/src/main/java/com/sendbird/uikit/vm/PendingMessageRepository.java index dd8055ef..b7e52b54 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/PendingMessageRepository.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/PendingMessageRepository.java @@ -6,8 +6,13 @@ import com.sendbird.android.message.BaseMessage; import com.sendbird.android.message.FileMessage; +import com.sendbird.uikit.internal.tasks.JobTask; +import com.sendbird.uikit.internal.tasks.TaskQueue; import com.sendbird.uikit.model.FileInfo; +import com.sendbird.uikit.utils.FileUtils; +import com.sendbird.uikit.utils.MessageUtils; +import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -51,6 +56,20 @@ public FileInfo getFileInfo(@NonNull BaseMessage message) { public void addFileInfo(@NonNull FileMessage message, @NonNull FileInfo fileInfo) { cachedFileInfos.put(message.getRequestId(), fileInfo); + if (MessageUtils.isVoiceMessage(message) && fileInfo.getCacheDir() != null) { + TaskQueue.addTask(new JobTask() { + @NonNull + @Override + protected File call() throws Exception { + final String filename = MessageUtils.getVoiceFilename(message); + final File destFile = new File(fileInfo.getCacheDir(), filename); + // As cachedFileInfos is cleared after the message is sent, + // the voice file is copied to play it from the cache dir. + FileUtils.copyFile(fileInfo.getFile(), destFile); + return destFile; + } + }); + } } void clearAllFileInfo(@NonNull List messages) { diff --git a/uikit/src/main/java/com/sendbird/uikit/widgets/MessageInputView.kt b/uikit/src/main/java/com/sendbird/uikit/widgets/MessageInputView.kt index c79a9e49..770023bb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/widgets/MessageInputView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/widgets/MessageInputView.kt @@ -24,6 +24,7 @@ import com.sendbird.uikit.interfaces.OnInputTextChangedListener import com.sendbird.uikit.internal.extensions.setAppearance import com.sendbird.uikit.internal.extensions.setCursorDrawable import com.sendbird.uikit.model.TextUIConfig +import com.sendbird.uikit.utils.MessageUtils import com.sendbird.uikit.utils.SoftInputUtils import com.sendbird.uikit.utils.TextUtils import com.sendbird.uikit.utils.ViewUtils @@ -43,6 +44,11 @@ class MessageInputView @JvmOverloads constructor( field = value binding.ibtnSend.setOnClickListener(value) } + var onVoiceRecorderButtonClickListener: OnClickListener? = null + set(value) { + field = value + binding.ibtnVoiceRecorder.setOnClickListener(value) + } var onAddClickListener: OnClickListener? = null set(value) { field = value @@ -73,6 +79,11 @@ class MessageInputView @JvmOverloads constructor( binding.ibtnAdd.visibility = value } var showSendButtonAlways = false + var useVoiceButton = false + set(value) { + field = value + setVoiceRecorderButtonVisibility(if (value) VISIBLE else GONE) + } var useOverlay = false var inputText: CharSequence? get() = binding.etInputText.text?.trim { it <= ' ' } @@ -93,6 +104,7 @@ class MessageInputView @JvmOverloads constructor( Mode.EDIT -> { setQuoteReplyPanelVisibility(GONE) setEditPanelVisibility(VISIBLE) + setVoiceRecorderButtonVisibility(GONE) binding.ibtnAdd.visibility = GONE } Mode.QUOTE_REPLY -> { @@ -135,11 +147,18 @@ class MessageInputView @JvmOverloads constructor( fun drawMessageToReply(message: BaseMessage) { var displayMessage = message.message if (message is FileMessage) { - ViewUtils.drawFileMessageIconToReply(binding.ivQuoteReplyMessageIcon, message) - ViewUtils.drawThumbnail(binding.ivQuoteReplyMessageImage, message) - binding.ivQuoteReplyMessageIcon.visibility = VISIBLE - binding.ivQuoteReplyMessageImage.visibility = VISIBLE - displayMessage = if (message.type.contains(StringSet.gif)) { + if (MessageUtils.isVoiceMessage(message)) { + binding.ivQuoteReplyMessageIcon.visibility = GONE + binding.ivQuoteReplyMessageImage.visibility = GONE + } else { + ViewUtils.drawFileMessageIconToReply(binding.ivQuoteReplyMessageIcon, message) + ViewUtils.drawThumbnail(binding.ivQuoteReplyMessageImage, message) + binding.ivQuoteReplyMessageIcon.visibility = VISIBLE + binding.ivQuoteReplyMessageImage.visibility = VISIBLE + } + displayMessage = if (MessageUtils.isVoiceMessage(message)) { + context.getString(R.string.sb_text_voice_message) + } else if (message.type.contains(StringSet.gif)) { StringSet.gif.uppercase(Locale.getDefault()) } else if (message.type.startsWith(StringSet.image)) { TextUtils.capitalize(StringSet.photo) @@ -167,12 +186,17 @@ class MessageInputView @JvmOverloads constructor( binding.ibtnAdd.isEnabled = enabled binding.etInputText.isEnabled = enabled binding.ibtnSend.isEnabled = enabled + binding.ibtnVoiceRecorder.isEnabled = enabled } fun setSendButtonVisibility(visibility: Int) { binding.ibtnSend.visibility = visibility } + fun setVoiceRecorderButtonVisibility(visibility: Int) { + binding.ibtnVoiceRecorder.visibility = visibility + } + fun setSendImageResource(@DrawableRes sendImageResource: Int) { binding.ibtnSend.setImageResource(sendImageResource) } @@ -254,6 +278,16 @@ class MessageInputView @JvmOverloads constructor( R.styleable.MessageInputComponent_sb_message_input_right_button_background, R.drawable.sb_button_uncontained_background_light ) + val micButtonIcon = a.getResourceId( + R.styleable.MessageInputComponent_sb_message_input_voice_recorder_button_icon, + R.drawable.icon_send + ) + val micButtonTint = + a.getColorStateList(R.styleable.MessageInputComponent_sb_message_input_voice_recorder_button_tint) + val micButtonBackground = a.getResourceId( + R.styleable.MessageInputComponent_sb_message_input_voice_recorder_button_background, + R.drawable.sb_button_uncontained_background_light + ) val editSaveButtonTextAppearance = a.getResourceId( R.styleable.MessageInputComponent_sb_message_input_edit_save_button_text_appearance, R.style.SendbirdButtonOnDark01 @@ -307,6 +341,10 @@ class MessageInputView @JvmOverloads constructor( binding.ibtnSend.setBackgroundResource(rightButtonBackground) setSendImageResource(rightButtonIcon) binding.ibtnSend.imageTintList = rightButtonTint + binding.ibtnVoiceRecorder.setBackgroundResource(micButtonBackground) + binding.ibtnVoiceRecorder.setImageResource(micButtonIcon) + binding.ibtnVoiceRecorder.imageTintList = micButtonTint + setVoiceRecorderButtonVisibility(if (useVoiceButton) VISIBLE else GONE) binding.btnSave.setAppearance(context, editSaveButtonTextAppearance) if (editSaveButtonTextColor != null) { binding.btnSave.setTextColor(editSaveButtonTextColor) @@ -330,8 +368,14 @@ class MessageInputView @JvmOverloads constructor( override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { if (!TextUtils.isEmpty(s) && Mode.EDIT != inputMode || showSendButtonAlways) { setSendButtonVisibility(VISIBLE) + if (useVoiceButton) { + setVoiceRecorderButtonVisibility(GONE) + } } else { setSendButtonVisibility(GONE) + if (useVoiceButton && Mode.EDIT != inputMode) { + setVoiceRecorderButtonVisibility(VISIBLE) + } } } @@ -346,16 +390,22 @@ class MessageInputView @JvmOverloads constructor( override fun afterTextChanged(s: Editable) { if (!TextUtils.isEmpty(s) && Mode.EDIT != inputMode || showSendButtonAlways) { setSendButtonVisibility(VISIBLE) + if (useVoiceButton) { + setVoiceRecorderButtonVisibility(GONE) + } } else { setSendButtonVisibility(GONE) + if (useVoiceButton && Mode.EDIT != inputMode) { + setVoiceRecorderButtonVisibility(VISIBLE) + } } } }) binding.etInputText.inputType = ( - InputType.TYPE_CLASS_TEXT - or InputType.TYPE_TEXT_FLAG_MULTI_LINE - or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - ) + InputType.TYPE_CLASS_TEXT + or InputType.TYPE_TEXT_FLAG_MULTI_LINE + or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + ) } finally { a.recycle() } diff --git a/uikit/src/main/res/color/sb_selector_input_voice_color_dark.xml b/uikit/src/main/res/color/sb_selector_input_voice_color_dark.xml new file mode 100644 index 00000000..d56b9184 --- /dev/null +++ b/uikit/src/main/res/color/sb_selector_input_voice_color_dark.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/uikit/src/main/res/color/sb_selector_input_voice_color_light.xml b/uikit/src/main/res/color/sb_selector_input_voice_color_light.xml new file mode 100644 index 00000000..3362c325 --- /dev/null +++ b/uikit/src/main/res/color/sb_selector_input_voice_color_light.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_dark.xml b/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_dark.xml new file mode 100644 index 00000000..a6214aab --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_dark.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_light.xml b/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_light.xml new file mode 100644 index 00000000..cdb396c9 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_oval_button_background_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_dark.xml b/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_dark.xml new file mode 100644 index 00000000..32a56792 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_dark.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_light.xml b/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_light.xml new file mode 100644 index 00000000..f49db026 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_progress_background_light.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_send_background_dark.xml b/uikit/src/main/res/color/sb_voice_message_recorder_send_background_dark.xml new file mode 100644 index 00000000..243d5dac --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_send_background_dark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_send_background_light.xml b/uikit/src/main/res/color/sb_voice_message_recorder_send_background_light.xml new file mode 100644 index 00000000..df6b8ab0 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_send_background_light.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_dark.xml b/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_dark.xml new file mode 100644 index 00000000..728a0d75 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_dark.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_light.xml b/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_light.xml new file mode 100644 index 00000000..e44a434f --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_send_icon_light.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_timeline_dark.xml b/uikit/src/main/res/color/sb_voice_message_recorder_timeline_dark.xml new file mode 100644 index 00000000..7890ec53 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_timeline_dark.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/color/sb_voice_message_recorder_timeline_light.xml b/uikit/src/main/res/color/sb_voice_message_recorder_timeline_light.xml new file mode 100644 index 00000000..7fb7ccf4 --- /dev/null +++ b/uikit/src/main/res/color/sb_voice_message_recorder_timeline_light.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/uikit/src/main/res/drawable-hdpi/icon_pause.png b/uikit/src/main/res/drawable-hdpi/icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..aec48093b988a363cf01c5caa1b251a63298638e GIT binary patch literal 529 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9Drb3m9;?W~mvP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBxn-U%jv*C{Z|^?zJe(lX`f%~&6c+ar9C;7S zC(XXX+az4lnj>J|!O6xVxb|%4^F{BO#T~cY`Os#zf1el7G%)zX`PZob^qlE4r_J0x z|M}bR<)>bTmT$kiE0t|;?3ME3TmR&=^$ z)8{@7t~8&$SH3_#eUkspyLnHHPoLi!fAj8~`HVZ57!Pp>xHT9swFoNoFeI{YC_6~d zM97kP&xzANt5VC0*(9Eto=Tr`T|+p%A-&Qve*LFBv%Pzk2U+&>z6&ikU+p-L+vX%l zaIVD;({-8mUCm^duY8vm=6hXs!rhWR%XaOKEIn=Wauc`Am*2;)hu=M$SDWDy;tl*@jhGSlh##yGhN7{DUhn3RA&>j zVO*!0`)W-Gr>L^oQ11$X-eKBdyff%&(9HN8g zgBu`K9i&_K!~&_xiDqo!sck7MM!I951CmUqZ9jOm#DM7TcN!5BSqG#j z!FYud6g40)YCvGbfG9gjjf9OF5EeBcFo_uOfD($|VnBitibM?vOK!jtB@|1&BpH_D z>MSTh@lD>%w^|9`qxdE-{gnX=6yM~7zS^3o^MHTkMvbJ(0GS6CD85)w&b#$bLw}<9 z;(K*azd=LGkU#SBBM0>i&@$u)sWeITrg=f;D@xbg)ZgfBs(wjm+@{*rut`kO=1kr2 znZ{>tZ5dFtC)52xZb@iq*wK~&e=_|=v%ocvM!%r#YW$G!!ge*nClpugeResdr(lHA zH3wiAXKQ*_TH&CKTOd{KC5t1jfK>G&UEvUIUHmew=wKaX(D`83;2SzZ+t)v@&Tsp* zK&on$#S<4ms`^jfpCh9t1Pp$KrJn(FDNA5v(pk~3pt;bRi27_8^ihVvk%h+?R?JsK zezD3j%xqZGqsF#|VLg50mY!amvh@Fv<}(I>rC&x!xe0b|-zAo~jgbFM0zdd2A=0!o zrpujojI?*Ep~wmeK!~6RAMHoPgP|COB55S+dX``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIZaO&$B+ufw|6fZItPllJ@jSu@=%ZRWG`{x z)^cJkSs;?v#wD{R=5wv_f9rJB_14BfGZ+{)NQd8kx6L-M`v3Fln`Lv1i*9@~`(1Tp z{*Bw$|If4jt5{vJ;eJ literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-hdpi/icon_voice_message_on.png b/uikit/src/main/res/drawable-hdpi/icon_voice_message_on.png new file mode 100644 index 0000000000000000000000000000000000000000..a4ea13462d1c1fb252dda1d97a6b1dc44291cde1 GIT binary patch literal 1214 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9Drb3m9;?W~mvP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBZm_3|V@L(#+qv&6XB!9{yI zIA=mmLU~gK^BvAfehx;CcR35x3(N|ZN<&ErgC)P<$Gl={6ccaP=MdKOSACz(ulMiaMRxqlh z|1&IS+Rw_f?f1%y^SeIAI?e3A@Z31~pX}eCC#T$QmHe^KO3j0%PA1?f^Ou{3Pd6U7 z*z#Ya=tYyBl(2&&S!iYmlfbv^f?J||{1)rCPH*XX?QXGmOA7Z*$D-BOlRCfpcdWku z;D@=;{nat5H|)w8lU|!!Y~-F_B(AWnZtjwZDI&W+XqHv?t?56X=6Grb0~XYy$nafD zV|qmA1^IVh)xIwA(`30}@N^D?^s+x}e;pWrUlX^qM4xDi=c>8d(?8>*rXUcVYuK%hGHu#ys1h#iIPJ=N3lHxWRxc~g6IM6pZxNlsas0R52Jy$&FV<~4 zo1gmGzIM__TZP>4Wo;^v^R`=lIw;@$WCpiS3)f{qN2xzj!c7YQA2j9vlKN@=*UtOI zagjquCX~5K)n>og>NfxO!$<+E-Z{w;>lEG>MMWn(S|bqlQ0gW7ZnF;y?=Ose=<^}? zi0q+O4Z-Q=C2e($-?ts(=$UC;{Pd0VuZ;))U0hmUw>u-F7TZcVX;U%KE;2P-?yeVBee!}6vZTP?^y!3VV_<2>F3iPuM>9Kcb=I;+##IL zEO)hmk<|3Jlk!};#na~-C`lzZR8=+bx$XB|t9U@;v~AU)+WH?l3O5wi@HuXit6y^< dJb>$8{Etn^vmKWR90nFQ44$rjF6*2UngCD8B~$``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIaW^>$B+ufx3^B`GC2ygJiOo6AfRS!#%#@O zE&RAcp;?vLsUeb4$Z5|i@vlo?PkyzfUFvF_^cfIP60^0pO+No`O6mD{|HrkPs#H$T zyL+^GopJ5&w0&#jPKtk*FPW=a7->7{*kk*m*ZjH{wl0yo*>zpM^ql^#(4hXD-hUee zW4+F^@3J+_3imp%ezcOI>obGMb4HDG%mH)Q7g+E+6v{O`vSGj>p~ReXG5135#gn!Z z8UI~$Wjpa|qHnv$tB*m>)2|8}efV|w*_{u(p0vO(Yy7#X%1P>Y@wU_5>n1+l mdCNNgTU1d0OdyzA$k4Fy`~n$Hi?_f4V(@hJb6Mw<&;$T<-;ziG literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-mdpi/icon_recording.png b/uikit/src/main/res/drawable-mdpi/icon_recording.png new file mode 100644 index 0000000000000000000000000000000000000000..e8d6992d66e8fca8bb536b4e335d78cacc5e08f6 GIT binary patch literal 700 zcmV;t0z>_YP)P1oa4v&=J_c=>+x$b|=8ypq;?kAanxA1n~x{zBAlW$0Wo?Ao1n7 zdu@pCh`v{!kdVX-{}`vlY3a16&ga#4lhWx}egAdJE0sh@;f3| z*7%`d+Ueg!<{rPuL7f2!dV&`u3lce~4M@?)_vVSDe+)OUK2=EIat|=|i9)_g6Ih~` zM7se??FJYzV1Zs1Vt|IxUmJ7j1uTlWLpdJ?pd2xvL@%Wna6~UhG2jWkJZWzNxfqb6 zmt6hhaD5;Kgg$}cWkRGL2mC=F5AsnP@Ed*nl8@%J_={zu2~$$F|4)rSekOwLs6Fw6 z3%#GzzLupiQwQCA7?L6JPo|Hw{j%-Hsb@h!hZV5<01E0$DIGb$=!NxB`<*c;-y|hW zsVQ8&iFyav$hF}{_JEC4dwSv#u#u_vd5%3`BhemDYyca$c6dhasA^xCsWF5#i*#V5 zO8!FA7;okc6getn&BVAX&;j48{J-? im*qMc$UNYA``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{#s8L$B+ufx3@O(HXBH=KJ+#_!S?3<1fK(3 zH(AUIdR!)65}9`3z1_=Fj*s!Y9+Ol&b(ig0nzvS4{7y|xvbgYd+duW%bCvcq7ERVQ zsk*SPKtB5J>Zb<-ncvLHs}?%P&|=K+kjKG-S%4U(wQxeRLpoE<&VGJbpO*g`CqF)t n)DXW@_rvMNH}F{&&dr(_c$&|6v;w;ClZd^yuOvH%DGKwtf)^O zFn-N#zOi;DIm83z%ESrXbA88W1b9J+0UkO(Z!rZyM$TjaMoyas_!Q8$wSXI9fSu8< zE%uy$V|GGp%4=pXIhpdfczv-;LECZl=fL4RC)>u8LOFfZ_p@riG~1!1wG zQJit{J=GCDr&{q%jd?<2g~>baLuz>zO8c&mZ}>MNj#Dg={KChwO*T35o`1pr4wtc( zf$~Cr&=L|0w&-^uR)M4AWF+g2;%J!!|0~YX91edOUDU|$Bg0;l#Dpdan-=)@2^k%e z;}2?b=TgErSCwxo=3dehtOCbtk63Xs#&v>qR<@;OpE_0LIXxpI{9S*qWb_mXMqA=3 zE^{aJ97i)%;@b26P^o!GbrpD>xREh{4=3c&$hn*=R=N3#ldBK%Fh-P;E$_ABFLa=A z7isYW`hI^;q+Fhl>0E%3GkODjpn2x#A58qsXQj8`+8*k!OtJRhyeV|2 zal1-+N>Jkzu5+>)vKrL|N)j@k9?!m5x$k=;W8;U;$a~h_LO}D7z=677bCz2!x14Ty zJ@?x6^S7&G<=w;A)*kAy?5>l&FhA|)zh^7jvfc_WJ8Sv+J^!n<&DmCc%c6d_R9$&} z{L%k&RlV$Yum@pXYwpAILO)TfBTv>2s5(*{int?{NJse=XPVRCwMeEU2K`f?LqD#1 zE3Efu`K9;I7F8_$_H#)^_|vX?E3eo|{%0ykU`gm>I3w6#$z1n|I?E{)&%`dVfnDgCjDr2;JgpDMod4J+1wXhf6MOSuQ}P5N{+ssr1f@kQsAFN z-BZE2^9!6mvYSmwUvbS&({EMj_K@4^F|&&={%bG2xo+;-l|a$9Z}uD5@174>TN|{7 z`(5GlHPh-T5zgV@EA1U&IOp6`!PgF!+{#U)=IY`3O)z4*}Q$iB}!U7}L literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xhdpi/icon_recording.png b/uikit/src/main/res/drawable-xhdpi/icon_recording.png new file mode 100644 index 0000000000000000000000000000000000000000..65292fcbe184b80be630fa8c3254173f86ec412a GIT binary patch literal 1497 zcmV;~1t$85P)~)9E^EiAy>F9B-PiPS_1R)|zJM{F zK|i5XL}l>TtMeB)4cQ4kl$x)O6UdVy#*L+JN)@HPoLP6JZdc!loREmBp+DjLM&GAT ztMB+0%6ve~eLzhv`q4zLI0I&K!DqyHKxQ5Pw zoO4Us>>ZTDkpFOvp%BUU(#6rz$V1l@ih2nF)ooHu4Xi;06x0O)EcV&^K)(HY= ze78;zK;yf0f)}9i<{xp%;>-uX4fnHWQ7gIUy*-#_>Xx*N=*u5kp%tP2PjK<*(s z$eM+3(Q}=`F%D3GGhK&DLgfVaUH{-Y`XRaIdyt_bQTeM<|KJ2f=*o$n715EdDL_0+ z@_qo(Ktjh<&@~180xoybgj}%yp<2LtkS-A*pep=`K3<}wS8#!LOiogC!Yv-i2)c?N zs%JsimI4Z}@YBw`)SG=jJd^@Ppz$pbaUcpDiU7R&?J&olD6lUAM9}z_i0HZOYzAG0 z4%Mz18fZ!Z0qpl%MMeiVwz_Ld0TU3b?2P6%MF19mc9DAnU)Mj_jsW5z0mLeKkHs}j z4nS;kT|e1Q0kR~SQ@v~>Y2fAReIo)y(D)t@8(-yK0P)E(0t67R&;gutDGqQ6;uRKw&Y=T%M+SnvfVid0>R@6RI)HQN0M4NUIEN14 z9C3gI;+4b*;G26!0N*Sf;3J4vK8pi%-dLEJBQXW|h7RDIg*dq&=dh=!-zW&i|jl|vMB;cA8`(1jX8P@_C@G=9uC#6hz1TlHf{!PAZdk!t>Q0p zXkEasAXeCG%eF_yG79J?Vb9esw5<$c6wuFuZ75M-=miedtSMS(O94UvpCzD`oqqo| z&(V(DBoar7AexZS-+GI`O9T+)-hhk_1`rJ-RF8>mUG~>?dAR(eA3?Mrw(7Lzvr7ud zBvca#z5^LDax?GuuD{$h1!R~ChE2kOK7SK^cL0%^A{rXZn(+e$LI9n>t?@Gf zbON`=&jip3+!{Xxc!l&;pAKvzQ#Q8!S|J5=ipH8uJWByBc= z5dv5j^ict$cU*b@pk2~NrdMZ2Fd8(efTMYr;^7nMGSDeCfzv%10;-v-nvC_Bnt`*c zmW<#lEzH1a_Kl2S+Yx4<;X4swYf0J+H2hHnR6Cay&}DFu4CG8uZMj1pfw!ofVaTJ3 z^4)I1tNr9eRL(Gj>1YM2!5jHb>bi1$7lsUxfGW(!Qm@N>Gdi z2WTQYfxL%O->lBF!k)iNwRh3y+VKJgx77b1bh6KYHhF^v00000NkvXXu0mjfOgo&i literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xhdpi/icon_stop.png b/uikit/src/main/res/drawable-xhdpi/icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..da6fe8b01ec25695e7aefbdbe4e2baf2e638810e GIT binary patch literal 539 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSoCO|{#S9E$svykh8Km+7D9BhG znviX*YBHN zo_6(S>eufvJ(vE5$_gH+H9VDES{C^CZTG}o_ilEGH*|6+%waghIkbpgj)d@@O)Jm2 zv3v;axn$cQI4!qYdXsBuS?J%lxAJbwb`zW6OLuH}DD^nUUEIsQhT0R4eT2+Hsgx*iG>oyJ^c2b1}PoZ(XPwR|-dbYuBQjPfyEj)z*43`x_D zR$8v+R0LnmO;c=6v^0;k&~l1Gv>7T(MFq6EKVWBH_Tk=p?)`K>-Fq>Vdej2G3l0E) zMGz$*d|SWy2QcV1Kk%hJ+!iw?C58n6Xs3Sw3JS+*w?hys{3sa^jqdq#+dvqk5E1}L zE+dRIQviVa1qF~Ib3hB%N6t-opa$Pk&&b^7F<0Ok%?YKmPR3eTs(mcL^Da=P07xjirG8sw9gw8o4~YdzHd{rcI>tjkxNz) z1)b=^H*UlA_8O}#@!--;=~n+AAmckqN7b5^3;o$alVMjfChfg*f-(1Usq{uBD~btn zBob7G4>%19r1W^Q1z$x$sL|&MR|=K`bDGx<2*YXD`ftw8+_#NyO?a7V^%tmmYku~YHP9gb#J_cBV>+oO->2e~#N1m@~# ztgq11tWf+ZXywB3{jg&$yk>YGv%dW<;OOoQysQGslfa>+X8y$g!_=dJ-qDUhW#@;> zXbnW^YSbO-WqbD)je1?NxgNz^Hf(4xw~Gv~Ckb;A?{{x42A`}c<^|NJfucvwstt~5 z^7Ntu>VdAM2u-&3VkG$c**nkic9)LW_2*25oDDbY>-ZuL0T}lXYc52BnO|4VOm8F* z-_aZl{m=3dLf|XXgw57!$KggQ841P=G96}1Rmql7xI>kqQ%+66`#>ukv53%E?B%K+ zO+fVPN@sD+!@U&$eIQB-o>-*x%o`NfNOsPJJ@C1nA0G29+Fy8gR5b<`V474zrFMGsVC24ynacLzCC+} z3aY=Sl%N#WYjyf*SHvfLa-J?hwOrC~e;3(VN_TvX0J3Sl(1xZF#7}7U(q`+y1O= zBiJSVTQu$63EH0BA56ME%EJ^lVd$x}{Ndms@HmU?iAgU(H7X`g(+s%t&R1d7)DxFH z=@`DU!h^1Fbv1tH!+xbO&*iRDZ`vmn6;%0d%(TguZ2E6!3l?6Mqsb?ykvhCp&wPjJ zv*;;Fd4q$tYrM9Jb53$gu4@K)1%6cisqAq~vHL6c)^Eg%w;qQgVgl$jp><8qYPrAZ zaZhD&FpY!w{&`S}%88fzXBA-$5#cD}2Bs{OL-FesSbj)GaK>C0IYV65WvKCHOE7$s z10Fhc;b>din)dgVT$5$RPO}pKIhslgF%2U2m>mx%ZcCyXB$m7@+)G{*NZ@x4tFby3Md`*V(7P|ML~EdOP#fJ+u7U z;L`KXHfQ&3`^t9y9$Vqr-D1zrCFi#9g%t*e&6bK^6RSf!}rcz`N(Au{ysW=dA$6)G`(u; z=<5u-URPMoU%kJq`nAM?_E-6<>fXF%l4ZVeNWFc3i2b*+#y&=$tMSL>x2^n@y?uLM zz3`u?+ZS@nffDQHGavBfXvknvn8nbc#;}NU1TmwIGsLah-`9Wfo}NO(X`66JK>Z2* zTlyz)^LFkMo(DUB{f_x-6JG!J+cm=m`_=pXulDcdeUv!!u+e(o=K;1iZ?o>&$N6vZ zuUEf(vh$f|u6}QP^Y^Q4y=v)W>wd{TTK8-7cE>YU-zzMC1x#1c$AZ6F9$9{iuUh&@ zReEy#cl#rMKW@xv-OHP@>i4~Sx38P&KLn;NgKO3t-!0BK=l*^Nl8Sw?Pv+6iH@OPI zcb^%2H@)_>S_^2>uVt@hm0QlsI%A&q&1}ZXx;O21ufNaAt^w)~xORQ^{_Cmv->$8X zoWJwgspS0ba?@!)E#zOlEwZ_K{q(2Ph5K|qm+$>sbZz>rWAQ<;w`0D6GBhSQaP=>9 W-|OU*HmgM`AX!gWKbLh*2~7ZctI3-H literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xxhdpi/icon_recording.png b/uikit/src/main/res/drawable-xxhdpi/icon_recording.png new file mode 100644 index 0000000000000000000000000000000000000000..dc35979a51e43e8e072e4aa843771948c98e8583 GIT binary patch literal 2438 zcmV;133>L3P)4$@NS9P`o`6i1bXJ~VnJ1v#ITcr3I$2#hz+F;_ z>f%bqB^FC?iCqA@11x6d`#Iakb>{8+|2RRv)muY^G;fNqx9U#m*;SL^q4 zW`*!ZRWaDdg@6Gp;TL7RE+a4FW8m?&jHRj~23lFte^bV78SfQVf)9wL*}b7;}8aQWQcETp8C2E4e5H9F0+jLP*x7Nc>You4-ceC$dl+XKG_81kN%~ z$i^Kzn;A-iq!LRp7LD%DV%(W?CMJ~F*QEb-#R$=|o ziKp?cc(UDKPVDf+6uB6QdbIZTH`m;{( zg+{))@7Nu{iR8y&>Q3M^qA@!_y|Q-#4p*q^1f#ql$e0~qDUu(@bE zfFHkJzkj0uWD0BFnFuE?l}LV^-ch5_p&h_h;5P#1!!Dv(X`n+3AQwk} z%uAta6dL4^MX0!7z@`gQ=0{s@g7 z+<)lvpe{}v{SlsCJ3@Ur07rj>sMn5ApANuTUxcc&3v@jhE)&U*aM^B9w(kL59e|@h zLbp=&M7dV`I>16CKf<@ro@X!YD}YQu9CltnziR}t*(nz{xI!FuE{1n?0IL|1AF;4p zZtViQ3gALOTv~IwZ3J@S%rD|}uB|bstph9s#3|eULR$fNx#b)%L%-erLfZ)RQ9zuY z>;`Rj0FM5Mne7HWdSWL9fYdz7@$CY5DIji-)&b2mfD_xFNW*43wAlf;=mn|Rj6ux; zSO`eN=3w6}fL8+2u*vo28i4(Oq~v}bP2uJq&>F+ z_)I{W&bw}p4#ey^BM?&N=m1C>yFf_Ur}k6`E`&j{7=e&93IIu?0FX2a07;_&kTffR zI{|6BqX3YyJ4PTR%?h9pkfy>4;Ddlfu?9fO3JL%z`-wFGQg%lHAY}ywfRq*78WAZw zuNT2Agpr#2ysys$|0p0W_c>n|z&inHxzG8!015$VDeO&M04o7$xgUY*SBPOJ2&uWx z{kj9#ikO8kQnAVP=Jfz?1f*e;>&*h710WTv`h&B^7g+}&6)&{43uNxZb|~U!lTJ~8 zvVBeP_X6VfXw=^>fbRsv?a@L0Z0jFw9gvG#rXyaDa(=r5a6SMrdotob>+>KW5>H0_ zvz7<39g0|Owm)la9l*w*m4LXc)UwUL>(+?>6cCrzoIcy}V0MEL6Ps#YsO8e%XM4VZ zE`V6r;_h%j71jJzH!Te17-IlQs5UxVi_|vNE^#M31fYAMQ-#>PB02adtS)n~g zf78_gY~k<^0byEoJ)o-t7`gyL_2kU&wmz>B#+E2Tbgdp2c=hQ3#;NK8Kqxwo{-*D% zL>8h8ARO1~d3kT&4q#jvZ^c>w^VjR>Z-%^1a3OYtFn`w?aC3(t9l)?7ggGn9_)-H3 zVCXBwb6x;*X1Q4m`G%n#pk`gb37C&U9cO*p4DA55KM0tYzvuvm7Qjk8T^jRotpoG% z&=JV6AjTXN8o2On`b3BOe;GfQ@s)tnvC(Ix6S9kDJ`%(UVyPn?GXm9|I75u-6*^+{ zsU5lq*4BT7T_H@(uJkH&q+`|qP5&w5yo}!n7@wW+`nx>vF#^?W1hP@c2^ddN#uwUg zo0Fk@7JzYLJ%I68(p&Nu^2FB&RI~r!|H}AUfPa6I=Pgg)1z_y;1+23y1o&f%-q#Aq zjl$YfCgvNKOQZ0;254eY3?B-4T=Z`9aUNh7`wt&|q5?@PROQep$#2H&05vCehA>>A z2@arRc7R%;k*gTdv0V9`CYTrDJS^?($!8jsV^4d1XH+ zVwu9)f+MC0du7s06Pt1bYGNEhQ@vw2$=EX&Rs^}iI!Y1#!;bux3rTXK2vUw*oG7IT z5{+CW|Awsvc{q4Pk^WYUIOt$!nXgsd(=?Ri-W9nX#v77;%c<@Q6cVTE zwjGNyUTOZf@UVO9yjj2hPTZ4qFACr=3sr^S%6Pf{cf) z{#XIHM%d?{>EQ&DYdmgOvE1n)fs4lHAJ(5YgGBe>7ogV~bb0$7l>h($07*qoM6N<$ Ef}jU)LjV8( literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xxhdpi/icon_stop.png b/uikit/src/main/res/drawable-xxhdpi/icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..78399861a0ec708584d00e1c02019be29e25acfb GIT binary patch literal 823 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 literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xxxhdpi/icon_recording.png b/uikit/src/main/res/drawable-xxxhdpi/icon_recording.png new file mode 100644 index 0000000000000000000000000000000000000000..8fa6ed6423d34b6397833c7e864acac2fcbd1925 GIT binary patch literal 3345 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 K*0kK1di8&)HsDnN literal 0 HcmV?d00001 diff --git a/uikit/src/main/res/drawable-xxxhdpi/icon_stop.png b/uikit/src/main/res/drawable-xxxhdpi/icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe0566029d7a176f14df026e2a3c72ec8848814 GIT binary patch literal 1156 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 + + diff --git a/uikit/src/main/res/layout/sb_view_message_input.xml b/uikit/src/main/res/layout/sb_view_message_input.xml index dcc3db56..f83149cb 100644 --- a/uikit/src/main/res/layout/sb_view_message_input.xml +++ b/uikit/src/main/res/layout/sb_view_message_input.xml @@ -155,6 +155,20 @@ app:layout_constraintStart_toEndOf="@id/etInputText" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/ibtnVoiceRecorder" /> + + diff --git a/uikit/src/main/res/layout/sb_view_my_voice_message.xml b/uikit/src/main/res/layout/sb_view_my_voice_message.xml new file mode 100644 index 00000000..398aaf65 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_my_voice_message.xml @@ -0,0 +1,6 @@ + + diff --git a/uikit/src/main/res/layout/sb_view_my_voice_message_component.xml b/uikit/src/main/res/layout/sb_view_my_voice_message_component.xml new file mode 100644 index 00000000..9ba44b9e --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_my_voice_message_component.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_other_voice_message.xml b/uikit/src/main/res/layout/sb_view_other_voice_message.xml new file mode 100644 index 00000000..701d9951 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_other_voice_message.xml @@ -0,0 +1,6 @@ + + diff --git a/uikit/src/main/res/layout/sb_view_other_voice_message_component.xml b/uikit/src/main/res/layout/sb_view_other_voice_message_component.xml new file mode 100644 index 00000000..adb334c9 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_other_voice_message_component.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_parent_message_info.xml b/uikit/src/main/res/layout/sb_view_parent_message_info.xml index 41466b60..4cf40df4 100644 --- a/uikit/src/main/res/layout/sb_view_parent_message_info.xml +++ b/uikit/src/main/res/layout/sb_view_parent_message_info.xml @@ -107,6 +107,19 @@ app:layout_constraintBottom_toBottomOf="@id/contentPanel" /> + + + app:constraint_referenced_ids="tvTextMessage,fileGroup,ivThumbnail,ivThumbnailOverlay,ivThumbnailIcon,voiceMessage"/> + + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_voice_message_input.xml b/uikit/src/main/res/layout/sb_view_voice_message_input.xml new file mode 100644 index 00000000..16944662 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_voice_message_input.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/uikit/src/main/res/values/attrs.xml b/uikit/src/main/res/values/attrs.xml index fb338ca3..95f2441d 100644 --- a/uikit/src/main/res/values/attrs.xml +++ b/uikit/src/main/res/values/attrs.xml @@ -108,6 +108,8 @@ + + @@ -118,6 +120,8 @@ + + @@ -287,6 +291,10 @@ + + + + @@ -382,6 +390,9 @@ + + + @@ -536,5 +547,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uikit/src/main/res/values/dimens.xml b/uikit/src/main/res/values/dimens.xml index 7292add4..04daa4fc 100644 --- a/uikit/src/main/res/values/dimens.xml +++ b/uikit/src/main/res/values/dimens.xml @@ -166,6 +166,7 @@ 240dp 160dp 180dp + 136dp 276dp 196dp 222dp diff --git a/uikit/src/main/res/values/strings.xml b/uikit/src/main/res/values/strings.xml index 1f3e9ce6..0ebda94e 100644 --- a/uikit/src/main/res/values/strings.xml +++ b/uikit/src/main/res/values/strings.xml @@ -127,6 +127,8 @@ Couldn\'t unmute participant. Try again. Couldn\'t unban member. Try again. Couldn\'t unban participant. Try again. + You\'re muted by the operator. + Channel is frozen Couldn\'t open camera. @@ -201,4 +203,7 @@ Enter channel name Remove photo + + + Voice message diff --git a/uikit/src/main/res/values/styles.xml b/uikit/src/main/res/values/styles.xml index b291378b..4a17f07c 100755 --- a/uikit/src/main/res/values/styles.xml +++ b/uikit/src/main/res/values/styles.xml @@ -263,6 +263,8 @@ @style/Widget.Sendbird.Message.Timeline @style/Widget.Sendbird.Emoji @style/Widget.Sendbird.ThreadInfoView + @style/Widget.Sendbird.Message.Me.VoiceMessage + @style/Widget.Sendbird.Message.Other.VoiceMessage + + + + + + - + + + + + + diff --git a/uikit/src/test/java/com/sendbird/uikit/ExampleUnitTest.java b/uikit/src/test/java/com/sendbird/uikit/ExampleUnitTest.java deleted file mode 100644 index 2e9abc30..00000000 --- a/uikit/src/test/java/com/sendbird/uikit/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sendbird.uikit; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file