From f1dd0a78fa4fa219a3bf507595f951a2b6d3741c Mon Sep 17 00:00:00 2001 From: AL-Session <160798022+AL-Session@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:41:25 +1100 Subject: [PATCH 001/867] Fix/make file attachment message visible (#854) * WIP * WIP * Adjusted view_visible_message_content to show any msg w/ a document attachment above it * Cleanup * Nudge to trigger CI build * Adjusted view layout to display any text accompanying an audio file --------- Co-authored-by: alansley --- .../securesms/MediaPreviewActivity.java | 3 +- .../v2/messages/VisibleMessageContentView.kt | 22 ++++-- .../layout/view_visible_message_content.xml | 79 +++++++++---------- 3 files changed, 55 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index c714aa0eea..09c59969e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -406,14 +406,13 @@ private void forward() { @SuppressWarnings("CodeBlock2Expr") @SuppressLint("InlinedApi") private void saveToDisk() { - Log.w("ACL", "Asked to save to disk!"); MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; SaveAttachmentTask.showOneTimeWarningDialogOrSave(this, 1, () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - .maxSdkVersion(Build.VERSION_CODES.P) + .maxSdkVersion(Build.VERSION_CODES.P) // Note: P is API 28 .withPermanentDenialDialog(getPermanentlyDeniedStorageText()) .onAnyDenied(() -> { Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index a99e7b60d1..1f49d886cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -20,6 +20,8 @@ import androidx.core.view.children import androidx.core.view.isVisible import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager +import java.util.Locale +import kotlin.math.roundToInt import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -38,8 +40,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor -import java.util.Locale -import kotlin.math.roundToInt class VisibleMessageContentView : ConstraintLayout { private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } @@ -148,9 +148,13 @@ class VisibleMessageContentView : ConstraintLayout { // When in a link preview ensure the bodyTextView can expand to the full width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } + // AUDIO message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { - hideBody = true + + // Show any text message associated with the audio message (which may be a voice clip - but could also be a mp3 or such) + hideBody = false + // Audio attachment if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.voiceMessageView.root.indexInAdapter = indexInAdapter @@ -161,7 +165,7 @@ class VisibleMessageContentView : ConstraintLayout { onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } } else { - hideBody = true + // If it's an audio message but we haven't downloaded it yet show it as pending (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> binding.pendingAttachmentView.root.bind( PendingAttachmentView.AttachmentType.AUDIO, @@ -172,14 +176,17 @@ class VisibleMessageContentView : ConstraintLayout { } } } + // DOCUMENT message is MmsMessageRecord && message.slideDeck.documentSlide != null -> { - hideBody = true // TODO: check if this is still the logic we want + // Show any message that came with the attached document + hideBody = false + // Document attachment if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.documentView.root.bind(message, getTextColor(context, message)) } else { - hideBody = true + // If the document hasn't been downloaded yet then show it as pending (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> binding.pendingAttachmentView.root.bind( PendingAttachmentView.AttachmentType.DOCUMENT, @@ -190,6 +197,7 @@ class VisibleMessageContentView : ConstraintLayout { } } } + // IMAGE / VIDEO message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { if (mediaDownloaded || mediaInProgress || message.isOutgoing) { @@ -201,7 +209,7 @@ class VisibleMessageContentView : ConstraintLayout { isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - binding.albumThumbnailView.root.modifyLayoutParams { + binding.albumThumbnailView.root.modifyLayoutParams { horizontalBias = if (message.isOutgoing) 1f else 0f } onContentClick.add { event -> diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index a954c31228..2a7f6e4d0a 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -14,8 +14,8 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintHorizontal_bias="0" - app:layout_constraintEnd_toEndOf="parent" - > + app:layout_constraintEnd_toEndOf="parent" > + - - - - - - + app:barrierDirection="bottom" + app:constraint_referenced_ids="linkPreviewView,quoteView" /> + + + android:layout_height="wrap_content" + app:layout_constraintTop_toBottomOf="@+id/bodyTextView" + app:layout_constraintStart_toStartOf="parent"/> + + + + + + Date: Mon, 6 Jan 2025 01:47:00 +0200 Subject: [PATCH 002/867] Feature/lucide icons (#827) * Starting to import Lucide icons and clean up * Removing unused icons * Lucide icons + removing unsued stuff Removed the whole EMoji/MediaKeyboard classes as they didn't seem used * More Lucide icons + ui tweaks + clean up * comment * Wrong tinting * delete icon * More icons * check icons --- .../securesms/PassphrasePromptActivity.java | 28 +- .../securesms/components/ComposeText.java | 33 +-- .../securesms/components/DocumentView.java | 185 ------------ .../securesms/components/FromTextView.java | 3 +- .../securesms/components/LabeledEditText.java | 87 ------ .../components/RepeatableImageKey.java | 84 ------ .../emoji/EmojiKeyboardProvider.java | 162 ---------- .../components/emoji/EmojiPages.java | 67 ----- .../components/emoji/EmojiTextView.java | 11 +- .../components/emoji/EmojiToggle.java | 84 ------ .../components/emoji/MediaKeyboard.java | 276 ------------------ .../emoji/MediaKeyboardBottomTabAdapter.java | 97 ------ .../emoji/MediaKeyboardProvider.java | 53 ---- .../conversation/v2/ConversationActivityV2.kt | 10 +- .../v2/ConversationReactionOverlay.kt | 9 +- .../conversation/v2/MessageDetailActivity.kt | 6 +- .../conversation/v2/input_bar/InputBar.kt | 4 +- .../v2/input_bar/InputBarButton.kt | 6 +- .../v2/input_bar/InputBarRecordingView.kt | 2 +- .../v2/messages/OpenGroupInvitationView.kt | 3 + .../conversation/v2/messages/QuoteView.kt | 13 +- .../v2/messages/VisibleMessageView.kt | 7 +- .../v2/utilities/ThumbnailView.kt | 2 +- .../groups/compose/EditGroupScreen.kt | 4 +- .../home/ConversationOptionsBottomSheet.kt | 6 +- .../securesms/home/ConversationView.kt | 2 +- .../securesms/media/DocumentsPage.kt | 4 +- .../securesms/media/MediaOverviewTopAppBar.kt | 4 +- .../thoughtcrime/securesms/media/MediaPage.kt | 4 +- .../mediasend/MediaSendFragment.java | 118 ++------ .../mediasend/MediaSendViewModel.java | 19 +- .../MultipleRecipientNotificationBuilder.kt | 2 +- .../SingleRecipientNotificationBuilder.java | 6 +- .../securesms/preferences/SettingsActivity.kt | 8 +- .../securesms/ui/components/AppBar.kt | 2 +- app/src/main/res/drawable-hdpi/check.png | Bin 292 -> 0 bytes .../res/drawable-hdpi/ic_camera_filled_24.png | Bin 591 -> 0 bytes .../res/drawable-hdpi/ic_check_circle_32.png | Bin 1513 -> 0 bytes .../drawable-hdpi/ic_document_large_dark.png | Bin 1292 -> 0 bytes .../drawable-hdpi/ic_document_large_light.png | Bin 1296 -> 0 bytes .../drawable-hdpi/ic_document_small_dark.png | Bin 974 -> 0 bytes .../res/drawable-hdpi/ic_gif_white_24dp.png | Bin 196 -> 0 bytes .../main/res/drawable-hdpi/ic_image_dark.png | Bin 303 -> 0 bytes .../res/drawable-hdpi/ic_image_white_24dp.png | Bin 261 -> 0 bytes .../ic_insert_drive_file_white_24dp.png | Bin 153 -> 0 bytes .../drawable-hdpi/ic_photo_camera_dark.png | Bin 446 -> 0 bytes app/src/main/res/drawable-hdpi/ic_plus_28.png | Bin 761 -> 0 bytes app/src/main/res/drawable-hdpi/ic_reply.png | Bin 462 -> 0 bytes .../res/drawable-hdpi/ic_reply_white_36dp.png | Bin 467 -> 0 bytes app/src/main/res/drawable-mdpi/check.png | Bin 233 -> 0 bytes .../res/drawable-mdpi/ic_camera_filled_24.png | Bin 389 -> 0 bytes .../res/drawable-mdpi/ic_check_circle_32.png | Bin 809 -> 0 bytes .../drawable-mdpi/ic_document_large_dark.png | Bin 837 -> 0 bytes .../drawable-mdpi/ic_document_large_light.png | Bin 769 -> 0 bytes .../drawable-mdpi/ic_document_small_dark.png | Bin 583 -> 0 bytes .../res/drawable-mdpi/ic_gif_white_24dp.png | Bin 133 -> 0 bytes .../main/res/drawable-mdpi/ic_image_dark.png | Bin 225 -> 0 bytes .../res/drawable-mdpi/ic_image_white_24dp.png | Bin 185 -> 0 bytes .../ic_insert_drive_file_white_24dp.png | Bin 133 -> 0 bytes .../drawable-mdpi/ic_photo_camera_dark.png | Bin 353 -> 0 bytes app/src/main/res/drawable-mdpi/ic_plus_24.png | Bin 168 -> 0 bytes app/src/main/res/drawable-mdpi/ic_plus_28.png | Bin 463 -> 0 bytes app/src/main/res/drawable-mdpi/ic_reply.png | Bin 343 -> 0 bytes .../res/drawable-mdpi/ic_reply_white_36dp.png | Bin 350 -> 0 bytes app/src/main/res/drawable-xhdpi/check.png | Bin 397 -> 0 bytes .../drawable-xhdpi/ic_camera_filled_24.png | Bin 608 -> 0 bytes .../res/drawable-xhdpi/ic_check_circle_32.png | Bin 2222 -> 0 bytes .../drawable-xhdpi/ic_document_large_dark.png | Bin 1899 -> 0 bytes .../ic_document_large_light.png | Bin 1859 -> 0 bytes .../drawable-xhdpi/ic_document_small_dark.png | Bin 1327 -> 0 bytes .../res/drawable-xhdpi/ic_gif_white_24dp.png | Bin 158 -> 0 bytes .../main/res/drawable-xhdpi/ic_image_dark.png | Bin 374 -> 0 bytes .../drawable-xhdpi/ic_image_white_24dp.png | Bin 304 -> 0 bytes .../ic_insert_drive_file_white_24dp.png | Bin 206 -> 0 bytes .../drawable-xhdpi/ic_photo_camera_dark.png | Bin 641 -> 0 bytes .../main/res/drawable-xhdpi/ic_plus_24.png | Bin 248 -> 0 bytes .../main/res/drawable-xhdpi/ic_plus_28.png | Bin 1020 -> 0 bytes app/src/main/res/drawable-xhdpi/ic_reply.png | Bin 561 -> 0 bytes .../drawable-xhdpi/ic_reply_white_36dp.png | Bin 567 -> 0 bytes app/src/main/res/drawable-xxhdpi/check.png | Bin 312 -> 0 bytes .../drawable-xxhdpi/ic_camera_filled_24.png | Bin 1074 -> 0 bytes .../drawable-xxhdpi/ic_check_circle_32.png | Bin 4550 -> 0 bytes .../ic_document_large_dark.png | Bin 3059 -> 0 bytes .../ic_document_large_light.png | Bin 3091 -> 0 bytes .../ic_document_small_dark.png | Bin 2181 -> 0 bytes .../res/drawable-xxhdpi/ic_gif_white_24dp.png | Bin 213 -> 0 bytes .../res/drawable-xxhdpi/ic_image_dark.png | Bin 526 -> 0 bytes .../drawable-xxhdpi/ic_image_white_24dp.png | Bin 450 -> 0 bytes .../ic_insert_drive_file_white_24dp.png | Bin 283 -> 0 bytes .../drawable-xxhdpi/ic_photo_camera_dark.png | Bin 881 -> 0 bytes .../main/res/drawable-xxhdpi/ic_plus_24.png | Bin 348 -> 0 bytes .../main/res/drawable-xxhdpi/ic_plus_28.png | Bin 1687 -> 0 bytes app/src/main/res/drawable-xxhdpi/ic_reply.png | Bin 775 -> 0 bytes .../drawable-xxhdpi/ic_reply_white_36dp.png | Bin 814 -> 0 bytes .../drawable-xxxhdpi/ic_camera_filled_24.png | Bin 1393 -> 0 bytes .../drawable-xxxhdpi/ic_check_circle_32.png | Bin 7187 -> 0 bytes .../ic_document_large_dark.png | Bin 4576 -> 0 bytes .../ic_document_large_light.png | Bin 4559 -> 0 bytes .../ic_document_small_dark.png | Bin 3022 -> 0 bytes .../drawable-xxxhdpi/ic_gif_white_24dp.png | Bin 224 -> 0 bytes .../drawable-xxxhdpi/ic_image_white_24dp.png | Bin 570 -> 0 bytes .../ic_insert_drive_file_white_24dp.png | Bin 372 -> 0 bytes .../drawable-xxxhdpi/ic_photo_camera_dark.png | Bin 1504 -> 0 bytes .../drawable-xxxhdpi/ic_reply_white_36dp.png | Bin 1057 -> 0 bytes .../res/drawable/ic_arrow_down_to_line.xml | 9 + app/src/main/res/drawable/ic_arrow_up.xml | 20 +- .../main/res/drawable/ic_baseline_add_24.xml | 10 - .../ic_baseline_check_circle_outline_24.xml | 10 - .../res/drawable/ic_baseline_delete_24.xml | 10 - .../drawable/ic_baseline_photo_camera_24.xml | 13 - .../drawable/ic_baseline_photo_library_24.xml | 10 - .../res/drawable/ic_baseline_reply_24.xml | 10 - .../main/res/drawable/ic_baseline_save_24.xml | 10 - app/src/main/res/drawable/ic_camera.xml | 7 + app/src/main/res/drawable/ic_check.xml | 5 + app/src/main/res/drawable/ic_circle_check.xml | 20 +- .../main/res/drawable/ic_clear_messages.xml | 20 -- app/src/main/res/drawable/ic_copy.xml | 18 +- app/src/main/res/drawable/ic_delete.xml | 9 - app/src/main/res/drawable/ic_delete_24.xml | 10 - .../res/drawable/ic_disappearing_messages.xml | 16 - app/src/main/res/drawable/ic_edit_group.xml | 22 -- app/src/main/res/drawable/ic_external.xml | 12 - app/src/main/res/drawable/ic_file.xml | 7 + .../res/drawable/ic_filled_circle_check.xml | 13 - app/src/main/res/drawable/ic_gif.xml | 9 + app/src/main/res/drawable/ic_image.xml | 9 + app/src/main/res/drawable/ic_images.xml | 8 +- app/src/main/res/drawable/ic_leave_group.xml | 13 - app/src/main/res/drawable/ic_log_out.xml | 25 +- app/src/main/res/drawable/ic_mail.xml | 19 +- .../drawable/ic_message_details__reply.xml | 9 - app/src/main/res/drawable/ic_mic.xml | 9 + app/src/main/res/drawable/ic_microphone.xml | 36 --- .../res/drawable/ic_notification_settings.xml | 9 - app/src/main/res/drawable/ic_pictures.xml | 18 -- .../main/res/drawable/ic_pin_conversation.xml | 13 - app/src/main/res/drawable/ic_plus.xml | 18 +- .../main/res/drawable/ic_question_mark.xml | 15 - app/src/main/res/drawable/ic_reply.xml | 7 + .../res/drawable/ic_search_conversation.xml | 9 - .../res/drawable/ic_square_arrow_up_right.xml | 9 + app/src/main/res/drawable/ic_trash_2.xml | 13 + ...edia_keyboard_selected_background_dark.xml | 9 - ...dia_keyboard_selected_background_light.xml | 9 - ...ew_quote_attachment_preview_background.xml | 2 +- .../res/layout/activity_conversation_v2.xml | 4 +- app/src/main/res/layout/activity_home.xml | 1 + app/src/main/res/layout/document_view.xml | 113 ------- .../res/layout/emoji_keyboard_icon_dark.xml | 15 - .../emoji_keyboard_icon_dark_selected.xml | 16 - .../res/layout/emoji_keyboard_icon_light.xml | 15 - .../emoji_keyboard_icon_light_selected.xml | 16 - .../fragment_conversation_bottom_sheet.xml | 2 +- app/src/main/res/layout/image_editor_hud.xml | 7 +- app/src/main/res/layout/media_keyboard.xml | 109 ------- .../layout/media_keyboard_bottom_tab_item.xml | 23 -- .../main/res/layout/mediarail_button_item.xml | 7 +- .../main/res/layout/mediasend_activity.xml | 2 +- .../main/res/layout/mediasend_fragment.xml | 34 +-- .../res/layout/preference_external_link.xml | 2 +- .../res/layout/prompt_passphrase_activity.xml | 14 +- .../scribble_fragment_emojidrawer_stub.xml | 8 - .../main/res/layout/view_deleted_message.xml | 4 +- app/src/main/res/layout/view_document.xml | 2 +- .../res/layout/view_input_bar_recording.xml | 6 +- .../res/layout/view_open_group_invitation.xml | 2 +- app/src/main/res/layout/view_quote.xml | 3 +- app/src/main/res/layout/view_quote_draft.xml | 3 +- app/src/main/res/menu/media_preview.xml | 6 +- .../menu/menu_conversation_item_action.xml | 4 +- app/src/main/res/menu/menu_group_request.xml | 2 +- .../main/res/menu/menu_message_request.xml | 2 +- app/src/main/res/values/attrs.xml | 18 -- app/src/main/res/values/themes.xml | 17 +- .../utilities/TextSecurePreferences.kt | 11 - libsession/src/main/res/values/attrs.xml | 18 -- 177 files changed, 288 insertions(+), 2138 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java delete mode 100644 app/src/main/res/drawable-hdpi/check.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_camera_filled_24.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_check_circle_32.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_document_large_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_document_large_light.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_document_small_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_gif_white_24dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_image_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_image_white_24dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_photo_camera_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_plus_28.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_reply.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png delete mode 100644 app/src/main/res/drawable-mdpi/check.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_camera_filled_24.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_check_circle_32.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_document_large_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_document_large_light.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_document_small_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_gif_white_24dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_image_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_image_white_24dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_plus_24.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_plus_28.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_reply.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/check.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_camera_filled_24.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_check_circle_32.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_document_large_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_document_large_light.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_document_small_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_image_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_plus_24.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_plus_28.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_reply.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/check.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_camera_filled_24.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_document_large_light.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_document_small_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_image_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_plus_24.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_plus_28.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_reply.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png create mode 100644 app/src/main/res/drawable/ic_arrow_down_to_line.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_add_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_delete_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_photo_camera_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_photo_library_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_reply_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_save_24.xml create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_check.xml delete mode 100644 app/src/main/res/drawable/ic_clear_messages.xml delete mode 100644 app/src/main/res/drawable/ic_delete.xml delete mode 100644 app/src/main/res/drawable/ic_delete_24.xml delete mode 100644 app/src/main/res/drawable/ic_disappearing_messages.xml delete mode 100644 app/src/main/res/drawable/ic_edit_group.xml delete mode 100644 app/src/main/res/drawable/ic_external.xml create mode 100644 app/src/main/res/drawable/ic_file.xml delete mode 100644 app/src/main/res/drawable/ic_filled_circle_check.xml create mode 100644 app/src/main/res/drawable/ic_gif.xml create mode 100644 app/src/main/res/drawable/ic_image.xml delete mode 100644 app/src/main/res/drawable/ic_leave_group.xml delete mode 100644 app/src/main/res/drawable/ic_message_details__reply.xml create mode 100644 app/src/main/res/drawable/ic_mic.xml delete mode 100644 app/src/main/res/drawable/ic_microphone.xml delete mode 100644 app/src/main/res/drawable/ic_notification_settings.xml delete mode 100644 app/src/main/res/drawable/ic_pictures.xml delete mode 100644 app/src/main/res/drawable/ic_pin_conversation.xml delete mode 100644 app/src/main/res/drawable/ic_question_mark.xml create mode 100644 app/src/main/res/drawable/ic_reply.xml delete mode 100644 app/src/main/res/drawable/ic_search_conversation.xml create mode 100644 app/src/main/res/drawable/ic_square_arrow_up_right.xml create mode 100644 app/src/main/res/drawable/ic_trash_2.xml delete mode 100644 app/src/main/res/drawable/media_keyboard_selected_background_dark.xml delete mode 100644 app/src/main/res/drawable/media_keyboard_selected_background_light.xml delete mode 100644 app/src/main/res/layout/document_view.xml delete mode 100644 app/src/main/res/layout/emoji_keyboard_icon_dark.xml delete mode 100644 app/src/main/res/layout/emoji_keyboard_icon_dark_selected.xml delete mode 100644 app/src/main/res/layout/emoji_keyboard_icon_light.xml delete mode 100644 app/src/main/res/layout/emoji_keyboard_icon_light_selected.xml delete mode 100644 app/src/main/res/layout/media_keyboard.xml delete mode 100644 app/src/main/res/layout/media_keyboard_bottom_tab_item.xml delete mode 100644 app/src/main/res/layout/scribble_fragment_emojidrawer_stub.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index 16b5856766..f3de6a6953 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -24,6 +24,8 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.graphics.Color; +import android.graphics.ColorFilter; import android.graphics.PorterDuff; import android.os.Bundle; import android.os.IBinder; @@ -40,11 +42,13 @@ import java.security.Signature; import network.loki.messenger.R; import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.ThemeUtil; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.crypto.BiometricSecretProvider; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.AnimationCompleteListener; +import org.thoughtcrime.securesms.util.ResUtil; //TODO Rename to ScreenLockActivity and refactor to Kotlin. public class PassphrasePromptActivity extends BaseActionBarActivity { @@ -68,11 +72,17 @@ public class PassphrasePromptActivity extends BaseActionBarActivity { private KeyCachingService keyCachingService; + private int accentColor; + private int errorColor; + @Override public void onCreate(Bundle savedInstanceState) { Log.i(TAG, "onCreate()"); super.onCreate(savedInstanceState); + accentColor = ThemeUtil.getThemedColor(this, R.attr.accentColor); + errorColor = ThemeUtil.getThemedColor(this, R.attr.danger); + setContentView(R.layout.prompt_passphrase_activity); initializeResources(); @@ -171,7 +181,7 @@ private void initializeResources() { fingerprintListener = new FingerprintListener(); fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); lockScreenButton.setOnClickListener(v -> resumeScreenLock()); } @@ -251,15 +261,15 @@ public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationRes // authentication failed onAuthenticationFailed(); } else { - fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.setImageResource(R.drawable.ic_check); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { handleAuthenticated(); fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); } }).start(); } @@ -281,15 +291,15 @@ public void onAnimationEnd(Animator animation) { return; } - fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.setImageResource(R.drawable.ic_check); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { handleAuthenticated(); fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); } }).start(); } @@ -299,7 +309,7 @@ public void onAuthenticationFailed() { Log.w(TAG, "onAuthenticationFailed()"); fingerprintPrompt.setImageResource(R.drawable.ic_close_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(errorColor, PorterDuff.Mode.SRC_IN); TranslateAnimation shake = new TranslateAnimation(0, 30, 0, 0); shake.setDuration(50); @@ -311,7 +321,7 @@ public void onAnimationStart(Animation animation) {} @Override public void onAnimationEnd(Animation animation) { fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(accentColor, PorterDuff.Mode.SRC_IN); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index 2365bc843b..a0646bc532 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -15,6 +15,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; import androidx.core.os.BuildCompat; import androidx.core.view.inputmethod.EditorInfoCompat; import androidx.core.view.inputmethod.InputConnectionCompat; @@ -22,9 +23,8 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.emoji.EmojiEditText; -public class ComposeText extends EmojiEditText { +public class ComposeText extends AppCompatEditText { private CharSequence hint; private SpannableString subHint; @@ -101,31 +101,6 @@ public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { } } - public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) { - this.cursorPositionChangedListener = listener; - } - - public void setTransport() { - final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext()); - final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext()); - - int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; - int inputType = getInputType(); - - setImeActionLabel(null, 0); - - if (useSystemEmoji) { - inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE; - } - - setInputType(inputType); - if (isIncognito) { - setImeOptions(imeOptions | 16777216); - } else { - setImeOptions(imeOptions); - } - } - @Override public InputConnection onCreateInputConnection(EditorInfo editorInfo) { InputConnection inputConnection = super.onCreateInputConnection(editorInfo); @@ -141,10 +116,6 @@ public InputConnection onCreateInputConnection(EditorInfo editorInfo) { return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); } - public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) { - this.mediaListener = mediaListener; - } - private void initialize() { if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { setImeOptions(getImeOptions() | 16777216); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java deleted file mode 100644 index f51b4a7d93..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.thoughtcrime.securesms.components; - - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.PorterDuff; -import androidx.annotation.AttrRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import com.pnikosis.materialishprogress.ProgressWheel; - -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import network.loki.messenger.R; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.mms.DocumentSlide; -import org.thoughtcrime.securesms.mms.SlideClickListener; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.guava.Optional; - -public class DocumentView extends FrameLayout { - - private static final String TAG = DocumentView.class.getSimpleName(); - - private final @NonNull AnimatingToggle controlToggle; - private final @NonNull ImageView downloadButton; - private final @NonNull ProgressWheel downloadProgress; - private final @NonNull View container; - private final @NonNull ViewGroup iconContainer; - private final @NonNull TextView fileName; - private final @NonNull TextView fileSize; - private final @NonNull TextView document; - - private @Nullable SlideClickListener downloadListener; - private @Nullable SlideClickListener viewListener; - private @Nullable DocumentSlide documentSlide; - - public DocumentView(@NonNull Context context) { - this(context, null); - } - - public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { - super(context, attrs, defStyleAttr); - inflate(context, R.layout.document_view, this); - - this.container = findViewById(R.id.document_container); - this.iconContainer = findViewById(R.id.icon_container); - this.controlToggle = findViewById(R.id.control_toggle); - this.downloadButton = findViewById(R.id.download); - this.downloadProgress = findViewById(R.id.download_progress); - this.fileName = findViewById(R.id.file_name); - this.fileSize = findViewById(R.id.file_size); - this.document = findViewById(R.id.document); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DocumentView, 0, 0); - int titleColor = typedArray.getInt(R.styleable.DocumentView_doc_titleColor, Color.BLACK); - int captionColor = typedArray.getInt(R.styleable.DocumentView_doc_captionColor, Color.BLACK); - int downloadTint = typedArray.getInt(R.styleable.DocumentView_doc_downloadButtonTint, Color.WHITE); - typedArray.recycle(); - - fileName.setTextColor(titleColor); - fileSize.setTextColor(captionColor); - downloadButton.setColorFilter(downloadTint, PorterDuff.Mode.MULTIPLY); - downloadProgress.setBarColor(downloadTint); - } - } - - public void setDownloadClickListener(@Nullable SlideClickListener listener) { - this.downloadListener = listener; - } - - public void setDocumentClickListener(@Nullable SlideClickListener listener) { - this.viewListener = listener; - } - - public void setDocument(final @NonNull DocumentSlide documentSlide, - final boolean showControls) - { - if (showControls && documentSlide.isPendingDownload()) { - controlToggle.displayQuick(downloadButton); - downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide)); - if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); - } else if (showControls && documentSlide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED) { - controlToggle.displayQuick(downloadProgress); - downloadProgress.spin(); - } else { - controlToggle.displayQuick(iconContainer); - if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); - } - - this.documentSlide = documentSlide; - - this.fileName.setText(documentSlide.getFileName().or(getContext().getString(R.string.attachmentsErrorNotSupported))); - this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize())); - this.document.setText(getFileType(documentSlide.getFileName())); - this.setOnClickListener(new OpenClickedListener(documentSlide)); - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - this.downloadButton.setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - this.downloadButton.setClickable(clickable); - } - - @Override - public void setEnabled(boolean enabled) { - super.setEnabled(enabled); - this.downloadButton.setEnabled(enabled); - } - - private @NonNull String getFileType(Optional fileName) { - if (!fileName.isPresent()) return ""; - - String[] parts = fileName.get().split("\\."); - - if (parts.length < 2) { - return ""; - } - - String suffix = parts[parts.length - 1]; - - if (suffix.length() <= 3) { - return suffix; - } - - return ""; - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventAsync(final PartProgressEvent event) { - if (documentSlide != null && event.attachment.equals(documentSlide.asAttachment())) { - downloadProgress.setInstantProgress(((float) event.progress) / event.total); - } - } - - private class DownloadClickedListener implements View.OnClickListener { - private final @NonNull DocumentSlide slide; - - private DownloadClickedListener(@NonNull DocumentSlide slide) { - this.slide = slide; - } - - @Override - public void onClick(View v) { - if (downloadListener != null) downloadListener.onClick(v, slide); - } - } - - private class OpenClickedListener implements View.OnClickListener { - private final @NonNull DocumentSlide slide; - - private OpenClickedListener(@NonNull DocumentSlide slide) { - this.slide = slide; - } - - @Override - public void onClick(View v) { - if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) { - viewListener.onClick(v, slide); - } - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index ae9f5e6e70..bb23723dc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -14,8 +14,9 @@ import android.util.AttributeSet; import network.loki.messenger.R; -import org.thoughtcrime.securesms.components.emoji.EmojiTextView; + import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.util.ResUtil; import org.session.libsession.utilities.CenterAlignedRelativeSizeSpan; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java deleted file mode 100644 index 11c9fcaf1c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.Editable; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.TextView; - -import network.loki.messenger.R; - -public class LabeledEditText extends FrameLayout implements View.OnFocusChangeListener { - - private TextView label; - private EditText input; - private View border; - private ViewGroup textContainer; - - public LabeledEditText(@NonNull Context context) { - super(context); - init(null); - } - - public LabeledEditText(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.labeled_edit_text, this); - - String labelText = ""; - int backgroundColor = Color.BLACK; - int textLayout = R.layout.labeled_edit_text_default; - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LabeledEditText, 0, 0); - - labelText = typedArray.getString(R.styleable.LabeledEditText_labeledEditText_label); - backgroundColor = typedArray.getColor(R.styleable.LabeledEditText_labeledEditText_background, Color.BLACK); - textLayout = typedArray.getResourceId(R.styleable.LabeledEditText_labeledEditText_textLayout, R.layout.labeled_edit_text_default); - - typedArray.recycle(); - } - - label = findViewById(R.id.label); - border = findViewById(R.id.border); - textContainer = findViewById(R.id.text_container); - - inflate(getContext(), textLayout, textContainer); - input = findViewById(R.id.input); - - label.setText(labelText); - label.setBackgroundColor(backgroundColor); - - if (TextUtils.isEmpty(labelText)) { - label.setVisibility(INVISIBLE); - } - - input.setOnFocusChangeListener(this); - } - - public EditText getInput() { - return input; - } - - public void setText(String text) { - input.setText(text); - } - - public Editable getText() { - return input.getText(); - } - - @Override - public void onFocusChange(View v, boolean hasFocus) { - border.setBackgroundResource(hasFocus ? R.drawable.labeled_edit_text_background_active - : R.drawable.labeled_edit_text_background_inactive); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java b/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java deleted file mode 100644 index 39ddf092cb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import androidx.appcompat.widget.AppCompatImageButton; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; - -public class RepeatableImageKey extends AppCompatImageButton { - - private KeyEventListener listener; - - public RepeatableImageKey(Context context) { - super(context); - init(); - } - - public RepeatableImageKey(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public RepeatableImageKey(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - setOnClickListener(new RepeaterClickListener()); - setOnTouchListener(new RepeaterTouchListener()); - } - - public void setOnKeyEventListener(KeyEventListener listener) { - this.listener = listener; - } - - private void notifyListener() { - if (this.listener != null) this.listener.onKeyEvent(); - } - - private class RepeaterClickListener implements OnClickListener { - @Override public void onClick(View v) { - notifyListener(); - } - } - - private class Repeater implements Runnable { - @Override - public void run() { - notifyListener(); - postDelayed(this, ViewConfiguration.getKeyRepeatDelay()); - } - } - - private class RepeaterTouchListener implements OnTouchListener { - private final Repeater repeater; - - RepeaterTouchListener() { - this.repeater = new Repeater(); - } - - @Override - public boolean onTouch(View view, MotionEvent motionEvent) { - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_DOWN: - view.postDelayed(repeater, ViewConfiguration.getKeyRepeatTimeout()); - performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - return false; - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - view.removeCallbacks(repeater); - return false; - default: - return false; - } - } - } - - public interface KeyEventListener { - void onKeyEvent(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java deleted file mode 100644 index d34db1d810..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - - -import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener; -import com.bumptech.glide.RequestManager; -import org.thoughtcrime.securesms.util.ResUtil; - -import org.session.libsession.utilities.ThemeUtil; - -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -/** - * A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}. - */ -public class EmojiKeyboardProvider implements MediaKeyboardProvider, - MediaKeyboardProvider.TabIconProvider, - MediaKeyboardProvider.BackspaceObserver, - VariationSelectorListener -{ - private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); - - private final Context context; - private final List models; - private final RecentEmojiPageModel recentModel; - private final EmojiPagerAdapter emojiPagerAdapter; - private final EmojiEventListener emojiEventListener; - - private Controller controller; - - public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) { - this.context = context; - this.emojiEventListener = emojiEventListener; - this.models = new LinkedList<>(); - this.recentModel = new RecentEmojiPageModel(context); - this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() { - @Override - public void onEmojiSelected(String emoji) { - recentModel.onCodePointSelected(emoji); - - if (emojiEventListener != null) { - emojiEventListener.onEmojiSelected(emoji); - } - } - - @Override - public void onKeyEvent(KeyEvent keyEvent) { - if (emojiEventListener != null) { - emojiEventListener.onKeyEvent(keyEvent); - } - } - }, this); - - models.add(recentModel); - models.addAll(EmojiPages.DISPLAY_PAGES); - } - - @Override - public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) { - presenter.present(this, emojiPagerAdapter, this, this, null, null, recentModel.getEmoji().size() > 0 ? 0 : 1); - } - - @Override - public void setController(@Nullable Controller controller) { - this.controller = controller; - } - - @Override - public int getProviderIconView(boolean selected) { - if (selected) { - return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark_selected : R.layout.emoji_keyboard_icon_light_selected; - } else { - return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark : R.layout.emoji_keyboard_icon_light; - } - } - - @Override - public void loadCategoryTabIcon(@NonNull RequestManager glideRequests, @NonNull ImageView imageView, int index) { - Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr()); - imageView.setImageDrawable(drawable); - } - - @Override - public void onBackspaceClicked() { - if (emojiEventListener != null) { - emojiEventListener.onKeyEvent(DELETE_KEY_EVENT); - } - } - - @Override - public void onVariationSelectorStateChanged(boolean open) { - if (controller != null) { - controller.setViewPagerEnabled(!open); - } - } - - @Override - public boolean equals(@Nullable Object obj) { - return obj instanceof EmojiKeyboardProvider; - } - - private static class EmojiPagerAdapter extends PagerAdapter { - private Context context; - private List pages; - private EmojiEventListener emojiSelectionListener; - private VariationSelectorListener variationSelectorListener; - - public EmojiPagerAdapter(@NonNull Context context, - @NonNull List pages, - @NonNull EmojiEventListener emojiSelectionListener, - @NonNull VariationSelectorListener variationSelectorListener) - { - super(); - this.context = context; - this.pages = pages; - this.emojiSelectionListener = emojiSelectionListener; - this.variationSelectorListener = variationSelectorListener; - } - - @Override - public int getCount() { - return pages.size(); - } - - @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, false); - container.addView(page); - return page; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object object) { - container.removeView((View)object); - } - - @Override - public void setPrimaryItem(ViewGroup container, int position, Object object) { - EmojiPageView current = (EmojiPageView) object; - current.onSelected(); - super.setPrimaryItem(container, position, object); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java deleted file mode 100644 index dddcb56a8e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.net.Uri; -import network.loki.messenger.R; -import org.thoughtcrime.securesms.emoji.EmojiCategory; -import java.util.Arrays; -import java.util.List; - -class EmojiPages { - - private static final EmojiPageModel PAGE_PEOPLE_0 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83d\ude00"), new Emoji("\ud83d\ude01"), new Emoji("\ud83d\ude02"), new Emoji("\ud83e\udd23"), new Emoji("\ud83d\ude03"), new Emoji("\ud83d\ude04"), new Emoji("\ud83d\ude05"), new Emoji("\ud83d\ude06"), new Emoji("\ud83d\ude09"), new Emoji("\ud83d\ude0a"), new Emoji("\ud83d\ude0b"), new Emoji("\ud83d\ude0e"), new Emoji("\ud83d\ude0d"), new Emoji("\ud83d\ude18"), new Emoji("\ud83d\ude17"), new Emoji("\ud83d\ude19"), new Emoji("\ud83d\ude1a"), new Emoji("\u263a\ufe0f"), new Emoji("\ud83d\ude42"), new Emoji("\ud83e\udd17"), new Emoji("\ud83e\udd29"), new Emoji("\ud83e\udd14"), new Emoji("\ud83e\udd28"), new Emoji("\ud83d\ude10"), new Emoji("\ud83d\ude11"), new Emoji("\ud83d\ude36"), new Emoji("\ud83d\ude44"), new Emoji("\ud83d\ude0f"), new Emoji("\ud83d\ude23"), new Emoji("\ud83d\ude25"), new Emoji("\ud83d\ude2e"), new Emoji("\ud83e\udd10"), new Emoji("\ud83d\ude2f"), new Emoji("\ud83d\ude2a"), new Emoji("\ud83d\ude2b"), new Emoji("\ud83d\ude34"), new Emoji("\ud83d\ude0c"), new Emoji("\ud83d\ude1b"), new Emoji("\ud83d\ude1c"), new Emoji("\ud83d\ude1d"), new Emoji("\ud83e\udd24"), new Emoji("\ud83d\ude12"), new Emoji("\ud83d\ude13"), new Emoji("\ud83d\ude14"), new Emoji("\ud83d\ude15"), new Emoji("\ud83d\ude43"), new Emoji("\ud83e\udd11"), new Emoji("\ud83d\ude32"), new Emoji("\u2639\ufe0f"), new Emoji("\ud83d\ude41"), new Emoji("\ud83d\ude16"), new Emoji("\ud83d\ude1e"), new Emoji("\ud83d\ude1f"), new Emoji("\ud83d\ude24"), new Emoji("\ud83d\ude22"), new Emoji("\ud83d\ude2d"), new Emoji("\ud83d\ude26"), new Emoji("\ud83d\ude27"), new Emoji("\ud83d\ude28"), new Emoji("\ud83d\ude29"), new Emoji("\ud83e\udd2f"), new Emoji("\ud83d\ude2c"), new Emoji("\ud83d\ude30"), new Emoji("\ud83d\ude31"), new Emoji("\ud83d\ude33"), new Emoji("\ud83e\udd2a"), new Emoji("\ud83d\ude35"), new Emoji("\ud83d\ude21"), new Emoji("\ud83d\ude20"), new Emoji("\ud83e\udd2c"), new Emoji("\ud83d\ude37"), new Emoji("\ud83e\udd12"), new Emoji("\ud83e\udd15"), new Emoji("\ud83e\udd22"), new Emoji("\ud83e\udd2e"), new Emoji("\ud83e\udd27"), new Emoji("\ud83d\ude07"), new Emoji("\ud83e\udd20"), new Emoji("\ud83e\udd21"), new Emoji("\ud83e\udd25"), new Emoji("\ud83e\udd2b"), new Emoji("\ud83e\udd2d"), new Emoji("\ud83e\uddd0"), new Emoji("\ud83e\udd13"), new Emoji("\ud83d\ude08"), new Emoji("\ud83d\udc7f"), new Emoji("\ud83d\udc79"), new Emoji("\ud83d\udc7a"), new Emoji("\ud83d\udc80"), new Emoji("\u2620\ufe0f"), new Emoji("\ud83d\udc7b"), new Emoji("\ud83d\udc7d"), new Emoji("\ud83d\udc7e"), new Emoji("\ud83e\udd16"), new Emoji("\ud83d\udca9"), new Emoji("\ud83d\ude3a"), new Emoji("\ud83d\ude38"), new Emoji("\ud83d\ude39"), new Emoji("\ud83d\ude3b"), new Emoji("\ud83d\ude3c"), new Emoji("\ud83d\ude3d"), new Emoji("\ud83d\ude40"), new Emoji("\ud83d\ude3f"), new Emoji("\ud83d\ude3e"), new Emoji("\ud83d\ude48"), new Emoji("\ud83d\ude49"), new Emoji("\ud83d\ude4a"), new Emoji("\ud83d\udc76", "\ud83d\udc76\ud83c\udffb", "\ud83d\udc76\ud83c\udffc", "\ud83d\udc76\ud83c\udffd", "\ud83d\udc76\ud83c\udffe", "\ud83d\udc76\ud83c\udfff"), new Emoji("\ud83e\uddd2", "\ud83e\uddd2\ud83c\udffb", "\ud83e\uddd2\ud83c\udffc", "\ud83e\uddd2\ud83c\udffd", "\ud83e\uddd2\ud83c\udffe", "\ud83e\uddd2\ud83c\udfff"), new Emoji("\ud83d\udc66", "\ud83d\udc66\ud83c\udffb", "\ud83d\udc66\ud83c\udffc", "\ud83d\udc66\ud83c\udffd", "\ud83d\udc66\ud83c\udffe", "\ud83d\udc66\ud83c\udfff"), new Emoji("\ud83d\udc67", "\ud83d\udc67\ud83c\udffb", "\ud83d\udc67\ud83c\udffc", "\ud83d\udc67\ud83c\udffd", "\ud83d\udc67\ud83c\udffe", "\ud83d\udc67\ud83c\udfff"), new Emoji("\ud83e\uddd1", "\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udfff"), new Emoji("\ud83d\udc68", "\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udfff"), new Emoji("\ud83d\udc69", "\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udfff"), new Emoji("\ud83e\uddd3", "\ud83e\uddd3\ud83c\udffb", "\ud83e\uddd3\ud83c\udffc", "\ud83e\uddd3\ud83c\udffd", "\ud83e\uddd3\ud83c\udffe", "\ud83e\uddd3\ud83c\udfff"), new Emoji("\ud83d\udc74", "\ud83d\udc74\ud83c\udffb", "\ud83d\udc74\ud83c\udffc", "\ud83d\udc74\ud83c\udffd", "\ud83d\udc74\ud83c\udffe", "\ud83d\udc74\ud83c\udfff"), new Emoji("\ud83d\udc75", "\ud83d\udc75\ud83c\udffb", "\ud83d\udc75\ud83c\udffc", "\ud83d\udc75\ud83c\udffd", "\ud83d\udc75\ud83c\udffe", "\ud83d\udc75\ud83c\udfff"), new Emoji("\ud83d\udc68\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc69\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc68\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc69\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc68\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc69\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc68\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc69\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc68\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc69\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc68\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc69\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc68\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc69\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc68\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc69\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc68\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc69\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc6e\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6e\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2640\ufe0f") - ), Uri.parse("emoji/People_0.png")); - - private static final EmojiPageModel PAGE_PEOPLE_1 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83d\udc82\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc82\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc77\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc77\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd34", "\ud83e\udd34\ud83c\udffb", "\ud83e\udd34\ud83c\udffc", "\ud83e\udd34\ud83c\udffd", "\ud83e\udd34\ud83c\udffe", "\ud83e\udd34\ud83c\udfff"), new Emoji("\ud83d\udc78", "\ud83d\udc78\ud83c\udffb", "\ud83d\udc78\ud83c\udffc", "\ud83d\udc78\ud83c\udffd", "\ud83d\udc78\ud83c\udffe", "\ud83d\udc78\ud83c\udfff"), new Emoji("\ud83d\udc73\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc73\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc72", "\ud83d\udc72\ud83c\udffb", "\ud83d\udc72\ud83c\udffc", "\ud83d\udc72\ud83c\udffd", "\ud83d\udc72\ud83c\udffe", "\ud83d\udc72\ud83c\udfff"), new Emoji("\ud83e\uddd5", "\ud83e\uddd5\ud83c\udffb", "\ud83e\uddd5\ud83c\udffc", "\ud83e\uddd5\ud83c\udffd", "\ud83e\uddd5\ud83c\udffe", "\ud83e\uddd5\ud83c\udfff"), new Emoji("\ud83e\uddd4", "\ud83e\uddd4\ud83c\udffb", "\ud83e\uddd4\ud83c\udffc", "\ud83e\uddd4\ud83c\udffd", "\ud83e\uddd4\ud83c\udffe", "\ud83e\uddd4\ud83c\udfff"), new Emoji("\ud83d\udc71\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc71\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd35", "\ud83e\udd35\ud83c\udffb", "\ud83e\udd35\ud83c\udffc", "\ud83e\udd35\ud83c\udffd", "\ud83e\udd35\ud83c\udffe", "\ud83e\udd35\ud83c\udfff"), new Emoji("\ud83d\udc70", "\ud83d\udc70\ud83c\udffb", "\ud83d\udc70\ud83c\udffc", "\ud83d\udc70\ud83c\udffd", "\ud83d\udc70\ud83c\udffe", "\ud83d\udc70\ud83c\udfff"), new Emoji("\ud83e\udd30", "\ud83e\udd30\ud83c\udffb", "\ud83e\udd30\ud83c\udffc", "\ud83e\udd30\ud83c\udffd", "\ud83e\udd30\ud83c\udffe", "\ud83e\udd30\ud83c\udfff"), new Emoji("\ud83e\udd31", "\ud83e\udd31\ud83c\udffb", "\ud83e\udd31\ud83c\udffc", "\ud83e\udd31\ud83c\udffd", "\ud83e\udd31\ud83c\udffe", "\ud83e\udd31\ud83c\udfff"), new Emoji("\ud83d\udc7c", "\ud83d\udc7c\ud83c\udffb", "\ud83d\udc7c\ud83c\udffc", "\ud83d\udc7c\ud83c\udffd", "\ud83d\udc7c\ud83c\udffe", "\ud83d\udc7c\ud83c\udfff"), new Emoji("\ud83c\udf85", "\ud83c\udf85\ud83c\udffb", "\ud83c\udf85\ud83c\udffc", "\ud83c\udf85\ud83c\udffd", "\ud83c\udf85\ud83c\udffe", "\ud83c\udf85\ud83c\udfff"), new Emoji("\ud83e\udd36", "\ud83e\udd36\ud83c\udffb", "\ud83e\udd36\ud83c\udffc", "\ud83e\udd36\ud83c\udffd", "\ud83e\udd36\ud83c\udffe", "\ud83e\udd36\ud83c\udfff"), new Emoji("\ud83e\uddd9\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd9\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4d\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4d\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd26", "\ud83e\udd26\ud83c\udffb", "\ud83e\udd26\ud83c\udffc", "\ud83e\udd26\ud83c\udffd", "\ud83e\udd26\ud83c\udffe", "\ud83e\udd26\ud83c\udfff"), new Emoji("\ud83e\udd26\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd26\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd37", "\ud83e\udd37\ud83c\udffb", "\ud83e\udd37\ud83c\udffc", "\ud83e\udd37\ud83c\udffd", "\ud83e\udd37\ud83c\udffe", "\ud83e\udd37\ud83c\udfff"), new Emoji("\ud83e\udd37\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd37\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc87\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc87\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfc3\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc3\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc83", "\ud83d\udc83\ud83c\udffb", "\ud83d\udc83\ud83c\udffc", "\ud83d\udc83\ud83c\udffd", "\ud83d\udc83\ud83c\udffe", "\ud83d\udc83\ud83c\udfff"), new Emoji("\ud83d\udd7a", "\ud83d\udd7a\ud83c\udffb", "\ud83d\udd7a\ud83c\udffc", "\ud83d\udd7a\ud83c\udffd", "\ud83d\udd7a\ud83c\udffe", "\ud83d\udd7a\ud83c\udfff"), new Emoji("\ud83d\udc6f\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6f\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd7\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2640\ufe0f") - ), Uri.parse("emoji/People_1.png")); - - private static final EmojiPageModel PAGE_PEOPLE_2 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83e\uddd7\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udec0", "\ud83d\udec0\ud83c\udffb", "\ud83d\udec0\ud83c\udffc", "\ud83d\udec0\ud83c\udffd", "\ud83d\udec0\ud83c\udffe", "\ud83d\udec0\ud83c\udfff"), new Emoji("\ud83d\udecc", "\ud83d\udecc\ud83c\udffb", "\ud83d\udecc\ud83c\udffc", "\ud83d\udecc\ud83c\udffd", "\ud83d\udecc\ud83c\udffe", "\ud83d\udecc\ud83c\udfff"), new Emoji("\ud83d\udd74\ufe0f", "\ud83d\udd74\ud83c\udffb", "\ud83d\udd74\ud83c\udffc", "\ud83d\udd74\ud83c\udffd", "\ud83d\udd74\ud83c\udffe", "\ud83d\udd74\ud83c\udfff"), new Emoji("\ud83d\udde3\ufe0f"), new Emoji("\ud83d\udc64"), new Emoji("\ud83d\udc65"), new Emoji("\ud83e\udd3a"), new Emoji("\ud83c\udfc7", "\ud83c\udfc7\ud83c\udffb", "\ud83c\udfc7\ud83c\udffc", "\ud83c\udfc7\ud83c\udffd", "\ud83c\udfc7\ud83c\udffe", "\ud83c\udfc7\ud83c\udfff"), new Emoji("\u26f7\ufe0f"), new Emoji("\ud83c\udfc2", "\ud83c\udfc2\ud83c\udffb", "\ud83c\udfc2\ud83c\udffc", "\ud83c\udfc2\ud83c\udffd", "\ud83c\udfc2\ud83c\udffe", "\ud83c\udfc2\ud83c\udfff"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2642\ufe0f", "\u26f9\ud83c\udffb\u200d\u2642\ufe0f", "\u26f9\ud83c\udffc\u200d\u2642\ufe0f", "\u26f9\ud83c\udffd\u200d\u2642\ufe0f", "\u26f9\ud83c\udffe\u200d\u2642\ufe0f", "\u26f9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2640\ufe0f", "\u26f9\ud83c\udffb\u200d\u2640\ufe0f", "\u26f9\ud83c\udffc\u200d\u2640\ufe0f", "\u26f9\ud83c\udffd\u200d\u2640\ufe0f", "\u26f9\ud83c\udffe\u200d\u2640\ufe0f", "\u26f9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb4\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb4\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfce\ufe0f"), new Emoji("\ud83c\udfcd\ufe0f"), new Emoji("\ud83e\udd38", "\ud83e\udd38\ud83c\udffb", "\ud83e\udd38\ud83c\udffc", "\ud83e\udd38\ud83c\udffd", "\ud83e\udd38\ud83c\udffe", "\ud83e\udd38\ud83c\udfff"), new Emoji("\ud83e\udd38\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd38\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3c"), new Emoji("\ud83e\udd3c\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3c\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3d", "\ud83e\udd3d\ud83c\udffb", "\ud83e\udd3d\ud83c\udffc", "\ud83e\udd3d\ud83c\udffd", "\ud83e\udd3d\ud83c\udffe", "\ud83e\udd3d\ud83c\udfff"), new Emoji("\ud83e\udd3d\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3d\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3e", "\ud83e\udd3e\ud83c\udffb", "\ud83e\udd3e\ud83c\udffc", "\ud83e\udd3e\ud83c\udffd", "\ud83e\udd3e\ud83c\udffe", "\ud83e\udd3e\ud83c\udfff"), new Emoji("\ud83e\udd3e\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3e\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd39", "\ud83e\udd39\ud83c\udffb", "\ud83e\udd39\ud83c\udffc", "\ud83e\udd39\ud83c\udffd", "\ud83e\udd39\ud83c\udffe", "\ud83e\udd39\ud83c\udfff"), new Emoji("\ud83e\udd39\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd39\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc6b"), new Emoji("\ud83d\udc6c"), new Emoji("\ud83d\udc6d"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc69"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83e\udd33", "\ud83e\udd33\ud83c\udffb", "\ud83e\udd33\ud83c\udffc", "\ud83e\udd33\ud83c\udffd", "\ud83e\udd33\ud83c\udffe", "\ud83e\udd33\ud83c\udfff"), new Emoji("\ud83d\udcaa", "\ud83d\udcaa\ud83c\udffb", "\ud83d\udcaa\ud83c\udffc", "\ud83d\udcaa\ud83c\udffd", "\ud83d\udcaa\ud83c\udffe", "\ud83d\udcaa\ud83c\udfff"), new Emoji("\ud83d\udc48", "\ud83d\udc48\ud83c\udffb", "\ud83d\udc48\ud83c\udffc", "\ud83d\udc48\ud83c\udffd", "\ud83d\udc48\ud83c\udffe", "\ud83d\udc48\ud83c\udfff"), new Emoji("\ud83d\udc49", "\ud83d\udc49\ud83c\udffb", "\ud83d\udc49\ud83c\udffc", "\ud83d\udc49\ud83c\udffd", "\ud83d\udc49\ud83c\udffe", "\ud83d\udc49\ud83c\udfff"), new Emoji("\u261d\ufe0f", "\u261d\ud83c\udffb", "\u261d\ud83c\udffc", "\u261d\ud83c\udffd", "\u261d\ud83c\udffe", "\u261d\ud83c\udfff"), new Emoji("\ud83d\udc46", "\ud83d\udc46\ud83c\udffb", "\ud83d\udc46\ud83c\udffc", "\ud83d\udc46\ud83c\udffd", "\ud83d\udc46\ud83c\udffe", "\ud83d\udc46\ud83c\udfff"), new Emoji("\ud83d\udd95", "\ud83d\udd95\ud83c\udffb", "\ud83d\udd95\ud83c\udffc", "\ud83d\udd95\ud83c\udffd", "\ud83d\udd95\ud83c\udffe", "\ud83d\udd95\ud83c\udfff"), new Emoji("\ud83d\udc47", "\ud83d\udc47\ud83c\udffb", "\ud83d\udc47\ud83c\udffc", "\ud83d\udc47\ud83c\udffd", "\ud83d\udc47\ud83c\udffe", "\ud83d\udc47\ud83c\udfff"), new Emoji("\u270c\ufe0f", "\u270c\ud83c\udffb", "\u270c\ud83c\udffc", "\u270c\ud83c\udffd", "\u270c\ud83c\udffe", "\u270c\ud83c\udfff"), new Emoji("\ud83e\udd1e", "\ud83e\udd1e\ud83c\udffb", "\ud83e\udd1e\ud83c\udffc", "\ud83e\udd1e\ud83c\udffd", "\ud83e\udd1e\ud83c\udffe", "\ud83e\udd1e\ud83c\udfff"), new Emoji("\ud83d\udd96", "\ud83d\udd96\ud83c\udffb", "\ud83d\udd96\ud83c\udffc", "\ud83d\udd96\ud83c\udffd", "\ud83d\udd96\ud83c\udffe", "\ud83d\udd96\ud83c\udfff"), new Emoji("\ud83e\udd18", "\ud83e\udd18\ud83c\udffb", "\ud83e\udd18\ud83c\udffc", "\ud83e\udd18\ud83c\udffd", "\ud83e\udd18\ud83c\udffe", "\ud83e\udd18\ud83c\udfff"), new Emoji("\ud83e\udd19", "\ud83e\udd19\ud83c\udffb", "\ud83e\udd19\ud83c\udffc", "\ud83e\udd19\ud83c\udffd", "\ud83e\udd19\ud83c\udffe", "\ud83e\udd19\ud83c\udfff"), new Emoji("\ud83d\udd90\ufe0f", "\ud83d\udd90\ud83c\udffb", "\ud83d\udd90\ud83c\udffc", "\ud83d\udd90\ud83c\udffd", "\ud83d\udd90\ud83c\udffe", "\ud83d\udd90\ud83c\udfff"), new Emoji("\u270b", "\u270b\ud83c\udffb", "\u270b\ud83c\udffc", "\u270b\ud83c\udffd", "\u270b\ud83c\udffe", "\u270b\ud83c\udfff"), new Emoji("\ud83d\udc4c", "\ud83d\udc4c\ud83c\udffb", "\ud83d\udc4c\ud83c\udffc", "\ud83d\udc4c\ud83c\udffd", "\ud83d\udc4c\ud83c\udffe", "\ud83d\udc4c\ud83c\udfff"), new Emoji("\ud83d\udc4d", "\ud83d\udc4d\ud83c\udffb", "\ud83d\udc4d\ud83c\udffc", "\ud83d\udc4d\ud83c\udffd", "\ud83d\udc4d\ud83c\udffe", "\ud83d\udc4d\ud83c\udfff"), new Emoji("\ud83d\udc4e", "\ud83d\udc4e\ud83c\udffb", "\ud83d\udc4e\ud83c\udffc", "\ud83d\udc4e\ud83c\udffd", "\ud83d\udc4e\ud83c\udffe", "\ud83d\udc4e\ud83c\udfff"), new Emoji("\u270a", "\u270a\ud83c\udffb", "\u270a\ud83c\udffc", "\u270a\ud83c\udffd", "\u270a\ud83c\udffe", "\u270a\ud83c\udfff"), new Emoji("\ud83d\udc4a", "\ud83d\udc4a\ud83c\udffb", "\ud83d\udc4a\ud83c\udffc", "\ud83d\udc4a\ud83c\udffd", "\ud83d\udc4a\ud83c\udffe", "\ud83d\udc4a\ud83c\udfff") - ), Uri.parse("emoji/People_2.png")); - - private static final EmojiPageModel PAGE_PEOPLE_3 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83e\udd1b", "\ud83e\udd1b\ud83c\udffb", "\ud83e\udd1b\ud83c\udffc", "\ud83e\udd1b\ud83c\udffd", "\ud83e\udd1b\ud83c\udffe", "\ud83e\udd1b\ud83c\udfff"), new Emoji("\ud83e\udd1c", "\ud83e\udd1c\ud83c\udffb", "\ud83e\udd1c\ud83c\udffc", "\ud83e\udd1c\ud83c\udffd", "\ud83e\udd1c\ud83c\udffe", "\ud83e\udd1c\ud83c\udfff"), new Emoji("\ud83e\udd1a", "\ud83e\udd1a\ud83c\udffb", "\ud83e\udd1a\ud83c\udffc", "\ud83e\udd1a\ud83c\udffd", "\ud83e\udd1a\ud83c\udffe", "\ud83e\udd1a\ud83c\udfff"), new Emoji("\ud83d\udc4b", "\ud83d\udc4b\ud83c\udffb", "\ud83d\udc4b\ud83c\udffc", "\ud83d\udc4b\ud83c\udffd", "\ud83d\udc4b\ud83c\udffe", "\ud83d\udc4b\ud83c\udfff"), new Emoji("\ud83e\udd1f", "\ud83e\udd1f\ud83c\udffb", "\ud83e\udd1f\ud83c\udffc", "\ud83e\udd1f\ud83c\udffd", "\ud83e\udd1f\ud83c\udffe", "\ud83e\udd1f\ud83c\udfff"), new Emoji("\u270d\ufe0f", "\u270d\ud83c\udffb", "\u270d\ud83c\udffc", "\u270d\ud83c\udffd", "\u270d\ud83c\udffe", "\u270d\ud83c\udfff"), new Emoji("\ud83d\udc4f", "\ud83d\udc4f\ud83c\udffb", "\ud83d\udc4f\ud83c\udffc", "\ud83d\udc4f\ud83c\udffd", "\ud83d\udc4f\ud83c\udffe", "\ud83d\udc4f\ud83c\udfff"), new Emoji("\ud83d\udc50", "\ud83d\udc50\ud83c\udffb", "\ud83d\udc50\ud83c\udffc", "\ud83d\udc50\ud83c\udffd", "\ud83d\udc50\ud83c\udffe", "\ud83d\udc50\ud83c\udfff"), new Emoji("\ud83d\ude4c", "\ud83d\ude4c\ud83c\udffb", "\ud83d\ude4c\ud83c\udffc", "\ud83d\ude4c\ud83c\udffd", "\ud83d\ude4c\ud83c\udffe", "\ud83d\ude4c\ud83c\udfff"), new Emoji("\ud83e\udd32", "\ud83e\udd32\ud83c\udffb", "\ud83e\udd32\ud83c\udffc", "\ud83e\udd32\ud83c\udffd", "\ud83e\udd32\ud83c\udffe", "\ud83e\udd32\ud83c\udfff"), new Emoji("\ud83d\ude4f", "\ud83d\ude4f\ud83c\udffb", "\ud83d\ude4f\ud83c\udffc", "\ud83d\ude4f\ud83c\udffd", "\ud83d\ude4f\ud83c\udffe", "\ud83d\ude4f\ud83c\udfff"), new Emoji("\ud83e\udd1d"), new Emoji("\ud83d\udc85", "\ud83d\udc85\ud83c\udffb", "\ud83d\udc85\ud83c\udffc", "\ud83d\udc85\ud83c\udffd", "\ud83d\udc85\ud83c\udffe", "\ud83d\udc85\ud83c\udfff"), new Emoji("\ud83d\udc42", "\ud83d\udc42\ud83c\udffb", "\ud83d\udc42\ud83c\udffc", "\ud83d\udc42\ud83c\udffd", "\ud83d\udc42\ud83c\udffe", "\ud83d\udc42\ud83c\udfff"), new Emoji("\ud83d\udc43", "\ud83d\udc43\ud83c\udffb", "\ud83d\udc43\ud83c\udffc", "\ud83d\udc43\ud83c\udffd", "\ud83d\udc43\ud83c\udffe", "\ud83d\udc43\ud83c\udfff"), new Emoji("\ud83d\udc63"), new Emoji("\ud83d\udc40"), new Emoji("\ud83d\udc41\ufe0f"), new Emoji("\ud83d\udc41\ufe0f\u200d\ud83d\udde8\ufe0f"), new Emoji("\ud83e\udde0"), new Emoji("\ud83d\udc45"), new Emoji("\ud83d\udc44"), new Emoji("\ud83d\udc8b"), new Emoji("\ud83d\udc98"), new Emoji("\u2764\ufe0f"), new Emoji("\ud83d\udc93"), new Emoji("\ud83d\udc94"), new Emoji("\ud83d\udc95"), new Emoji("\ud83d\udc96"), new Emoji("\ud83d\udc97"), new Emoji("\ud83d\udc99"), new Emoji("\ud83d\udc9a"), new Emoji("\ud83d\udc9b"), new Emoji("\ud83e\udde1"), new Emoji("\ud83d\udc9c"), new Emoji("\ud83d\udda4"), new Emoji("\ud83d\udc9d"), new Emoji("\ud83d\udc9e"), new Emoji("\ud83d\udc9f"), new Emoji("\u2763\ufe0f"), new Emoji("\ud83d\udc8c"), new Emoji("\ud83d\udca4"), new Emoji("\ud83d\udca2"), new Emoji("\ud83d\udca3"), new Emoji("\ud83d\udca5"), new Emoji("\ud83d\udca6"), new Emoji("\ud83d\udca8"), new Emoji("\ud83d\udcab"), new Emoji("\ud83d\udcac"), new Emoji("\ud83d\udde8\ufe0f"), new Emoji("\ud83d\uddef\ufe0f"), new Emoji("\ud83d\udcad"), new Emoji("\ud83d\udd73\ufe0f"), new Emoji("\ud83d\udc53"), new Emoji("\ud83d\udd76\ufe0f"), new Emoji("\ud83d\udc54"), new Emoji("\ud83d\udc55"), new Emoji("\ud83d\udc56"), new Emoji("\ud83e\udde3"), new Emoji("\ud83e\udde4"), new Emoji("\ud83e\udde5"), new Emoji("\ud83e\udde6"), new Emoji("\ud83d\udc57"), new Emoji("\ud83d\udc58"), new Emoji("\ud83d\udc59"), new Emoji("\ud83d\udc5a"), new Emoji("\ud83d\udc5b"), new Emoji("\ud83d\udc5c"), new Emoji("\ud83d\udc5d"), new Emoji("\ud83d\udecd\ufe0f"), new Emoji("\ud83c\udf92"), new Emoji("\ud83d\udc5e"), new Emoji("\ud83d\udc5f"), new Emoji("\ud83d\udc60"), new Emoji("\ud83d\udc61"), new Emoji("\ud83d\udc62"), new Emoji("\ud83d\udc51"), new Emoji("\ud83d\udc52"), new Emoji("\ud83c\udfa9"), new Emoji("\ud83c\udf93"), new Emoji("\ud83e\udde2"), new Emoji("\u26d1\ufe0f"), new Emoji("\ud83d\udcff"), new Emoji("\ud83d\udc84"), new Emoji("\ud83d\udc8d"), new Emoji("\ud83d\udc8e") - ), Uri.parse("emoji/People_3.png")); - - private static final EmojiPageModel PAGE_PEOPLE = new CompositeEmojiPageModel(R.attr.emoji_category_people, Arrays.asList(PAGE_PEOPLE_0, PAGE_PEOPLE_1, PAGE_PEOPLE_2, PAGE_PEOPLE_3)); - - private static final EmojiPageModel PAGE_NATURE = new StaticEmojiPageModel(EmojiCategory.NATURE, Arrays.asList( - new Emoji("\ud83d\udc35"), new Emoji("\ud83d\udc12"), new Emoji("\ud83e\udd8d"), new Emoji("\ud83d\udc36"), new Emoji("\ud83d\udc15"), new Emoji("\ud83d\udc29"), new Emoji("\ud83d\udc3a"), new Emoji("\ud83e\udd8a"), new Emoji("\ud83d\udc31"), new Emoji("\ud83d\udc08"), new Emoji("\ud83e\udd81"), new Emoji("\ud83d\udc2f"), new Emoji("\ud83d\udc05"), new Emoji("\ud83d\udc06"), new Emoji("\ud83d\udc34"), new Emoji("\ud83d\udc0e"), new Emoji("\ud83e\udd84"), new Emoji("\ud83e\udd93"), new Emoji("\ud83e\udd8c"), new Emoji("\ud83d\udc2e"), new Emoji("\ud83d\udc02"), new Emoji("\ud83d\udc03"), new Emoji("\ud83d\udc04"), new Emoji("\ud83d\udc37"), new Emoji("\ud83d\udc16"), new Emoji("\ud83d\udc17"), new Emoji("\ud83d\udc3d"), new Emoji("\ud83d\udc0f"), new Emoji("\ud83d\udc11"), new Emoji("\ud83d\udc10"), new Emoji("\ud83d\udc2a"), new Emoji("\ud83d\udc2b"), new Emoji("\ud83e\udd92"), new Emoji("\ud83d\udc18"), new Emoji("\ud83e\udd8f"), new Emoji("\ud83d\udc2d"), new Emoji("\ud83d\udc01"), new Emoji("\ud83d\udc00"), new Emoji("\ud83d\udc39"), new Emoji("\ud83d\udc30"), new Emoji("\ud83d\udc07"), new Emoji("\ud83d\udc3f\ufe0f"), new Emoji("\ud83e\udd94"), new Emoji("\ud83e\udd87"), new Emoji("\ud83d\udc3b"), new Emoji("\ud83d\udc28"), new Emoji("\ud83d\udc3c"), new Emoji("\ud83d\udc3e"), new Emoji("\ud83e\udd83"), new Emoji("\ud83d\udc14"), new Emoji("\ud83d\udc13"), new Emoji("\ud83d\udc23"), new Emoji("\ud83d\udc24"), new Emoji("\ud83d\udc25"), new Emoji("\ud83d\udc26"), new Emoji("\ud83d\udc27"), new Emoji("\ud83d\udd4a\ufe0f"), new Emoji("\ud83e\udd85"), new Emoji("\ud83e\udd86"), new Emoji("\ud83e\udd89"), new Emoji("\ud83d\udc38"), new Emoji("\ud83d\udc0a"), new Emoji("\ud83d\udc22"), new Emoji("\ud83e\udd8e"), new Emoji("\ud83d\udc0d"), new Emoji("\ud83d\udc32"), new Emoji("\ud83d\udc09"), new Emoji("\ud83e\udd95"), new Emoji("\ud83e\udd96"), new Emoji("\ud83d\udc33"), new Emoji("\ud83d\udc0b"), new Emoji("\ud83d\udc2c"), new Emoji("\ud83d\udc1f"), new Emoji("\ud83d\udc20"), new Emoji("\ud83d\udc21"), new Emoji("\ud83e\udd88"), new Emoji("\ud83d\udc19"), new Emoji("\ud83d\udc1a"), new Emoji("\ud83e\udd80"), new Emoji("\ud83e\udd90"), new Emoji("\ud83e\udd91"), new Emoji("\ud83d\udc0c"), new Emoji("\ud83e\udd8b"), new Emoji("\ud83d\udc1b"), new Emoji("\ud83d\udc1c"), new Emoji("\ud83d\udc1d"), new Emoji("\ud83d\udc1e"), new Emoji("\ud83e\udd97"), new Emoji("\ud83d\udd77\ufe0f"), new Emoji("\ud83d\udd78\ufe0f"), new Emoji("\ud83e\udd82"), new Emoji("\ud83d\udc90"), new Emoji("\ud83c\udf38"), new Emoji("\ud83d\udcae"), new Emoji("\ud83c\udff5\ufe0f"), new Emoji("\ud83c\udf39"), new Emoji("\ud83e\udd40"), new Emoji("\ud83c\udf3a"), new Emoji("\ud83c\udf3b"), new Emoji("\ud83c\udf3c"), new Emoji("\ud83c\udf37"), new Emoji("\ud83c\udf31"), new Emoji("\ud83c\udf32"), new Emoji("\ud83c\udf33"), new Emoji("\ud83c\udf34"), new Emoji("\ud83c\udf35"), new Emoji("\ud83c\udf3e"), new Emoji("\ud83c\udf3f"), new Emoji("\u2618\ufe0f"), new Emoji("\ud83c\udf40"), new Emoji("\ud83c\udf41"), new Emoji("\ud83c\udf42"), new Emoji("\ud83c\udf43") - ), Uri.parse("emoji/Nature.png")); - - private static final EmojiPageModel PAGE_FOODS = new StaticEmojiPageModel(EmojiCategory.FOODS, Arrays.asList( - new Emoji("\ud83c\udf47"), new Emoji("\ud83c\udf48"), new Emoji("\ud83c\udf49"), new Emoji("\ud83c\udf4a"), new Emoji("\ud83c\udf4b"), new Emoji("\ud83c\udf4c"), new Emoji("\ud83c\udf4d"), new Emoji("\ud83c\udf4e"), new Emoji("\ud83c\udf4f"), new Emoji("\ud83c\udf50"), new Emoji("\ud83c\udf51"), new Emoji("\ud83c\udf52"), new Emoji("\ud83c\udf53"), new Emoji("\ud83e\udd5d"), new Emoji("\ud83c\udf45"), new Emoji("\ud83e\udd65"), new Emoji("\ud83e\udd51"), new Emoji("\ud83c\udf46"), new Emoji("\ud83e\udd54"), new Emoji("\ud83e\udd55"), new Emoji("\ud83c\udf3d"), new Emoji("\ud83c\udf36\ufe0f"), new Emoji("\ud83e\udd52"), new Emoji("\ud83e\udd66"), new Emoji("\ud83c\udf44"), new Emoji("\ud83e\udd5c"), new Emoji("\ud83c\udf30"), new Emoji("\ud83c\udf5e"), new Emoji("\ud83e\udd50"), new Emoji("\ud83e\udd56"), new Emoji("\ud83e\udd68"), new Emoji("\ud83e\udd5e"), new Emoji("\ud83e\uddc0"), new Emoji("\ud83c\udf56"), new Emoji("\ud83c\udf57"), new Emoji("\ud83e\udd69"), new Emoji("\ud83e\udd53"), new Emoji("\ud83c\udf54"), new Emoji("\ud83c\udf5f"), new Emoji("\ud83c\udf55"), new Emoji("\ud83c\udf2d"), new Emoji("\ud83e\udd6a"), new Emoji("\ud83c\udf2e"), new Emoji("\ud83c\udf2f"), new Emoji("\ud83e\udd59"), new Emoji("\ud83e\udd5a"), new Emoji("\ud83c\udf73"), new Emoji("\ud83e\udd58"), new Emoji("\ud83c\udf72"), new Emoji("\ud83e\udd63"), new Emoji("\ud83e\udd57"), new Emoji("\ud83c\udf7f"), new Emoji("\ud83e\udd6b"), new Emoji("\ud83c\udf71"), new Emoji("\ud83c\udf58"), new Emoji("\ud83c\udf59"), new Emoji("\ud83c\udf5a"), new Emoji("\ud83c\udf5b"), new Emoji("\ud83c\udf5c"), new Emoji("\ud83c\udf5d"), new Emoji("\ud83c\udf60"), new Emoji("\ud83c\udf62"), new Emoji("\ud83c\udf63"), new Emoji("\ud83c\udf64"), new Emoji("\ud83c\udf65"), new Emoji("\ud83c\udf61"), new Emoji("\ud83e\udd5f"), new Emoji("\ud83e\udd60"), new Emoji("\ud83e\udd61"), new Emoji("\ud83c\udf66"), new Emoji("\ud83c\udf67"), new Emoji("\ud83c\udf68"), new Emoji("\ud83c\udf69"), new Emoji("\ud83c\udf6a"), new Emoji("\ud83c\udf82"), new Emoji("\ud83c\udf70"), new Emoji("\ud83e\udd67"), new Emoji("\ud83c\udf6b"), new Emoji("\ud83c\udf6c"), new Emoji("\ud83c\udf6d"), new Emoji("\ud83c\udf6e"), new Emoji("\ud83c\udf6f"), new Emoji("\ud83c\udf7c"), new Emoji("\ud83e\udd5b"), new Emoji("\u2615"), new Emoji("\ud83c\udf75"), new Emoji("\ud83c\udf76"), new Emoji("\ud83c\udf7e"), new Emoji("\ud83c\udf77"), new Emoji("\ud83c\udf78"), new Emoji("\ud83c\udf79"), new Emoji("\ud83c\udf7a"), new Emoji("\ud83c\udf7b"), new Emoji("\ud83e\udd42"), new Emoji("\ud83e\udd43"), new Emoji("\ud83e\udd64"), new Emoji("\ud83e\udd62"), new Emoji("\ud83c\udf7d\ufe0f"), new Emoji("\ud83c\udf74"), new Emoji("\ud83e\udd44"), new Emoji("\ud83d\udd2a"), new Emoji("\ud83c\udffa") - ), Uri.parse("emoji/Foods.png")); - - private static final EmojiPageModel PAGE_ACTIVITY = new StaticEmojiPageModel(EmojiCategory.ACTIVITY, Arrays.asList( - new Emoji("\ud83c\udf83"), new Emoji("\ud83c\udf84"), new Emoji("\ud83c\udf86"), new Emoji("\ud83c\udf87"), new Emoji("\u2728"), new Emoji("\ud83c\udf88"), new Emoji("\ud83c\udf89"), new Emoji("\ud83c\udf8a"), new Emoji("\ud83c\udf8b"), new Emoji("\ud83c\udf8d"), new Emoji("\ud83c\udf8e"), new Emoji("\ud83c\udf8f"), new Emoji("\ud83c\udf90"), new Emoji("\ud83c\udf91"), new Emoji("\ud83c\udf80"), new Emoji("\ud83c\udf81"), new Emoji("\ud83c\udf97\ufe0f"), new Emoji("\ud83c\udf9f\ufe0f"), new Emoji("\ud83c\udfab"), new Emoji("\ud83c\udf96\ufe0f"), new Emoji("\ud83c\udfc6"), new Emoji("\ud83c\udfc5"), new Emoji("\ud83e\udd47"), new Emoji("\ud83e\udd48"), new Emoji("\ud83e\udd49"), new Emoji("\u26bd"), new Emoji("\u26be"), new Emoji("\ud83c\udfc0"), new Emoji("\ud83c\udfd0"), new Emoji("\ud83c\udfc8"), new Emoji("\ud83c\udfc9"), new Emoji("\ud83c\udfbe"), new Emoji("\ud83c\udfb1"), new Emoji("\ud83c\udfb3"), new Emoji("\ud83c\udfcf"), new Emoji("\ud83c\udfd1"), new Emoji("\ud83c\udfd2"), new Emoji("\ud83c\udfd3"), new Emoji("\ud83c\udff8"), new Emoji("\ud83e\udd4a"), new Emoji("\ud83e\udd4b"), new Emoji("\ud83e\udd45"), new Emoji("\ud83c\udfaf"), new Emoji("\u26f3"), new Emoji("\u26f8\ufe0f"), new Emoji("\ud83c\udfa3"), new Emoji("\ud83c\udfbd"), new Emoji("\ud83c\udfbf"), new Emoji("\ud83d\udef7"), new Emoji("\ud83e\udd4c"), new Emoji("\ud83c\udfae"), new Emoji("\ud83d\udd79\ufe0f"), new Emoji("\ud83c\udfb2"), new Emoji("\u2660\ufe0f"), new Emoji("\u2665\ufe0f"), new Emoji("\u2666\ufe0f"), new Emoji("\u2663\ufe0f"), new Emoji("\ud83c\udccf"), new Emoji("\ud83c\udc04"), new Emoji("\ud83c\udfb4") - ), Uri.parse("emoji/Activity.png")); - - private static final EmojiPageModel PAGE_PLACES = new StaticEmojiPageModel(EmojiCategory.PLACES, Arrays.asList( - new Emoji("\ud83c\udf0d"), new Emoji("\ud83c\udf0e"), new Emoji("\ud83c\udf0f"), new Emoji("\ud83c\udf10"), new Emoji("\ud83d\uddfa\ufe0f"), new Emoji("\ud83d\uddfe"), new Emoji("\ud83c\udfd4\ufe0f"), new Emoji("\u26f0\ufe0f"), new Emoji("\ud83c\udf0b"), new Emoji("\ud83d\uddfb"), new Emoji("\ud83c\udfd5\ufe0f"), new Emoji("\ud83c\udfd6\ufe0f"), new Emoji("\ud83c\udfdc\ufe0f"), new Emoji("\ud83c\udfdd\ufe0f"), new Emoji("\ud83c\udfde\ufe0f"), new Emoji("\ud83c\udfdf\ufe0f"), new Emoji("\ud83c\udfdb\ufe0f"), new Emoji("\ud83c\udfd7\ufe0f"), new Emoji("\ud83c\udfd8\ufe0f"), new Emoji("\ud83c\udfd9\ufe0f"), new Emoji("\ud83c\udfda\ufe0f"), new Emoji("\ud83c\udfe0"), new Emoji("\ud83c\udfe1"), new Emoji("\ud83c\udfe2"), new Emoji("\ud83c\udfe3"), new Emoji("\ud83c\udfe4"), new Emoji("\ud83c\udfe5"), new Emoji("\ud83c\udfe6"), new Emoji("\ud83c\udfe8"), new Emoji("\ud83c\udfe9"), new Emoji("\ud83c\udfea"), new Emoji("\ud83c\udfeb"), new Emoji("\ud83c\udfec"), new Emoji("\ud83c\udfed"), new Emoji("\ud83c\udfef"), new Emoji("\ud83c\udff0"), new Emoji("\ud83d\udc92"), new Emoji("\ud83d\uddfc"), new Emoji("\ud83d\uddfd"), new Emoji("\u26ea"), new Emoji("\ud83d\udd4c"), new Emoji("\ud83d\udd4d"), new Emoji("\u26e9\ufe0f"), new Emoji("\ud83d\udd4b"), new Emoji("\u26f2"), new Emoji("\u26fa"), new Emoji("\ud83c\udf01"), new Emoji("\ud83c\udf03"), new Emoji("\ud83c\udf04"), new Emoji("\ud83c\udf05"), new Emoji("\ud83c\udf06"), new Emoji("\ud83c\udf07"), new Emoji("\ud83c\udf09"), new Emoji("\u2668\ufe0f"), new Emoji("\ud83c\udf0c"), new Emoji("\ud83c\udfa0"), new Emoji("\ud83c\udfa1"), new Emoji("\ud83c\udfa2"), new Emoji("\ud83d\udc88"), new Emoji("\ud83c\udfaa"), new Emoji("\ud83c\udfad"), new Emoji("\ud83d\uddbc\ufe0f"), new Emoji("\ud83c\udfa8"), new Emoji("\ud83c\udfb0"), new Emoji("\ud83d\ude82"), new Emoji("\ud83d\ude83"), new Emoji("\ud83d\ude84"), new Emoji("\ud83d\ude85"), new Emoji("\ud83d\ude86"), new Emoji("\ud83d\ude87"), new Emoji("\ud83d\ude88"), new Emoji("\ud83d\ude89"), new Emoji("\ud83d\ude8a"), new Emoji("\ud83d\ude9d"), new Emoji("\ud83d\ude9e"), new Emoji("\ud83d\ude8b"), new Emoji("\ud83d\ude8c"), new Emoji("\ud83d\ude8d"), new Emoji("\ud83d\ude8e"), new Emoji("\ud83d\ude90"), new Emoji("\ud83d\ude91"), new Emoji("\ud83d\ude92"), new Emoji("\ud83d\ude93"), new Emoji("\ud83d\ude94"), new Emoji("\ud83d\ude95"), new Emoji("\ud83d\ude96"), new Emoji("\ud83d\ude97"), new Emoji("\ud83d\ude98"), new Emoji("\ud83d\ude99"), new Emoji("\ud83d\ude9a"), new Emoji("\ud83d\ude9b"), new Emoji("\ud83d\ude9c"), new Emoji("\ud83d\udeb2"), new Emoji("\ud83d\udef4"), new Emoji("\ud83d\udef5"), new Emoji("\ud83d\ude8f"), new Emoji("\ud83d\udee3\ufe0f"), new Emoji("\ud83d\udee4\ufe0f"), new Emoji("\u26fd"), new Emoji("\ud83d\udea8"), new Emoji("\ud83d\udea5"), new Emoji("\ud83d\udea6"), new Emoji("\ud83d\udea7"), new Emoji("\ud83d\uded1"), new Emoji("\u2693"), new Emoji("\u26f5"), new Emoji("\ud83d\udef6"), new Emoji("\ud83d\udea4"), new Emoji("\ud83d\udef3\ufe0f"), new Emoji("\u26f4\ufe0f"), new Emoji("\ud83d\udee5\ufe0f"), new Emoji("\ud83d\udea2"), new Emoji("\u2708\ufe0f"), new Emoji("\ud83d\udee9\ufe0f"), new Emoji("\ud83d\udeeb"), new Emoji("\ud83d\udeec"), new Emoji("\ud83d\udcba"), new Emoji("\ud83d\ude81"), new Emoji("\ud83d\ude9f"), new Emoji("\ud83d\udea0"), new Emoji("\ud83d\udea1"), new Emoji("\ud83d\udef0\ufe0f"), new Emoji("\ud83d\ude80"), new Emoji("\ud83d\udef8"), new Emoji("\ud83d\udece\ufe0f"), new Emoji("\ud83d\udeaa"), new Emoji("\ud83d\udecf\ufe0f"), new Emoji("\ud83d\udecb\ufe0f"), new Emoji("\ud83d\udebd"), new Emoji("\ud83d\udebf"), new Emoji("\ud83d\udec1"), new Emoji("\u231b"), new Emoji("\u23f3"), new Emoji("\u231a"), new Emoji("\u23f0"), new Emoji("\u23f1\ufe0f"), new Emoji("\u23f2\ufe0f"), new Emoji("\ud83d\udd70\ufe0f"), new Emoji("\ud83d\udd5b"), new Emoji("\ud83d\udd67"), new Emoji("\ud83d\udd50"), new Emoji("\ud83d\udd5c"), new Emoji("\ud83d\udd51"), new Emoji("\ud83d\udd5d"), new Emoji("\ud83d\udd52"), new Emoji("\ud83d\udd5e"), new Emoji("\ud83d\udd53"), new Emoji("\ud83d\udd5f"), new Emoji("\ud83d\udd54"), new Emoji("\ud83d\udd60"), new Emoji("\ud83d\udd55"), new Emoji("\ud83d\udd61"), new Emoji("\ud83d\udd56"), new Emoji("\ud83d\udd62"), new Emoji("\ud83d\udd57"), new Emoji("\ud83d\udd63"), new Emoji("\ud83d\udd58"), new Emoji("\ud83d\udd64"), new Emoji("\ud83d\udd59"), new Emoji("\ud83d\udd65"), new Emoji("\ud83d\udd5a"), new Emoji("\ud83d\udd66"), new Emoji("\ud83c\udf11"), new Emoji("\ud83c\udf12"), new Emoji("\ud83c\udf13"), new Emoji("\ud83c\udf14"), new Emoji("\ud83c\udf15"), new Emoji("\ud83c\udf16"), new Emoji("\ud83c\udf17"), new Emoji("\ud83c\udf18"), new Emoji("\ud83c\udf19"), new Emoji("\ud83c\udf1a"), new Emoji("\ud83c\udf1b"), new Emoji("\ud83c\udf1c"), new Emoji("\ud83c\udf21\ufe0f"), new Emoji("\u2600\ufe0f"), new Emoji("\ud83c\udf1d"), new Emoji("\ud83c\udf1e"), new Emoji("\u2b50"), new Emoji("\ud83c\udf1f"), new Emoji("\ud83c\udf20"), new Emoji("\u2601\ufe0f"), new Emoji("\u26c5"), new Emoji("\u26c8\ufe0f"), new Emoji("\ud83c\udf24\ufe0f"), new Emoji("\ud83c\udf25\ufe0f"), new Emoji("\ud83c\udf26\ufe0f"), new Emoji("\ud83c\udf27\ufe0f"), new Emoji("\ud83c\udf28\ufe0f"), new Emoji("\ud83c\udf29\ufe0f"), new Emoji("\ud83c\udf2a\ufe0f"), new Emoji("\ud83c\udf2b\ufe0f"), new Emoji("\ud83c\udf2c\ufe0f"), new Emoji("\ud83c\udf00"), new Emoji("\ud83c\udf08"), new Emoji("\ud83c\udf02"), new Emoji("\u2602\ufe0f"), new Emoji("\u2614"), new Emoji("\u26f1\ufe0f"), new Emoji("\u26a1"), new Emoji("\u2744\ufe0f"), new Emoji("\u2603\ufe0f"), new Emoji("\u26c4"), new Emoji("\u2604\ufe0f"), new Emoji("\ud83d\udd25"), new Emoji("\ud83d\udca7"), new Emoji("\ud83c\udf0a") - ), Uri.parse("emoji/Places.png")); - - private static final EmojiPageModel PAGE_OBJECTS = new StaticEmojiPageModel(EmojiCategory.OBJECTS, Arrays.asList( - new Emoji("\ud83d\udd07"), new Emoji("\ud83d\udd08"), new Emoji("\ud83d\udd09"), new Emoji("\ud83d\udd0a"), new Emoji("\ud83d\udce2"), new Emoji("\ud83d\udce3"), new Emoji("\ud83d\udcef"), new Emoji("\ud83d\udd14"), new Emoji("\ud83d\udd15"), new Emoji("\ud83c\udfbc"), new Emoji("\ud83c\udfb5"), new Emoji("\ud83c\udfb6"), new Emoji("\ud83c\udf99\ufe0f"), new Emoji("\ud83c\udf9a\ufe0f"), new Emoji("\ud83c\udf9b\ufe0f"), new Emoji("\ud83c\udfa4"), new Emoji("\ud83c\udfa7"), new Emoji("\ud83d\udcfb"), new Emoji("\ud83c\udfb7"), new Emoji("\ud83c\udfb8"), new Emoji("\ud83c\udfb9"), new Emoji("\ud83c\udfba"), new Emoji("\ud83c\udfbb"), new Emoji("\ud83e\udd41"), new Emoji("\ud83d\udcf1"), new Emoji("\ud83d\udcf2"), new Emoji("\u260e\ufe0f"), new Emoji("\ud83d\udcde"), new Emoji("\ud83d\udcdf"), new Emoji("\ud83d\udce0"), new Emoji("\ud83d\udd0b"), new Emoji("\ud83d\udd0c"), new Emoji("\ud83d\udcbb"), new Emoji("\ud83d\udda5\ufe0f"), new Emoji("\ud83d\udda8\ufe0f"), new Emoji("\u2328\ufe0f"), new Emoji("\ud83d\uddb1\ufe0f"), new Emoji("\ud83d\uddb2\ufe0f"), new Emoji("\ud83d\udcbd"), new Emoji("\ud83d\udcbe"), new Emoji("\ud83d\udcbf"), new Emoji("\ud83d\udcc0"), new Emoji("\ud83c\udfa5"), new Emoji("\ud83c\udf9e\ufe0f"), new Emoji("\ud83d\udcfd\ufe0f"), new Emoji("\ud83c\udfac"), new Emoji("\ud83d\udcfa"), new Emoji("\ud83d\udcf7"), new Emoji("\ud83d\udcf8"), new Emoji("\ud83d\udcf9"), new Emoji("\ud83d\udcfc"), new Emoji("\ud83d\udd0d"), new Emoji("\ud83d\udd0e"), new Emoji("\ud83d\udd2c"), new Emoji("\ud83d\udd2d"), new Emoji("\ud83d\udce1"), new Emoji("\ud83d\udd6f\ufe0f"), new Emoji("\ud83d\udca1"), new Emoji("\ud83d\udd26"), new Emoji("\ud83c\udfee"), new Emoji("\ud83d\udcd4"), new Emoji("\ud83d\udcd5"), new Emoji("\ud83d\udcd6"), new Emoji("\ud83d\udcd7"), new Emoji("\ud83d\udcd8"), new Emoji("\ud83d\udcd9"), new Emoji("\ud83d\udcda"), new Emoji("\ud83d\udcd3"), new Emoji("\ud83d\udcd2"), new Emoji("\ud83d\udcc3"), new Emoji("\ud83d\udcdc"), new Emoji("\ud83d\udcc4"), new Emoji("\ud83d\udcf0"), new Emoji("\ud83d\uddde\ufe0f"), new Emoji("\ud83d\udcd1"), new Emoji("\ud83d\udd16"), new Emoji("\ud83c\udff7\ufe0f"), new Emoji("\ud83d\udcb0"), new Emoji("\ud83d\udcb4"), new Emoji("\ud83d\udcb5"), new Emoji("\ud83d\udcb6"), new Emoji("\ud83d\udcb7"), new Emoji("\ud83d\udcb8"), new Emoji("\ud83d\udcb3"), new Emoji("\ud83d\udcb9"), new Emoji("\ud83d\udcb1"), new Emoji("\ud83d\udcb2"), new Emoji("\u2709\ufe0f"), new Emoji("\ud83d\udce7"), new Emoji("\ud83d\udce8"), new Emoji("\ud83d\udce9"), new Emoji("\ud83d\udce4"), new Emoji("\ud83d\udce5"), new Emoji("\ud83d\udce6"), new Emoji("\ud83d\udceb"), new Emoji("\ud83d\udcea"), new Emoji("\ud83d\udcec"), new Emoji("\ud83d\udced"), new Emoji("\ud83d\udcee"), new Emoji("\ud83d\uddf3\ufe0f"), new Emoji("\u270f\ufe0f"), new Emoji("\u2712\ufe0f"), new Emoji("\ud83d\udd8b\ufe0f"), new Emoji("\ud83d\udd8a\ufe0f"), new Emoji("\ud83d\udd8c\ufe0f"), new Emoji("\ud83d\udd8d\ufe0f"), new Emoji("\ud83d\udcdd"), new Emoji("\ud83d\udcbc"), new Emoji("\ud83d\udcc1"), new Emoji("\ud83d\udcc2"), new Emoji("\ud83d\uddc2\ufe0f"), new Emoji("\ud83d\udcc5"), new Emoji("\ud83d\udcc6"), new Emoji("\ud83d\uddd2\ufe0f"), new Emoji("\ud83d\uddd3\ufe0f"), new Emoji("\ud83d\udcc7"), new Emoji("\ud83d\udcc8"), new Emoji("\ud83d\udcc9"), new Emoji("\ud83d\udcca"), new Emoji("\ud83d\udccb"), new Emoji("\ud83d\udccc"), new Emoji("\ud83d\udccd"), new Emoji("\ud83d\udcce"), new Emoji("\ud83d\udd87\ufe0f"), new Emoji("\ud83d\udccf"), new Emoji("\ud83d\udcd0"), new Emoji("\u2702\ufe0f"), new Emoji("\ud83d\uddc3\ufe0f"), new Emoji("\ud83d\uddc4\ufe0f"), new Emoji("\ud83d\uddd1\ufe0f"), new Emoji("\ud83d\udd12"), new Emoji("\ud83d\udd13"), new Emoji("\ud83d\udd0f"), new Emoji("\ud83d\udd10"), new Emoji("\ud83d\udd11"), new Emoji("\ud83d\udddd\ufe0f"), new Emoji("\ud83d\udd28"), new Emoji("\u26cf\ufe0f"), new Emoji("\u2692\ufe0f"), new Emoji("\ud83d\udee0\ufe0f"), new Emoji("\ud83d\udde1\ufe0f"), new Emoji("\u2694\ufe0f"), new Emoji("\ud83d\udd2b"), new Emoji("\ud83c\udff9"), new Emoji("\ud83d\udee1\ufe0f"), new Emoji("\ud83d\udd27"), new Emoji("\ud83d\udd29"), new Emoji("\u2699\ufe0f"), new Emoji("\ud83d\udddc\ufe0f"), new Emoji("\u2697\ufe0f"), new Emoji("\u2696\ufe0f"), new Emoji("\ud83d\udd17"), new Emoji("\u26d3\ufe0f"), new Emoji("\ud83d\udc89"), new Emoji("\ud83d\udc8a"), new Emoji("\ud83d\udeac"), new Emoji("\u26b0\ufe0f"), new Emoji("\u26b1\ufe0f"), new Emoji("\ud83d\uddff"), new Emoji("\ud83d\udee2\ufe0f"), new Emoji("\ud83d\udd2e"), new Emoji("\ud83d\uded2") - ), Uri.parse("emoji/Objects.png")); - - private static final EmojiPageModel PAGE_SYMBOLS = new StaticEmojiPageModel(EmojiCategory.SYMBOLS, Arrays.asList( - new Emoji("\ud83c\udfe7"), new Emoji("\ud83d\udeae"), new Emoji("\ud83d\udeb0"), new Emoji("\u267f"), new Emoji("\ud83d\udeb9"), new Emoji("\ud83d\udeba"), new Emoji("\ud83d\udebb"), new Emoji("\ud83d\udebc"), new Emoji("\ud83d\udebe"), new Emoji("\ud83d\udec2"), new Emoji("\ud83d\udec3"), new Emoji("\ud83d\udec4"), new Emoji("\ud83d\udec5"), new Emoji("\u26a0\ufe0f"), new Emoji("\ud83d\udeb8"), new Emoji("\u26d4"), new Emoji("\ud83d\udeab"), new Emoji("\ud83d\udeb3"), new Emoji("\ud83d\udead"), new Emoji("\ud83d\udeaf"), new Emoji("\ud83d\udeb1"), new Emoji("\ud83d\udeb7"), new Emoji("\ud83d\udcf5"), new Emoji("\ud83d\udd1e"), new Emoji("\u2622\ufe0f"), new Emoji("\u2623\ufe0f"), new Emoji("\u2b06\ufe0f"), new Emoji("\u2197\ufe0f"), new Emoji("\u27a1\ufe0f"), new Emoji("\u2198\ufe0f"), new Emoji("\u2b07\ufe0f"), new Emoji("\u2199\ufe0f"), new Emoji("\u2b05\ufe0f"), new Emoji("\u2196\ufe0f"), new Emoji("\u2195\ufe0f"), new Emoji("\u2194\ufe0f"), new Emoji("\u21a9\ufe0f"), new Emoji("\u21aa\ufe0f"), new Emoji("\u2934\ufe0f"), new Emoji("\u2935\ufe0f"), new Emoji("\ud83d\udd03"), new Emoji("\ud83d\udd04"), new Emoji("\ud83d\udd19"), new Emoji("\ud83d\udd1a"), new Emoji("\ud83d\udd1b"), new Emoji("\ud83d\udd1c"), new Emoji("\ud83d\udd1d"), new Emoji("\ud83d\uded0"), new Emoji("\u269b\ufe0f"), new Emoji("\ud83d\udd49\ufe0f"), new Emoji("\u2721\ufe0f"), new Emoji("\u2638\ufe0f"), new Emoji("\u262f\ufe0f"), new Emoji("\u271d\ufe0f"), new Emoji("\u2626\ufe0f"), new Emoji("\u262a\ufe0f"), new Emoji("\u262e\ufe0f"), new Emoji("\ud83d\udd4e"), new Emoji("\ud83d\udd2f"), new Emoji("\u2648"), new Emoji("\u2649"), new Emoji("\u264a"), new Emoji("\u264b"), new Emoji("\u264c"), new Emoji("\u264d"), new Emoji("\u264e"), new Emoji("\u264f"), new Emoji("\u2650"), new Emoji("\u2651"), new Emoji("\u2652"), new Emoji("\u2653"), new Emoji("\u26ce"), new Emoji("\ud83d\udd00"), new Emoji("\ud83d\udd01"), new Emoji("\ud83d\udd02"), new Emoji("\u25b6\ufe0f"), new Emoji("\u23e9"), new Emoji("\u23ed\ufe0f"), new Emoji("\u23ef\ufe0f"), new Emoji("\u25c0\ufe0f"), new Emoji("\u23ea"), new Emoji("\u23ee\ufe0f"), new Emoji("\ud83d\udd3c"), new Emoji("\u23eb"), new Emoji("\ud83d\udd3d"), new Emoji("\u23ec"), new Emoji("\u23f8\ufe0f"), new Emoji("\u23f9\ufe0f"), new Emoji("\u23fa\ufe0f"), new Emoji("\u23cf\ufe0f"), new Emoji("\ud83c\udfa6"), new Emoji("\ud83d\udd05"), new Emoji("\ud83d\udd06"), new Emoji("\ud83d\udcf6"), new Emoji("\ud83d\udcf3"), new Emoji("\ud83d\udcf4"), new Emoji("\u267b\ufe0f"), new Emoji("\u269c\ufe0f"), new Emoji("\ud83d\udd31"), new Emoji("\ud83d\udcdb"), new Emoji("\ud83d\udd30"), new Emoji("\u2b55"), new Emoji("\u2705"), new Emoji("\u2611\ufe0f"), new Emoji("\u2714\ufe0f"), new Emoji("\u2716\ufe0f"), new Emoji("\u274c"), new Emoji("\u274e"), new Emoji("\u2795"), new Emoji("\u2796"), new Emoji("\u2797"), new Emoji("\u27b0"), new Emoji("\u27bf"), new Emoji("\u303d\ufe0f"), new Emoji("\u2733\ufe0f"), new Emoji("\u2734\ufe0f"), new Emoji("\u2747\ufe0f"), new Emoji("\u203c\ufe0f"), new Emoji("\u2049\ufe0f"), new Emoji("\u2753"), new Emoji("\u2754"), new Emoji("\u2755"), new Emoji("\u2757"), new Emoji("\u3030\ufe0f"), new Emoji("\u00a9\ufe0f"), new Emoji("\u00ae\ufe0f"), new Emoji("\u2122\ufe0f"), new Emoji("\u0023\ufe0f\u20e3"), new Emoji("\u002a\ufe0f\u20e3"), new Emoji("\u0030\ufe0f\u20e3"), new Emoji("\u0031\ufe0f\u20e3"), new Emoji("\u0032\ufe0f\u20e3"), new Emoji("\u0033\ufe0f\u20e3"), new Emoji("\u0034\ufe0f\u20e3"), new Emoji("\u0035\ufe0f\u20e3"), new Emoji("\u0036\ufe0f\u20e3"), new Emoji("\u0037\ufe0f\u20e3"), new Emoji("\u0038\ufe0f\u20e3"), new Emoji("\u0039\ufe0f\u20e3"), new Emoji("\ud83d\udd1f"), new Emoji("\ud83d\udcaf"), new Emoji("\ud83d\udd20"), new Emoji("\ud83d\udd21"), new Emoji("\ud83d\udd22"), new Emoji("\ud83d\udd23"), new Emoji("\ud83d\udd24"), new Emoji("\ud83c\udd70\ufe0f"), new Emoji("\ud83c\udd8e"), new Emoji("\ud83c\udd71\ufe0f"), new Emoji("\ud83c\udd91"), new Emoji("\ud83c\udd92"), new Emoji("\ud83c\udd93"), new Emoji("\u2139\ufe0f"), new Emoji("\ud83c\udd94"), new Emoji("\u24c2\ufe0f"), new Emoji("\ud83c\udd95"), new Emoji("\ud83c\udd96"), new Emoji("\ud83c\udd7e\ufe0f"), new Emoji("\ud83c\udd97"), new Emoji("\ud83c\udd7f\ufe0f"), new Emoji("\ud83c\udd98"), new Emoji("\ud83c\udd99"), new Emoji("\ud83c\udd9a"), new Emoji("\ud83c\ude01"), new Emoji("\ud83c\ude02\ufe0f"), new Emoji("\ud83c\ude37\ufe0f"), new Emoji("\ud83c\ude36"), new Emoji("\ud83c\ude2f"), new Emoji("\ud83c\ude50"), new Emoji("\ud83c\ude39"), new Emoji("\ud83c\ude1a"), new Emoji("\ud83c\ude32"), new Emoji("\ud83c\ude51"), new Emoji("\ud83c\ude38"), new Emoji("\ud83c\ude34"), new Emoji("\ud83c\ude33"), new Emoji("\u3297\ufe0f"), new Emoji("\u3299\ufe0f"), new Emoji("\ud83c\ude3a"), new Emoji("\ud83c\ude35"), new Emoji("\u25aa\ufe0f"), new Emoji("\u25ab\ufe0f"), new Emoji("\u25fb\ufe0f"), new Emoji("\u25fc\ufe0f"), new Emoji("\u25fd"), new Emoji("\u25fe"), new Emoji("\u2b1b"), new Emoji("\u2b1c"), new Emoji("\ud83d\udd36"), new Emoji("\ud83d\udd37"), new Emoji("\ud83d\udd38"), new Emoji("\ud83d\udd39"), new Emoji("\ud83d\udd3a"), new Emoji("\ud83d\udd3b"), new Emoji("\ud83d\udca0"), new Emoji("\ud83d\udd18"), new Emoji("\ud83d\udd32"), new Emoji("\ud83d\udd33"), new Emoji("\u26aa"), new Emoji("\u26ab"), new Emoji("\ud83d\udd34"), new Emoji("\ud83d\udd35") - ), Uri.parse("emoji/Symbols.png")); - - private static final EmojiPageModel PAGE_FLAGS = new StaticEmojiPageModel(EmojiCategory.FLAGS, Arrays.asList( - new Emoji("\ud83c\udfc1"), new Emoji("\ud83d\udea9"), new Emoji("\ud83c\udf8c"), new Emoji("\ud83c\udff4"), new Emoji("\ud83c\udff3\ufe0f"), new Emoji("\ud83c\udff3\ufe0f\u200d\ud83c\udf08"), new Emoji("\ud83c\udde6\ud83c\udde8"), new Emoji("\ud83c\udde6\ud83c\udde9"), new Emoji("\ud83c\udde6\ud83c\uddea"), new Emoji("\ud83c\udde6\ud83c\uddeb"), new Emoji("\ud83c\udde6\ud83c\uddec"), new Emoji("\ud83c\udde6\ud83c\uddee"), new Emoji("\ud83c\udde6\ud83c\uddf1"), new Emoji("\ud83c\udde6\ud83c\uddf2"), new Emoji("\ud83c\udde6\ud83c\uddf4"), new Emoji("\ud83c\udde6\ud83c\uddf6"), new Emoji("\ud83c\udde6\ud83c\uddf7"), new Emoji("\ud83c\udde6\ud83c\uddf8"), new Emoji("\ud83c\udde6\ud83c\uddf9"), new Emoji("\ud83c\udde6\ud83c\uddfa"), new Emoji("\ud83c\udde6\ud83c\uddfc"), new Emoji("\ud83c\udde6\ud83c\uddfd"), new Emoji("\ud83c\udde6\ud83c\uddff"), new Emoji("\ud83c\udde7\ud83c\udde6"), new Emoji("\ud83c\udde7\ud83c\udde7"), new Emoji("\ud83c\udde7\ud83c\udde9"), new Emoji("\ud83c\udde7\ud83c\uddea"), new Emoji("\ud83c\udde7\ud83c\uddeb"), new Emoji("\ud83c\udde7\ud83c\uddec"), new Emoji("\ud83c\udde7\ud83c\udded"), new Emoji("\ud83c\udde7\ud83c\uddee"), new Emoji("\ud83c\udde7\ud83c\uddef"), new Emoji("\ud83c\udde7\ud83c\uddf1"), new Emoji("\ud83c\udde7\ud83c\uddf2"), new Emoji("\ud83c\udde7\ud83c\uddf3"), new Emoji("\ud83c\udde7\ud83c\uddf4"), new Emoji("\ud83c\udde7\ud83c\uddf6"), new Emoji("\ud83c\udde7\ud83c\uddf7"), new Emoji("\ud83c\udde7\ud83c\uddf8"), new Emoji("\ud83c\udde7\ud83c\uddf9"), new Emoji("\ud83c\udde7\ud83c\uddfb"), new Emoji("\ud83c\udde7\ud83c\uddfc"), new Emoji("\ud83c\udde7\ud83c\uddfe"), new Emoji("\ud83c\udde7\ud83c\uddff"), new Emoji("\ud83c\udde8\ud83c\udde6"), new Emoji("\ud83c\udde8\ud83c\udde8"), new Emoji("\ud83c\udde8\ud83c\udde9"), new Emoji("\ud83c\udde8\ud83c\uddeb"), new Emoji("\ud83c\udde8\ud83c\uddec"), new Emoji("\ud83c\udde8\ud83c\udded"), new Emoji("\ud83c\udde8\ud83c\uddee"), new Emoji("\ud83c\udde8\ud83c\uddf0"), new Emoji("\ud83c\udde8\ud83c\uddf1"), new Emoji("\ud83c\udde8\ud83c\uddf2"), new Emoji("\ud83c\udde8\ud83c\uddf3"), new Emoji("\ud83c\udde8\ud83c\uddf4"), new Emoji("\ud83c\udde8\ud83c\uddf5"), new Emoji("\ud83c\udde8\ud83c\uddf7"), new Emoji("\ud83c\udde8\ud83c\uddfa"), new Emoji("\ud83c\udde8\ud83c\uddfb"), new Emoji("\ud83c\udde8\ud83c\uddfc"), new Emoji("\ud83c\udde8\ud83c\uddfd"), new Emoji("\ud83c\udde8\ud83c\uddfe"), new Emoji("\ud83c\udde8\ud83c\uddff"), new Emoji("\ud83c\udde9\ud83c\uddea"), new Emoji("\ud83c\udde9\ud83c\uddec"), new Emoji("\ud83c\udde9\ud83c\uddef"), new Emoji("\ud83c\udde9\ud83c\uddf0"), new Emoji("\ud83c\udde9\ud83c\uddf2"), new Emoji("\ud83c\udde9\ud83c\uddf4"), new Emoji("\ud83c\udde9\ud83c\uddff"), new Emoji("\ud83c\uddea\ud83c\udde6"), new Emoji("\ud83c\uddea\ud83c\udde8"), new Emoji("\ud83c\uddea\ud83c\uddea"), new Emoji("\ud83c\uddea\ud83c\uddec"), new Emoji("\ud83c\uddea\ud83c\udded"), new Emoji("\ud83c\uddea\ud83c\uddf7"), new Emoji("\ud83c\uddea\ud83c\uddf8"), new Emoji("\ud83c\uddea\ud83c\uddf9"), new Emoji("\ud83c\uddea\ud83c\uddfa"), new Emoji("\ud83c\uddeb\ud83c\uddee"), new Emoji("\ud83c\uddeb\ud83c\uddef"), new Emoji("\ud83c\uddeb\ud83c\uddf0"), new Emoji("\ud83c\uddeb\ud83c\uddf2"), new Emoji("\ud83c\uddeb\ud83c\uddf4"), new Emoji("\ud83c\uddeb\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\udde6"), new Emoji("\ud83c\uddec\ud83c\udde7"), new Emoji("\ud83c\uddec\ud83c\udde9"), new Emoji("\ud83c\uddec\ud83c\uddea"), new Emoji("\ud83c\uddec\ud83c\uddeb"), new Emoji("\ud83c\uddec\ud83c\uddec"), new Emoji("\ud83c\uddec\ud83c\udded"), new Emoji("\ud83c\uddec\ud83c\uddee"), new Emoji("\ud83c\uddec\ud83c\uddf1"), new Emoji("\ud83c\uddec\ud83c\uddf2"), new Emoji("\ud83c\uddec\ud83c\uddf3"), new Emoji("\ud83c\uddec\ud83c\uddf5"), new Emoji("\ud83c\uddec\ud83c\uddf6"), new Emoji("\ud83c\uddec\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\uddf8"), new Emoji("\ud83c\uddec\ud83c\uddf9"), new Emoji("\ud83c\uddec\ud83c\uddfa"), new Emoji("\ud83c\uddec\ud83c\uddfc"), new Emoji("\ud83c\uddec\ud83c\uddfe"), new Emoji("\ud83c\udded\ud83c\uddf0"), new Emoji("\ud83c\udded\ud83c\uddf2"), new Emoji("\ud83c\udded\ud83c\uddf3"), new Emoji("\ud83c\udded\ud83c\uddf7"), new Emoji("\ud83c\udded\ud83c\uddf9"), new Emoji("\ud83c\udded\ud83c\uddfa"), new Emoji("\ud83c\uddee\ud83c\udde8"), new Emoji("\ud83c\uddee\ud83c\udde9"), new Emoji("\ud83c\uddee\ud83c\uddea"), new Emoji("\ud83c\uddee\ud83c\uddf1"), new Emoji("\ud83c\uddee\ud83c\uddf2"), new Emoji("\ud83c\uddee\ud83c\uddf3"), new Emoji("\ud83c\uddee\ud83c\uddf4"), new Emoji("\ud83c\uddee\ud83c\uddf6"), new Emoji("\ud83c\uddee\ud83c\uddf7"), new Emoji("\ud83c\uddee\ud83c\uddf8"), new Emoji("\ud83c\uddee\ud83c\uddf9"), new Emoji("\ud83c\uddef\ud83c\uddea"), new Emoji("\ud83c\uddef\ud83c\uddf2"), new Emoji("\ud83c\uddef\ud83c\uddf4"), new Emoji("\ud83c\uddef\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddea"), new Emoji("\ud83c\uddf0\ud83c\uddec"), new Emoji("\ud83c\uddf0\ud83c\udded"), new Emoji("\ud83c\uddf0\ud83c\uddee"), new Emoji("\ud83c\uddf0\ud83c\uddf2"), new Emoji("\ud83c\uddf0\ud83c\uddf3"), new Emoji("\ud83c\uddf0\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddf7"), new Emoji("\ud83c\uddf0\ud83c\uddfc"), new Emoji("\ud83c\uddf0\ud83c\uddfe"), new Emoji("\ud83c\uddf0\ud83c\uddff"), new Emoji("\ud83c\uddf1\ud83c\udde6"), new Emoji("\ud83c\uddf1\ud83c\udde7"), new Emoji("\ud83c\uddf1\ud83c\udde8"), new Emoji("\ud83c\uddf1\ud83c\uddee"), new Emoji("\ud83c\uddf1\ud83c\uddf0"), new Emoji("\ud83c\uddf1\ud83c\uddf7"), new Emoji("\ud83c\uddf1\ud83c\uddf8"), new Emoji("\ud83c\uddf1\ud83c\uddf9"), new Emoji("\ud83c\uddf1\ud83c\uddfa"), new Emoji("\ud83c\uddf1\ud83c\uddfb"), new Emoji("\ud83c\uddf1\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\udde6"), new Emoji("\ud83c\uddf2\ud83c\udde8"), new Emoji("\ud83c\uddf2\ud83c\udde9"), new Emoji("\ud83c\uddf2\ud83c\uddea"), new Emoji("\ud83c\uddf2\ud83c\uddeb"), new Emoji("\ud83c\uddf2\ud83c\uddec"), new Emoji("\ud83c\uddf2\ud83c\udded"), new Emoji("\ud83c\uddf2\ud83c\uddf0"), new Emoji("\ud83c\uddf2\ud83c\uddf1"), new Emoji("\ud83c\uddf2\ud83c\uddf2"), new Emoji("\ud83c\uddf2\ud83c\uddf3"), new Emoji("\ud83c\uddf2\ud83c\uddf4"), new Emoji("\ud83c\uddf2\ud83c\uddf5"), new Emoji("\ud83c\uddf2\ud83c\uddf6"), new Emoji("\ud83c\uddf2\ud83c\uddf7"), new Emoji("\ud83c\uddf2\ud83c\uddf8"), new Emoji("\ud83c\uddf2\ud83c\uddf9"), new Emoji("\ud83c\uddf2\ud83c\uddfa"), new Emoji("\ud83c\uddf2\ud83c\uddfb"), new Emoji("\ud83c\uddf2\ud83c\uddfc"), new Emoji("\ud83c\uddf2\ud83c\uddfd"), new Emoji("\ud83c\uddf2\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\uddff"), new Emoji("\ud83c\uddf3\ud83c\udde6"), new Emoji("\ud83c\uddf3\ud83c\udde8"), new Emoji("\ud83c\uddf3\ud83c\uddea"), new Emoji("\ud83c\uddf3\ud83c\uddeb"), new Emoji("\ud83c\uddf3\ud83c\uddec"), new Emoji("\ud83c\uddf3\ud83c\uddee"), new Emoji("\ud83c\uddf3\ud83c\uddf1"), new Emoji("\ud83c\uddf3\ud83c\uddf4"), new Emoji("\ud83c\uddf3\ud83c\uddf5"), new Emoji("\ud83c\uddf3\ud83c\uddf7"), new Emoji("\ud83c\uddf3\ud83c\uddfa"), new Emoji("\ud83c\uddf3\ud83c\uddff"), new Emoji("\ud83c\uddf4\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\udde6"), new Emoji("\ud83c\uddf5\ud83c\uddea"), new Emoji("\ud83c\uddf5\ud83c\uddeb"), new Emoji("\ud83c\uddf5\ud83c\uddec"), new Emoji("\ud83c\uddf5\ud83c\udded"), new Emoji("\ud83c\uddf5\ud83c\uddf0"), new Emoji("\ud83c\uddf5\ud83c\uddf1"), new Emoji("\ud83c\uddf5\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\uddf3"), new Emoji("\ud83c\uddf5\ud83c\uddf7"), new Emoji("\ud83c\uddf5\ud83c\uddf8"), new Emoji("\ud83c\uddf5\ud83c\uddf9"), new Emoji("\ud83c\uddf5\ud83c\uddfc"), new Emoji("\ud83c\uddf5\ud83c\uddfe"), new Emoji("\ud83c\uddf6\ud83c\udde6"), new Emoji("\ud83c\uddf7\ud83c\uddea"), new Emoji("\ud83c\uddf7\ud83c\uddf4"), new Emoji("\ud83c\uddf7\ud83c\uddf8"), new Emoji("\ud83c\uddf7\ud83c\uddfa"), new Emoji("\ud83c\uddf7\ud83c\uddfc"), new Emoji("\ud83c\uddf8\ud83c\udde6"), new Emoji("\ud83c\uddf8\ud83c\udde7"), new Emoji("\ud83c\uddf8\ud83c\udde8"), new Emoji("\ud83c\uddf8\ud83c\udde9"), new Emoji("\ud83c\uddf8\ud83c\uddea"), new Emoji("\ud83c\uddf8\ud83c\uddec"), new Emoji("\ud83c\uddf8\ud83c\udded"), new Emoji("\ud83c\uddf8\ud83c\uddee"), new Emoji("\ud83c\uddf8\ud83c\uddef"), new Emoji("\ud83c\uddf8\ud83c\uddf0"), new Emoji("\ud83c\uddf8\ud83c\uddf1"), new Emoji("\ud83c\uddf8\ud83c\uddf2"), new Emoji("\ud83c\uddf8\ud83c\uddf3"), new Emoji("\ud83c\uddf8\ud83c\uddf4"), new Emoji("\ud83c\uddf8\ud83c\uddf7"), new Emoji("\ud83c\uddf8\ud83c\uddf8"), new Emoji("\ud83c\uddf8\ud83c\uddf9"), new Emoji("\ud83c\uddf8\ud83c\uddfb"), new Emoji("\ud83c\uddf8\ud83c\uddfd"), new Emoji("\ud83c\uddf8\ud83c\uddfe"), new Emoji("\ud83c\uddf8\ud83c\uddff"), new Emoji("\ud83c\uddf9\ud83c\udde6"), new Emoji("\ud83c\uddf9\ud83c\udde8"), new Emoji("\ud83c\uddf9\ud83c\udde9"), new Emoji("\ud83c\uddf9\ud83c\uddeb"), new Emoji("\ud83c\uddf9\ud83c\uddec"), new Emoji("\ud83c\uddf9\ud83c\udded"), new Emoji("\ud83c\uddf9\ud83c\uddef"), new Emoji("\ud83c\uddf9\ud83c\uddf0"), new Emoji("\ud83c\uddf9\ud83c\uddf1"), new Emoji("\ud83c\uddf9\ud83c\uddf2"), new Emoji("\ud83c\uddf9\ud83c\uddf3"), new Emoji("\ud83c\uddf9\ud83c\uddf4"), new Emoji("\ud83c\uddf9\ud83c\uddf7"), new Emoji("\ud83c\uddf9\ud83c\uddf9"), new Emoji("\ud83c\uddf9\ud83c\uddfb"), new Emoji("\ud83c\uddf9\ud83c\uddfc"), new Emoji("\ud83c\uddf9\ud83c\uddff"), new Emoji("\ud83c\uddfa\ud83c\udde6"), new Emoji("\ud83c\uddfa\ud83c\uddec"), new Emoji("\ud83c\uddfa\ud83c\uddf2"), new Emoji("\ud83c\uddfa\ud83c\uddf8"), new Emoji("\ud83c\uddfa\ud83c\uddfe"), new Emoji("\ud83c\uddfa\ud83c\uddff"), new Emoji("\ud83c\uddfb\ud83c\udde6"), new Emoji("\ud83c\uddfb\ud83c\udde8"), new Emoji("\ud83c\uddfb\ud83c\uddea"), new Emoji("\ud83c\uddfb\ud83c\uddec"), new Emoji("\ud83c\uddfb\ud83c\uddee"), new Emoji("\ud83c\uddfb\ud83c\uddf3"), new Emoji("\ud83c\uddfb\ud83c\uddfa"), new Emoji("\ud83c\uddfc\ud83c\uddeb"), new Emoji("\ud83c\uddfc\ud83c\uddf8"), new Emoji("\ud83c\uddfd\ud83c\uddf0"), new Emoji("\ud83c\uddfe\ud83c\uddea"), new Emoji("\ud83c\uddfe\ud83c\uddf9"), new Emoji("\ud83c\uddff\ud83c\udde6"), new Emoji("\ud83c\uddff\ud83c\uddf2"), new Emoji("\ud83c\uddff\ud83c\uddfc"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f") - ), Uri.parse("emoji/Flags.png")); - - static final List DISPLAY_PAGES = Arrays.asList(PAGE_PEOPLE, - PAGE_NATURE, - PAGE_FOODS, - PAGE_ACTIVITY, - PAGE_PLACES, - PAGE_OBJECTS, - PAGE_SYMBOLS, - PAGE_FLAGS); - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index ffa2e197d3..4ccb16ed5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -14,7 +14,6 @@ import network.loki.messenger.R; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.guava.Optional; @@ -26,7 +25,6 @@ public class EmojiTextView extends AppCompatTextView { private CharSequence previousText; private BufferType previousBufferType = BufferType.NORMAL; private float originalFontSize; - private boolean useSystemEmoji; private boolean sizeChangeInProgress; private int maxLength; private CharSequence overflowText; @@ -81,9 +79,8 @@ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { previousText = text; previousOverflowText = overflowText; previousBufferType = type; - useSystemEmoji = useSystemEmoji(); - if (useSystemEmoji || candidates == null || candidates.size() == 0) { + if (candidates == null || candidates.size() == 0) { super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")).append(Optional.fromNullable(overflowText).or("")), BufferType.NORMAL); if (getEllipsize() == TextUtils.TruncateAt.END && maxLength > 0) { @@ -117,7 +114,7 @@ private void ellipsizeAnyTextForMaxLength() { EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent); - if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) { + if (newCandidates == null || newCandidates.size() == 0) { super.setText(newContent, BufferType.NORMAL); } else { CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false); @@ -166,13 +163,9 @@ private boolean unchanged(CharSequence text, CharSequence overflowText, BufferTy return Util.equals(finalPrevText, finalText) && Util.equals(finalPrevOverflowText, finalOverflowText) && Util.equals(previousBufferType, bufferType) && - useSystemEmoji == useSystemEmoji() && !sizeChangeInProgress; } - private boolean useSystemEmoji() { - return TextSecurePreferences.isSystemEmojiPreferred(getContext()); - } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java deleted file mode 100644 index 5becb16292..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.AppCompatImageButton; -import org.session.libsession.utilities.TextSecurePreferences; - -import network.loki.messenger.R; - -public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener { - - private Drawable emojiToggle; - private Drawable stickerToggle; - - private Drawable mediaToggle; - private Drawable imeToggle; - - - public EmojiToggle(Context context) { - super(context); - initialize(); - } - - public EmojiToggle(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public EmojiToggle(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - initialize(); - } - - public void setToMedia() { - setImageDrawable(mediaToggle); - } - - public void setToIme() { - setImageDrawable(imeToggle); - } - - private void initialize() { - TypedArray drawables = getContext().obtainStyledAttributes(new int[] { - R.attr.conversation_emoji_toggle, - R.attr.conversation_sticker_toggle, - R.attr.conversation_keyboard_toggle}); - - this.emojiToggle = drawables.getDrawable(0); - this.stickerToggle = drawables.getDrawable(1); - this.imeToggle = drawables.getDrawable(2); - this.mediaToggle = emojiToggle; - - drawables.recycle(); - setToMedia(); - } - - public void attach(MediaKeyboard drawer) { - drawer.setKeyboardListener(this); - } - - public void setStickerMode(boolean stickerMode) { - this.mediaToggle = stickerMode ? stickerToggle : emojiToggle; - - if (getDrawable() != imeToggle) { - setToMedia(); - } - } - - @Override public void onShown() { - setToIme(); - } - - @Override public void onHidden() { - setToMedia(); - } - - @Override - public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) { - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java deleted file mode 100644 index acb53f7767..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java +++ /dev/null @@ -1,276 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - - -import org.thoughtcrime.securesms.components.InputAwareLayout.InputView; -import org.thoughtcrime.securesms.components.RepeatableImageKey; -import org.session.libsignal.utilities.Log; -import com.bumptech.glide.Glide; - -import java.util.Arrays; - -import network.loki.messenger.R; - -public class MediaKeyboard extends FrameLayout implements InputView, - MediaKeyboardProvider.Presenter, - MediaKeyboardProvider.Controller, - MediaKeyboardBottomTabAdapter.EventListener -{ - - private static final String TAG = Log.tag(MediaKeyboard.class); - - private RecyclerView categoryTabs; - private ViewPager categoryPager; - private ViewGroup providerTabs; - private RepeatableImageKey backspaceButton; - private RepeatableImageKey backspaceButtonBackup; - private View searchButton; - private View addButton; - private MediaKeyboardListener keyboardListener; - private MediaKeyboardProvider[] providers; - private int providerIndex; - - private MediaKeyboardBottomTabAdapter categoryTabAdapter; - - public MediaKeyboard(Context context) { - this(context, null); - } - - public MediaKeyboard(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public void setProviders(int startIndex, MediaKeyboardProvider... providers) { - if (!Arrays.equals(this.providers, providers)) { - this.providers = providers; - this.providerIndex = startIndex; - - requestPresent(providers, providerIndex); - } - } - - public void setKeyboardListener(MediaKeyboardListener listener) { - this.keyboardListener = listener; - } - - @Override - public boolean isShowing() { - return getVisibility() == VISIBLE; - } - - @Override - public void show(int height, boolean immediate) { - if (this.categoryPager == null) initView(); - - ViewGroup.LayoutParams params = getLayoutParams(); - params.height = height; - Log.i(TAG, "showing emoji drawer with height " + params.height); - setLayoutParams(params); - setVisibility(VISIBLE); - - if (keyboardListener != null) keyboardListener.onShown(); - - requestPresent(providers, providerIndex); - } - - @Override - public void hide(boolean immediate) { - setVisibility(GONE); - if (keyboardListener != null) keyboardListener.onHidden(); - Log.i(TAG, "hide()"); - } - - @Override - public void present(@NonNull MediaKeyboardProvider provider, - @NonNull PagerAdapter pagerAdapter, - @NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider, - @Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, - @Nullable MediaKeyboardProvider.AddObserver addObserver, - @Nullable MediaKeyboardProvider.SearchObserver searchObserver, - int startingIndex) - { - if (categoryPager == null) return; - if (!provider.equals(providers[providerIndex])) return; - if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(provider); - - boolean isSolo = providers.length == 1; - - presentProviderStrip(isSolo); - presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex); - presentProviderTabs(providers, providerIndex); - presentSearchButton(searchObserver); - presentBackspaceButton(backspaceObserver, isSolo); - presentAddButton(addObserver); - } - - @Override - public int getCurrentPosition() { - return categoryPager != null ? categoryPager.getCurrentItem() : 0; - } - - @Override - public void requestDismissal() { - hide(true); - providerIndex = 0; - keyboardListener.onKeyboardProviderChanged(providers[providerIndex]); - } - - @Override - public boolean isVisible() { - return getVisibility() == View.VISIBLE; - } - - @Override - public void onTabSelected(int index) { - if (categoryPager != null) { - categoryPager.setCurrentItem(index); - categoryTabs.smoothScrollToPosition(index); - } - } - - @Override - public void setViewPagerEnabled(boolean enabled) { - if (categoryPager != null) { - categoryPager.setEnabled(enabled); - } - } - - private void initView() { - final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true); - - this.categoryTabs = view.findViewById(R.id.media_keyboard_tabs); - this.categoryPager = view.findViewById(R.id.media_keyboard_pager); - this.providerTabs = view.findViewById(R.id.media_keyboard_provider_tabs); - this.backspaceButton = view.findViewById(R.id.media_keyboard_backspace); - this.backspaceButtonBackup = view.findViewById(R.id.media_keyboard_backspace_backup); - this.searchButton = view.findViewById(R.id.media_keyboard_search); - this.addButton = view.findViewById(R.id.media_keyboard_add); - - this.categoryTabAdapter = new MediaKeyboardBottomTabAdapter(Glide.with(this), this); - - categoryTabs.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false)); - categoryTabs.setAdapter(categoryTabAdapter); - } - - private void requestPresent(@NonNull MediaKeyboardProvider[] providers, int newIndex) { - providers[providerIndex].setController(null); - providerIndex = newIndex; - - providers[providerIndex].setController(this); - providers[providerIndex].requestPresentation(this, providers.length == 1); - } - - - private void presentCategoryPager(@NonNull PagerAdapter pagerAdapter, - @NonNull MediaKeyboardProvider.TabIconProvider iconProvider, - int startingIndex) { - if (categoryPager.getAdapter() != pagerAdapter) { - categoryPager.setAdapter(pagerAdapter); - } - - categoryPager.setCurrentItem(startingIndex); - - categoryPager.clearOnPageChangeListeners(); - categoryPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled(int i, float v, int i1) { - } - - @Override - public void onPageSelected(int i) { - categoryTabAdapter.setActivePosition(i); - categoryTabs.smoothScrollToPosition(i); - } - - @Override - public void onPageScrollStateChanged(int i) { - } - }); - - categoryTabAdapter.setTabIconProvider(iconProvider, pagerAdapter.getCount()); - categoryTabAdapter.setActivePosition(startingIndex); - } - - private void presentProviderTabs(@NonNull MediaKeyboardProvider[] providers, int selected) { - providerTabs.removeAllViews(); - - LayoutInflater inflater = LayoutInflater.from(getContext()); - - for (int i = 0; i < providers.length; i++) { - MediaKeyboardProvider provider = providers[i]; - View view = inflater.inflate(provider.getProviderIconView(i == selected), providerTabs, false); - - view.setTag(provider); - - final int index = i; - view.setOnClickListener(v -> { - requestPresent(providers, index); - }); - - providerTabs.addView(view); - } - } - - private void presentBackspaceButton(@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, - boolean useBackupPosition) - { - if (backspaceObserver != null) { - if (useBackupPosition) { - backspaceButton.setVisibility(INVISIBLE); - backspaceButton.setOnKeyEventListener(null); - backspaceButtonBackup.setVisibility(VISIBLE); - backspaceButtonBackup.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); - } else { - backspaceButton.setVisibility(VISIBLE); - backspaceButton.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); - backspaceButtonBackup.setVisibility(GONE); - backspaceButtonBackup.setOnKeyEventListener(null); - } - } else { - backspaceButton.setVisibility(INVISIBLE); - backspaceButton.setOnKeyEventListener(null); - backspaceButtonBackup.setVisibility(GONE); - backspaceButton.setOnKeyEventListener(null); - } - } - - private void presentAddButton(@Nullable MediaKeyboardProvider.AddObserver addObserver) { - if (addObserver != null) { - addButton.setVisibility(VISIBLE); - addButton.setOnClickListener(v -> addObserver.onAddClicked()); - } else { - addButton.setVisibility(GONE); - addButton.setOnClickListener(null); - } - } - - private void presentSearchButton(@Nullable MediaKeyboardProvider.SearchObserver searchObserver) { - searchButton.setVisibility(searchObserver != null ? VISIBLE : INVISIBLE); - } - - private void presentProviderStrip(boolean isSolo) { - int visibility = isSolo ? View.GONE : View.VISIBLE; - - searchButton.setVisibility(visibility); - backspaceButton.setVisibility(visibility); - providerTabs.setVisibility(visibility); - } - - public interface MediaKeyboardListener { - void onShown(); - void onHidden(); - void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java deleted file mode 100644 index 08a2ec528f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - - -import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider; -import com.bumptech.glide.RequestManager; - -import network.loki.messenger.R; - -public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - - private TabIconProvider tabIconProvider; - private int activePosition; - private int count; - - public MediaKeyboardBottomTabAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - } - - @Override - public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) { - viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition); - } - - @Override - public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return count; - } - - public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) { - this.tabIconProvider = iconProvider; - this.count = count; - - notifyDataSetChanged(); - } - - public void setActivePosition(int position) { - this.activePosition = position; - notifyDataSetChanged(); - } - - static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder { - - private final ImageView image; - private final View indicator; - - public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) { - super(itemView); - - this.image = itemView.findViewById(R.id.media_keyboard_bottom_tab_image); - this.indicator = itemView.findViewById(R.id.media_keyboard_bottom_tab_indicator); - } - - void bind(@NonNull RequestManager glideRequests, - @NonNull EventListener eventListener, - @NonNull TabIconProvider tabIconProvider, - int index, - boolean selected) - { - tabIconProvider.loadCategoryTabIcon(glideRequests, image, index); - image.setAlpha(selected ? 1 : 0.5f); - image.setSelected(selected); - - indicator.setVisibility(selected ? View.VISIBLE : View.INVISIBLE); - - itemView.setOnClickListener(v -> eventListener.onTabSelected(index)); - } - - void recycle() { - itemView.setOnClickListener(null); - } - } - - interface EventListener { - void onTabSelected(int index); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java deleted file mode 100644 index 21bd6b3a48..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import android.widget.ImageView; - - - -import com.bumptech.glide.RequestManager; - -public interface MediaKeyboardProvider { - @LayoutRes int getProviderIconView(boolean selected); - /** @return True if the click was handled with provider-specific logic, otherwise false */ - void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider); - void setController(@Nullable Controller controller); - - interface BackspaceObserver { - void onBackspaceClicked(); - } - - interface AddObserver { - void onAddClicked(); - } - - interface SearchObserver { - void onSearchOpened(); - void onSearchClosed(); - void onSearchChanged(@NonNull String query); - } - - interface Controller { - void setViewPagerEnabled(boolean enabled); - } - - interface Presenter { - void present(@NonNull MediaKeyboardProvider provider, - @NonNull PagerAdapter pagerAdapter, - @NonNull TabIconProvider iconProvider, - @Nullable BackspaceObserver backspaceObserver, - @Nullable AddObserver addObserver, - @Nullable SearchObserver searchObserver, - int startingIndex); - int getCurrentPosition(); - void requestDismissal(); - boolean isVisible(); - } - - interface TabIconProvider { - void loadCategoryTabIcon(@NonNull RequestManager glideRequests, @NonNull ImageView imageView, int index); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 132c605637..38678f3154 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -386,10 +386,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val glide by lazy { Glide.with(this) } private val lockViewHitMargin by lazy { toPx(40, resources) } - private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) } - private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } - private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } - private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } + private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif, hasOpaqueBackground = true) } + private val documentButton by lazy { InputBarButton(this, R.drawable.ic_file, hasOpaqueBackground = true) } + private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_images, hasOpaqueBackground = true) } + private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_camera, hasOpaqueBackground = true) } private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) private val firstLoad = AtomicBoolean(true) @@ -852,7 +852,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ) // we need to add the inline icon - val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external) + val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_square_arrow_up_right) val imageSize = toPx(10, resources) val imagePaddingTop = toPx(4, resources) drawable?.setBounds(0, 0, imageSize, imageSize) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 3aa19af994..2f6661459f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -37,6 +37,7 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.menu.ActionItem @@ -98,6 +99,12 @@ class ConversationReactionOverlay : FrameLayout { private val scope = CoroutineScope(Dispatchers.Default) private var job: Job? = null + private val iconMore by lazy { + val d = ContextCompat.getDrawable(context, R.drawable.ic_plus) + d?.setTint(context.getColorFromAttr(android.R.attr.textColor)) + d + } + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) @@ -434,7 +441,7 @@ class ConversationReactionOverlay : FrameLayout { view.translationY = 0f val isAtCustomIndex = i == customEmojiIndex if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24)) + view.setImageDrawable(iconMore) view.tag = null } else { view.setImageEmoji(emojis[i]) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 038210f8ff..0832da7241 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -250,7 +250,7 @@ fun CellButtons( onReply?.let { LargeItemButton( R.string.reply, - R.drawable.ic_message_details__reply, + R.drawable.ic_reply, onClick = it ) Divider() @@ -266,7 +266,7 @@ fun CellButtons( onSave?.let { LargeItemButton( R.string.save, - R.drawable.ic_baseline_save_24, + R.drawable.ic_arrow_down_to_line, onClick = it ) Divider() @@ -283,7 +283,7 @@ fun CellButtons( LargeItemButton( R.string.delete, - R.drawable.ic_delete, + R.drawable.ic_trash_2, colors = dangerButtonColors(), onClick = onDelete ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index fe86f8d382..fa4dc6bc00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -84,8 +84,8 @@ class InputBar @JvmOverloads constructor( var voiceMessageDurationMS = 0L var voiceRecorderState = VoiceRecorderState.Idle - private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachmentsButton)} - val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_voiceMessageNew)} + private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus).apply { contentDescription = context.getString(R.string.AccessibilityId_attachmentsButton)} + val microphoneButton = InputBarButton(context, R.drawable.ic_mic).apply { contentDescription = context.getString(R.string.AccessibilityId_voiceMessageNew)} private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send)} init { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt index c21de8021a..655879edc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt @@ -28,7 +28,6 @@ class InputBarButton : RelativeLayout { private val gestureHandler = Handler(Looper.getMainLooper()) private var isSendButton = false private var hasOpaqueBackground = false - private var isGIFButton = false @DrawableRes private var iconID = 0 private var longPressCallback: Runnable? = null private var onDownTimestamp = 0L @@ -73,7 +72,7 @@ class InputBarButton : RelativeLayout { private val imageView by lazy { val result = ImageView(context) - val size = if (isGIFButton) toPx(24, resources) else toPx(16, resources) + val size = toPx(20, resources) result.layoutParams = LayoutParams(size, size) result.scaleType = ImageView.ScaleType.CENTER_INSIDE result.setImageResource(iconID) @@ -88,11 +87,10 @@ class InputBarButton : RelativeLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } constructor(context: Context, @DrawableRes iconID: Int, isSendButton: Boolean = false, - hasOpaqueBackground: Boolean = false, isGIFButton: Boolean = false) : super(context) { + hasOpaqueBackground: Boolean = false) : super(context) { this.isSendButton = isSendButton this.iconID = iconID this.hasOpaqueBackground = hasOpaqueBackground - this.isGIFButton = isGIFButton val size = resources.getDimension(R.dimen.input_bar_button_expanded_size).toInt() val layoutParams = LayoutParams(size, size) this.layoutParams = layoutParams diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index f245dcadf4..1212782d2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -65,7 +65,7 @@ class InputBarRecordingView : RelativeLayout { fun show(scope: CoroutineScope) { startTimestamp = Date().time - binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) + binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_mic, context.theme)) binding.inputBarCancelButton.alpha = 0.0f binding.inputBarMiddleContentContainer.alpha = 1.0f binding.lockView.alpha = 1.0f diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt index fc9a46228e..862aed6010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -11,6 +11,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewOpenGroupInvitationBinding import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.getAccentColor @@ -29,8 +30,10 @@ class OpenGroupInvitationView : LinearLayout { val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation this.data = data val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus + val backgroundColor = if (!message.isOutgoing) context.getAccentColor() else ContextCompat.getColor(context, R.color.transparent_black_6) + with(binding){ openGroupInvitationIconImageView.setImageResource(iconID) openGroupInvitationIconBackground.backgroundTintList = ColorStateList.valueOf(backgroundColor) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index dc6b05b444..5e8449c1b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -97,14 +97,17 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? if (!hasAttachments) { binding.quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) } else if (attachments != null) { - binding.quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme)) - val backgroundColor = context.getAccentColor() - binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) + binding.quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf( + context.getColorFromAttr( + if(isOutgoingMessage && mode == Mode.Regular) R.attr.message_sent_text_color + else R.attr.message_received_text_color + ) + ) binding.quoteViewAttachmentPreviewImageView.isVisible = false binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false when { attachments.audioSlide != null -> { - binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_mic) binding.quoteViewAttachmentPreviewImageView.isVisible = true // A missing file name is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. @@ -118,7 +121,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } } attachments.documentSlide != null -> { - binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_file) binding.quoteViewAttachmentPreviewImageView.isVisible = true binding.quoteViewBodyTextView.text = resources.getString(R.string.document) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 74c3b00358..ba0650934d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -93,7 +93,11 @@ class VisibleMessageView : FrameLayout { ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate()) } - private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() + private val swipeToReplyIcon by lazy { + val d = ContextCompat.getDrawable(context, R.drawable.ic_reply)!!.mutate() + d.setTint(context.getColorFromAttr(R.attr.colorControlNormal)) + d + } private val swipeToReplyIconRect = Rect() private var dx = 0.0f private var previousTranslationX = 0.0f @@ -463,6 +467,7 @@ class VisibleMessageView : FrameLayout { val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2) val right = left + iconSize val bottom = top + iconSize + //todo the position for this icon doesn't seem right swipeToReplyIconRect.left = left swipeToReplyIconRect.top = top swipeToReplyIconRect.right = right diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index b7103b9c23..a55ed51268 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -218,4 +218,4 @@ open class ThumbnailView @JvmOverloads constructor( private fun RequestBuilder.missingThumbnailPicture( inProgress: Boolean -) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) +) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) //todo ICONS replace with /!\ and test tint diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 6c4a9b3572..98a482b4df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -195,7 +195,7 @@ fun EditGroup( modifier = Modifier.size(LocalDimensions.current.spacing), onClick = onEditNameConfirmed) { Icon( - painter = painterResource(R.drawable.check), + painter = painterResource(R.drawable.ic_check), contentDescription = stringResource(R.string.AccessibilityId_confirm), tint = LocalColors.current.text, ) @@ -378,7 +378,7 @@ private fun MemberActionSheet( if (member.canRemove) { this += ActionSheetItemData( title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), - iconRes = R.drawable.ic_delete_24, + iconRes = R.drawable.ic_trash_2, onClick = onRemove, qaTag = R.string.AccessibilityId_removeContact ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index e5f46a79c7..f4136e5462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -106,7 +106,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto if (configFactory.wasKickedFromGroupV2(recipient)) { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_trash_2 } else { text = context.getString(R.string.leave) contentDescription = context.getString(R.string.AccessibilityId_leave) @@ -118,14 +118,14 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto recipient.isLocalNumber -> { text = context.getString(R.string.hide) contentDescription = context.getString(R.string.AccessibilityId_clear) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_trash_2 } // 1on1 else -> { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_trash_2 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 248ff1e4f2..d12d99b3cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -130,7 +130,7 @@ class ConversationView : LinearLayout { binding.statusIndicatorImageView.setImageDrawable(drawable) } thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) - thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) + thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } binding.profilePictureView.update(thread.recipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt index 4d132f009b..c8e1a792b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource @@ -73,7 +74,8 @@ fun DocumentsPage( verticalAlignment = Alignment.CenterVertically, ) { Image( - painterResource(R.drawable.ic_document_large_dark), + painterResource(R.drawable.ic_file), + colorFilter = ColorFilter.tint(LocalColors.current.text), contentDescription = null ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt index c6fa7d4a1f..5332dd8da8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt @@ -33,7 +33,7 @@ fun MediaOverviewTopAppBar( actionModeActions = { IconButton(onClick = onSaveClicked) { Icon( - painterResource(R.drawable.ic_baseline_save_24), + painterResource(R.drawable.ic_arrow_down_to_line), contentDescription = stringResource(R.string.save), tint = LocalColors.current.text, ) @@ -41,7 +41,7 @@ fun MediaOverviewTopAppBar( IconButton(onClick = onDeleteClicked) { Icon( - painterResource(R.drawable.ic_baseline_delete_24), + painterResource(R.drawable.ic_trash_2), contentDescription = stringResource(R.string.delete), tint = LocalColors.current.text, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt index 35479ae503..c50f1c65ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt @@ -155,6 +155,7 @@ private fun ThumbnailRow( } else { // The resource given by the placeholder needs tinting according to our theme. // But the missing thumbnail picture does not. + //todo ICONS replace with /!\ and simplify logic >> Do we need a bg for broken image? right now the icon is floating var (placeholder, shouldTint) = if (item.hasPlaceholder) { item.placeholder(LocalContext.current) to true } else { @@ -211,7 +212,8 @@ private fun ThumbnailRow( .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f)), contentScale = ContentScale.Inside, - painter = painterResource(R.drawable.ic_check_white_48dp), + painter = painterResource(R.drawable.ic_check), + colorFilter = ColorFilter.tint(Color.White), contentDescription = stringResource(R.string.AccessibilityId_select), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 169ac83ead..0022b0904d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -1,21 +1,12 @@ package org.thoughtcrime.securesms.mediasend; import android.annotation.SuppressLint; -import androidx.lifecycle.ViewModelProvider; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Rect; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.viewpager.widget.ViewPager; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import android.text.Editable; import android.text.TextWatcher; import android.view.KeyEvent; @@ -28,33 +19,36 @@ import android.widget.ImageButton; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import com.bumptech.glide.Glide; + import org.session.libsession.utilities.MediaTypes; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.ListenableFuture; +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.SettableFuture; +import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.components.ComposeText; import org.thoughtcrime.securesms.components.ControllableViewPager; import org.thoughtcrime.securesms.components.InputAwareLayout; -import org.thoughtcrime.securesms.components.emoji.EmojiEditText; -import org.thoughtcrime.securesms.components.emoji.EmojiEventListener; -import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; -import org.thoughtcrime.securesms.components.emoji.EmojiToggle; -import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; -import org.thoughtcrime.securesms.util.SimpleTextWatcher; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; -import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; -import com.bumptech.glide.Glide; import org.thoughtcrime.securesms.providers.BlobProvider; -import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.PushCharacterCalculator; import org.thoughtcrime.securesms.util.Stopwatch; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.Stub; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.SettableFuture; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -85,9 +79,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl private ImageButton sendButton; private ComposeText composeText; private ViewGroup composeContainer; - private EmojiEditText captionText; - private EmojiToggle emojiToggle; - private Stub emojiDrawer; private ViewGroup playbackControlsContainer; private TextView charactersLeft; private View closeButton; @@ -144,9 +135,6 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat sendButton = view.findViewById(R.id.mediasend_send_button); composeText = view.findViewById(R.id.mediasend_compose_text); composeContainer = view.findViewById(R.id.mediasend_compose_container); - captionText = view.findViewById(R.id.mediasend_caption); - emojiToggle = view.findViewById(R.id.mediasend_emoji_toggle); - emojiDrawer = new Stub<>(view.findViewById(R.id.mediasend_emoji_drawer_stub)); fragmentPager = view.findViewById(R.id.mediasend_pager); mediaRail = view.findViewById(R.id.mediasend_media_rail); playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); @@ -163,13 +151,6 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState()); }); -// sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { -// presentCharactersRemaining(); -// composeText.setTransport(newTransport); -// sendButtonBkg.getBackground().setColorFilter(getResources().getColor(R.color.transparent), PorterDuff.Mode.MULTIPLY); -// sendButtonBkg.getBackground().invalidateSelf(); -// }); - ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); composeText.setOnKeyListener(composeKeyPressedListener); @@ -177,7 +158,6 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat composeText.setOnClickListener(composeKeyPressedListener); composeText.setOnFocusChangeListener(composeKeyPressedListener); - captionText.clearFocus(); composeText.requestFocus(); fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager()); @@ -195,32 +175,19 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat hud.addOnKeyboardShownListener(this); hud.addOnKeyboardHiddenListener(this); - captionText.addTextChangedListener(new SimpleTextWatcher() { - @Override - public void onTextChanged(String text) { - viewModel.onCaptionChanged(text); - } - }); - composeText.append(viewModel.getBody()); Recipient recipient = Recipient.from(requireContext(), getArguments().getParcelable(KEY_ADDRESS), false); String displayName = Optional.fromNullable(recipient.getName()) .or(Optional.fromNullable(recipient.getProfileName()) .or(recipient.getAddress().serialize())); - composeText.setHint(getString(R.string.message, displayName), null); + composeText.setHint(getString(R.string.message), null); composeText.setOnEditorActionListener((v, actionId, event) -> { boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; if (isSend) sendButton.performClick(); return isSend; }); - if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) { - emojiToggle.setVisibility(View.GONE); - } else { - emojiToggle.setOnClickListener(this::onEmojiToggleClicked); - } - closeButton.setOnClickListener(v -> requireActivity().onBackPressed()); } @@ -274,18 +241,12 @@ public void onRailItemDeleteClicked(int distanceFromActive) { @Override public void onKeyboardShown() { - if (captionText.hasFocus()) { - mediaRail.setVisibility(View.VISIBLE); - composeContainer.setVisibility(View.GONE); - captionText.setVisibility(View.VISIBLE); - } else if (composeText.hasFocus()) { + if (composeText.hasFocus()) { mediaRail.setVisibility(View.VISIBLE); composeContainer.setVisibility(View.VISIBLE); - captionText.setVisibility(View.GONE); } else { mediaRail.setVisibility(View.GONE); composeContainer.setVisibility(View.VISIBLE); - captionText.setVisibility(View.GONE); } } @@ -293,10 +254,6 @@ public void onKeyboardShown() { public void onKeyboardHidden() { composeContainer.setVisibility(View.VISIBLE); mediaRail.setVisibility(View.VISIBLE); - - if (!Util.isEmpty(viewModel.getSelectedMedia().getValue()) && viewModel.getSelectedMedia().getValue().size() > 1) { - captionText.setVisibility(View.VISIBLE); - } } public void onTouchEventsNeeded(boolean needed) { @@ -325,7 +282,6 @@ private void initViewModel() { fragmentPagerAdapter.setMedia(media); mediaRail.setVisibility(View.VISIBLE); - captionText.setVisibility((media.size() > 1 || media.get(0).getCaption().isPresent()) ? View.VISIBLE : View.GONE); mediaRailAdapter.setMedia(media); }); @@ -336,10 +292,6 @@ private void initViewModel() { mediaRailAdapter.setActivePosition(position); mediaRail.smoothScrollToPosition(position); - if (fragmentPagerAdapter.getAllMedia().size() > position) { - captionText.setText(fragmentPagerAdapter.getAllMedia().get(position).getCaption().or("")); - } - View playbackControls = fragmentPagerAdapter.getPlaybackControls(position); if (playbackControls != null) { @@ -359,11 +311,6 @@ private void initViewModel() { }); } - private EmojiEditText getActiveInputField() { - if (captionText.hasFocus()) return captionText; - else return composeText; - } - private void presentCharactersRemaining() { String messageBody = composeText.getTextTrimmed(); @@ -381,29 +328,6 @@ private void presentCharactersRemaining() { } } - private void onEmojiToggleClicked(View v) { - if (!emojiDrawer.resolved()) { - emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(requireContext(), new EmojiEventListener() { - @Override - public void onKeyEvent(KeyEvent keyEvent) { - getActiveInputField().dispatchKeyEvent(keyEvent); - } - - @Override - public void onEmojiSelected(String emoji) { - getActiveInputField().insertEmoji(emoji); - } - })); - emojiToggle.attach(emojiDrawer.get()); - } - - if (hud.getCurrentInput() == emojiDrawer.get()) { - hud.showSoftkey(composeText); - } else { - hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get()))); - } - } - @SuppressLint("StaticFieldLeak") private void processMedia(@NonNull List mediaList, @NonNull Map savedState) { Map> futures = new HashMap<>(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index 5336503589..6cdbe22a10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -1,24 +1,24 @@ package org.thoughtcrime.securesms.mediasend; import android.app.Application; +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; -import android.content.Context; -import android.net.Uri; -import androidx.annotation.NonNull; -import android.text.TextUtils; import com.annimon.stream.Stream; +import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.SingleLiveEvent; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.guava.Optional; import java.util.Collections; import java.util.HashMap; @@ -231,13 +231,6 @@ void onImageCaptureUndo(@NonNull Context context) { } } - - void onCaptionChanged(@NonNull String newCaption) { - if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) { - selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption); - } - } - void saveDrawState(@NonNull Map state) { savedDrawState.clear(); savedDrawState.putAll(state); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt index dc13beed33..2cfd92d5b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt @@ -57,7 +57,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati fun addActions(markAsReadIntent: PendingIntent?) { val markAllAsReadAction = NotificationCompat.Action( - R.drawable.check, + R.drawable.ic_check, context.getString(R.string.messageMarkRead), markAsReadIntent ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 0b22de8d3b..9bcbbd60c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -157,7 +157,7 @@ public void addActions(@NonNull PendingIntent markReadIntent, @Nullable PendingIntent wearableReplyIntent, @NonNull ReplyMethod replyMethod) { - Action markAsReadAction = new Action(R.drawable.check, + Action markAsReadAction = new Action(R.drawable.ic_check, context.getString(R.string.messageMarkRead), markReadIntent); @@ -169,9 +169,9 @@ public void addActions(@NonNull PendingIntent markReadIntent, String actionName = context.getString(R.string.reply); String label = context.getString(replyMethodLongDescription(replyMethod)); - Action replyAction = new Action(R.drawable.ic_reply_white_36dp, actionName, quickReplyIntent); + Action replyAction = new Action(R.drawable.ic_reply, actionName, quickReplyIntent); - replyAction = new Action.Builder(R.drawable.ic_reply_white_36dp, + replyAction = new Action.Builder(R.drawable.ic_reply, actionName, wearableReplyIntent) .addRemoteInput(new RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index fdb58078ee..0b6bbf904f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -483,7 +484,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Divider() LargeItemButton(R.string.sessionClearData, - R.drawable.ic_delete, + R.drawable.ic_trash_2, Modifier.contentDescription(R.string.AccessibilityId_sessionClearData), dangerButtonColors() ) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } @@ -562,8 +563,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { // empty state else -> { Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(id = R.drawable.ic_pictures), + modifier = Modifier.align(Alignment.Center) + .size(40.dp), + painter = painterResource(id = R.drawable.ic_image), contentDescription = null, colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index 4961b1f0a0..b08bb0c6f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -53,7 +53,7 @@ fun AppBarPreview( actionModeActions = { IconButton(onClick = {}) { Icon( - painter = painterResource(id = R.drawable.check), + painter = painterResource(id = R.drawable.ic_check), contentDescription = "check" ) } diff --git a/app/src/main/res/drawable-hdpi/check.png b/app/src/main/res/drawable-hdpi/check.png deleted file mode 100644 index 9eebc48fee93d83d867f28e3cc6f85da8d5a502d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 292 zcmV+<0o(qGP)MBxPiEUXpzn?OR&)uXfuU{X{WJwx|HGu?B>eg{M?Hc?&P(T z5u9n}o12jlmO=RlWV@XTC~Kj)24UYM!yB3x2ZQV%6Yo|YxWP$I(TaM7QL^q74K z4#@}?<7hp3I)EC q(Bgj+UuwLF2rKRsd7I5<6Zrx0*GNr#R=oNE0000s9Kx*5@$g%PS0#!+N4Ph%bEr$nj>liDKG z`J~G%4RugdtqNa-TV{bBcSQ6GU~TAzShEe=$`7%T7@2RP^CCgVg10q)c*>T|gB&fiR< zzW{iT_A?D11Fh#P{c!hr2;W>9>qpADrPx)qe(d=nqNp|RTRdp(dss3h2)4b19nA3MZ<@{u+q|*9t?*Xtg%vr9xAb3 zeX6HI=ph9QDn$GXrl?>lB8edg;d3Zv*$nBZjbjQ|P19-pZfEPh*K_VY=XiCxEm+=j z_St)_?>l?1z1QByn3yP+`%V#muK@wJv21b;Pd&` zEs;5VfGuod8y|)*eB#^bFqbArqQE~PMX9;DxkU>L3qLx0EU1 z-sdFzAXC6HzLw@->N;Bzy?N9upSe}e@bm_?R6_75iD2W zOT=eMXJ_Y^p};G@U<0ElSpA~M0N3Z(j2O>C{T$pD2~{D)vjf@GG*wnsen*||?(VA< z6&3HwrH;k4xq&TUzn~fs6#gJ)CuQ3DsPLevsi{3bKmX(9<>iH=Vp~@M;Cp*}Uo9^$ ze@|MP24%2C1DgusfHvHaZ+OSY$G=uxX7)>sW-qSD^@;mxrl6 zJw2b;u`D6*n+zj@>ZLFwbnur42M3$&*f*;KHu3kAC|Cn}K<@n~3zr%j8$UE_Y*r@2 z>1IJeK`0VB_$!i_wV2tlTLw14dC9;E<^XXujY{7BdtqVWn_{V0dg8U!)zzO3K>HmX z9iK}%Q{|PBk&zSURy8#>Z-}jcX;khH#q~nAzP`RxQd07^Sb9-nqf|)!)ZgF#soWQd zULGAC?Xdfnt?JoX$WD|>Y;0`YDlILo6u{4WJf3{{eO9!1Y;3H{3Y3#_t5ia*1LDhn zw6`a}Thr6iKTzOAWSLLu>gxKetgOtty}eD4Ju0)tJH^Gt#K?)oz_cZXz{u&{F@X$m zaH4ctdg69{eSO2!)YO=hHyo;NsB~Iq!L-C?flWhHZfGDwFGSUrQjY-$Ft#`)_RY@D z-kO-0Xr=I83K+m57&y@eo9X!1F{)UI%3~FX5DQU#umuTvfq;(6qeTcHkYIIYpB#0E zyMoy-I*F(|UWWnkphz&B{2w?ta*X7u-D!T{fCQpqf?b>c6H|6}c40JpV)COm{y>7+ ziFAY2nzZ(wf8=9`I1%crX_oBO(P!5A`T5n>*4AGl`I*%z0Yz3PLa#uEX;-N#qt}rC z%!lrO;T-){MAR>Ubv>#X_-_UfA|=pOuWS3-Z4cOS5Ntba4r9mY|E|tIelGA<7ibqp P00000NkvXXu0mjfdO_tj diff --git a/app/src/main/res/drawable-hdpi/ic_document_large_dark.png b/app/src/main/res/drawable-hdpi/ic_document_large_dark.png deleted file mode 100644 index f17a5078a6300234eec9b8ac71dea88aec80fb61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1292 zcmV+n1@roeP)Px(#z{m$RA>e5n>|k?M-YZ*cew-c5t$PNVC2M>qYIGGNuWDBA=y7*XEp>#1P+|| zA2@XAz{$l3EeU}`-ys4-M1T{VVL-wcVDTZ?4_MbbnzvNf*K;>}%gt~N z>FQl(wX>zbUrK&=*S+nuuHXW={kZESjq}Yv1JpU=Ct&qpXy{qu z)e-~(4E65rF1WqD4Ngx_|5#aBS=!mz`CF!^23H}7(FCx;t*xz}#>dCMG5*xdQmLeH zl}hEa#l^*^dwYAYj*gBlJQuN>?iu#G%8nb0gkY1ClT~Ld)h^&rmfef{O! z+}yNW1e#nJxBo#cE*K_Ykw^fwvKIo}#>U3Ij-?A`*U!|Jf9UsuvtTw$ z*3Jx&a0?3ypNa@*b#=LY)k?6~0FCUD!8ek)0?q~r*J+nU98&%!Q^HjI6prRE-o%o%N(-fR@^^+ za&~rh+)Xgv4C5F!o6VrnXav{S*Wu0az`($JFDG><(3Y0Fp->ph^z9x3IxQC1*o4#NGCzu{qyxhM zVHTiR_59{8V@dfR0+>C9YCi-pH;EzKLjluFk@BH{kw@DCOt&H@Mv<|cBuZr#8Pl!E ziBV)MCy7#-MaFb1a$*!2%SobCW|1-7ikuil#&VJTQ>ULDtf%+0hnFo z!W}{&3Si)~V7&&UCW5gg49B52EH0QEfQbYm4BKdlo;B1)C||2$0EPfpEEho_$K4o= z`F!xFRjdxK2_OB|<~Z8$K};Avf%Lg8;eNetNG%ikHoZq)tKll`gV*UdU-owz|t)W){0000flo3lqX%Tr^v|*b&l!pnarPVQE<|#URQc@S|Y)O3bEbKhZh#q8l zI*R0ZA2H+dP`l}q$Cc8$+-+?ccm9TZ@%?>Y{N8`FgYbTOJNNDc004Rc{y4(6G28g- zfVQ`X3ZiUF<1)d|8^9NttpWhR(Eyy+xdiRc6=QfKFQd#*7pWNHE0aaZ9Ctu&3H03d$EH{Pjg;6ERO6KNrUeC@hHZ?VUv$V9l*_v{J;b>wlGxYc^t-;Lk@=HleaHGaZ zI?qeJD|EmxN32PFayqCKl_vTqzy7?Zr->Xo_>8PP-&=MlgU)wpemq}rNTQSdBepnx zaq8j2hdZ7*e6>wK9+IER^lsEmsr4bTL?Y4Uelc0$_~r(QVz?j>2;f;+rA~g$bDk&a z30B<3>2a;O<>h5uBLf&%rL5fxz!e`UYMZZC>zV}v%;7@iqh7oEiEpP$;~*1G&zU1` zF^7)~NqG$0aZDU{@n6Wbc$*m=h zN9t)Qw(|hZ=ViFnu224?E=!F&_9Z7`-#wCjTwhlwrKPD=LZPs=eSbjGv3sbfwJs{z z0AkP=pg76OH|b*v^a@`;xyVYzxVd#7+?*7{3l5mz&boWfMRyL~H-x+n3=E{-&delb zb(ggxDy!_iQ(qDf;udTeX^d;{^Q2xg|MQwJEX z{YGb)CYJtdpDym?eGkzPo~mVa+mp-f2&6s8^@ND@)nMSFk*TiL8T+;o4$apbU)bUa zt#`VU^ZyQYi0RaU3C#fg*y_p3QdII|FaVTmQ&Uqxrp5S(T$#OVK_GGG(kf?#@-xb<4+Vj-Awk9_v_&JW7)*6#Anr)f1PN=Lvh;y)4_U%X#}DO2bLD zfj`)*a4#b5BkX^eka$lBMm& zX7^vPxtA&4;*Jcff2SW&f(d`+AGLK=3D_9etwIjUyG5$sOf!bapQ3O5ZOhdg=9n(I z^|AYr$r&0T)HE>0f!ASzHq1dsz2QfPBRjP~&@ANRD@B+erjOf3HW2`s`g|guokzL4 zQ8RAr35#7K= z%=IX8oOu^E>T~I6+0FHF-sUS{!!WZrB9o(eMo8BWZ8zE+LY>}D93a3KkK=oj+5Z6q C0cNQH diff --git a/app/src/main/res/drawable-hdpi/ic_document_small_dark.png b/app/src/main/res/drawable-hdpi/ic_document_small_dark.png deleted file mode 100644 index 4142741f3191e417dab36b1e08e8ef03b1d97560..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 974 zcmV;<12O!GP)Px&h)G02R9Fe^m_bV$K@`V#H`U;!6p4pI54ji;suaIPPa^aKcqz2KS|QMT6npEf z-@qRI0=?)#=_%5KpvQvEr66s@NYkY6Kkk3?GCP{F*@PZ;VD`P4ee>qG|DD}rCkzH9 z9Jq<+>Pt>~IDJQqtIrV@1e~0fL60^!H>)cvD~}&YL1(YmGckoi!NlCwo|&03lV4w7 z_nXb;x1F7xuQH%*1`Q5gfS51^c6N65zEmo`9OUx%`u&@EVdVmPhlhvnR##U)N`{|S zmXUywH6X0l>$QIc)45`}s8*|O9~>OKmU9oKY9X|*hbF#;LvXaTwDcr7JBAa(ndPv! zxVS9}-W!l`05esVSaA%dAPfhB05Gm06coT$lG|HB!jUKBLT5)-Rv-=F(b18&f*Q^! zha_15X$6@NxvSOcmM@@&Lyk0w) z0us*L(xD6)G@#n->}+s(c^O<>Tm)BFS3#%KF;)DqTrM~E_xEoFG;jY5I8rHiOQGD{ z+?)Yxx7$OvSO$gj^Ye|py}jPr+8SQezoc>vgj6m`p5@aDF#P(V7K_E93PsT;He6m_ z-rCsMc$z@O&P0@hYAAriZ!tjN%Z1y@nK+4$1h6cPFH(C_jd~M-ljNoV5^%79!&A#A zmllCIFBgD+6kPk0g~NmZAQ<5cb$rl(-EJ34jIT1-{=}8jFaRA{padPftHAEG)bbe=4QbYJHLo?YA=7 z52=5on#~-3BN8qF+}+)MUa3@`$#-nByRP>(KrLMawO))JBe3ckv*Q;Nbuq3hhm6ydxDR2rc_fcJ4CiP6V=` zUqfl1G5584F8BnaMW2Zw9463%7zmgwfRNu+2x40ktKrb00pV*f?VG`49PD|JihxO4 wPDslWquPuc$7(?2NwqbE_Q#nti?IguFAS|eZ!))c9RL6T07*qoM6N<$f?uJ|k0wldT1B8K8x2KC^NCo5DO9wfb3Y^nVJmz(Eey#Dmlh8vGZxtDPS8geByw{mrL z9#4Cu$MLX1k*n~Vl+lB(`>!4Dt$MHMzERy{qrXDDRO_MNf2C^~H>obXm^`21+Wi$m u<#GJJXChu7*!EeGeZj&yZq+FPPUa^*&JwSi|L_XXSqz@8elF{r5}E+N0ZJeM diff --git a/app/src/main/res/drawable-hdpi/ic_image_dark.png b/app/src/main/res/drawable-hdpi/ic_image_dark.png deleted file mode 100644 index 49cecb88f60b3e862193b21855dbab860eb3bea2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 303 zcmV+~0nq-5P)wvAgM$I3ont{GXw%Q9>R+VffP0xv9a<9)+sDJLD*q8ijCs0jSmML z9cCugzOM~$es}{R$&^Zsih+qx6F2PteY_!-p>ziZQn+#lW>Q$Y1Bqxr1Ku>C0S)wA zE*W@WkDPI-z(kdO;fR;`K+grX4^Cv_G$J6Y*yWy|&aIg)A|R_Mc;wq1JJw9j7#et| zmL~@o0G=qU5xI$wfW3;EH(ojZb*>2r$SMS9w9d&6|A4H*liY-VKvv;LZo)s%W&von z;cfa%3LAIecInUeLkzK2l>hxg&Ed=8T8>vLnNR#i(of~M`WgTL002ovPDHLkV1i{^ Bd#V5c diff --git a/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png deleted file mode 100644 index b414cf5b6881d6ec172d2a7fbd73ada5bbf167ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 261 zcmV+g0s8)lP)km|k0wldT1B8K8f~SjPh{y4_XAbf<81OJ3(CJ7} zndX``b76v-N7DYNg?AXzx0$@j_%O$-MDb9#M4)m=#h>FoD;{~Q*wnG^Q^%Sa9jkV9 zthk}E_JdQ<#r;ZwuA-qP*1MK2|9P?TocWEMO!)@USli?GV)cP`F?hQAxvXk`u2xGGwYlhzxQq(rc$el5$e3bU&* zGmrLvm;7DTb&MmKQ559^pw)(M>P#U{Qv|7##YmkGK| z=wpQi5`18a5eYfa#UyPxFu^2aIbfKr3%InfvDo*Pg^h!2GSDR{-H{YMGl3bmkr%Pf zTtBdUe2fBBZ{>+C2cd4D8~97!Kro~neBRdyxTJ)Alw4DoBV6@BEBN(cl!R@o2d>24 zzY@kkoJCbVknznaQRIDZsR!b|sSt(j`?zkv23WI&2kL=i-xP=<=liUBpdt==NEk2T zkgwGPj&NndC<@zA4@^l5+mzgcFiD+@2ED+S7_4kgF_=%!y2Gx%GVF3xu*tFf);;J3 z<}@OPb&NahaV_YRmL8ws$>b|@N}DQK;@&!;Mu+LPzx2%qCYaPx%vq?ljR9Fe^m|c$AFcgJJff;D3YD8Di7t}p;7u^7F-2m%g1AT*~RP7!rUfK;5 z^(FiaFzNkLA1kDpK!|6gs=3mUZ|v*q<6|c_bULm5rwT-Y=1m?UpE9)vAzxqH@73;6 z-T{(b1NoHmU_Lk+Alt}D-qaxZ!f@twewm#ggk)%< z zJJ)Gf)!Eu0#7*nouyy6ht*eYEbupI;&g;!)b8|c%QSDJfJ&29R<97B{#=JRCT{#Y4N zW&$95jwD$Wv&+BT&W9l2lXIewJ(rncRQi}AQ8g$~rRvK{6s=N=Hu6-(l;u6CV~~Hs zTB4zssa3R9G=KtWuh;XJ2Doo*n1w z=xj_@$Di_FQ<8%T^NS!h@RuXDF(d|#Pn}NYUS}bQGBQNDbaN!@Smp)6Qz&400000NkvXXu0mjf+Tv@- diff --git a/app/src/main/res/drawable-hdpi/ic_reply.png b/app/src/main/res/drawable-hdpi/ic_reply.png deleted file mode 100644 index b3bae928957e22b4127367b9b0be667752df35de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmV;<0WtoGP)fGKoCV)%fA$m6hH%M zfC5YdDIjhj|8~kS1=t19fE#cFE&vLk0CGpd5+8}p<1Vu+%}67J#KJwyYhjZ_ZW{wI z00S@p0|*kJ>$>-70U*A>;SeRjn&RmIYm27>Xo#l)Xo;r)Xo_D9pe=q8KnUVz1B4=e z7C=bi#kL;Br?~!OaNauS?rZ?3^yXW*z}(>1C15U)v=M+3AXhrT$~i9pKLBYdMXXS; zIsAm@rZIoeWK$rX0w9!?gj5OQK#71NM2;xrloV2g_@WImfxsI{CKUAzYxaYNkoKt| z{t15@lS){ZJ)0Agig-yRq&30?>H?SUR*qa+P`eN-op7IWBt%AUqhyHY)nM~72Z)7z z{;PiAdpzSCUek7?gu+h#5&JNS#!+g;%XZoagmf6JZ~Dz#<@I~OBSfM4TSar&Q8#lM z5L@;Q2kEp+Vz#J%ulJFtqXvkwhz-C148Q;;+phov0FBs}aEBSND*ylh07*qoM6N<$ Ef?-s=5C8xG diff --git a/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png deleted file mode 100644 index 3f8076f25b892916c6212e536f3f1447a6860de1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 467 zcmV;^0WAKBP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00B!$L_t(o!|m6|%qcZk{+|jieoHIMvB3MKb@z{zjR!IAl6L7KJi-XPdD&SaizdlG&(OT*@4pMLi4} zEbIJ6lO}oT0}@P~tlZJ>&jV?Rrv52ayVuokS+{W!->^%OGF9$)9D==&vi-zNvBVDN zGzM_{Qjg~ZtDN!?YJwTbv0$DO%|Pv*^uUwh_`CLvN%>K3h3Bv84UHjnj#~h(bjEL= zGvG7)tV%5wkdg00026Nklp3|3CPD@V_Z}+FAa00kt3hZ%nTC4zP9$O0=IC z#M=L}{*V7JN`dYFbN;{hzv#aJ*^d9O{eS!aNB5Y9d5*(|F_804mD!e|I1`+ j2OFXO-+r{{96_ diff --git a/app/src/main/res/drawable-mdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-mdpi/ic_camera_filled_24.png deleted file mode 100644 index fa5b99e7bfd20ddb2119db67a2c57a48da108c04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 389 zcmV;00eb$4P)qY>*Mk z1V*l2k6xUgKt;lml`7A9j@_5EqN>=_x?R~@5{SqsBAW5hEr`uplyd-+g;adXtzS5< z55N(?dxfmUjpKNefaJd=j(th8s7OQtfCtyVTl)YobLt+zql<{*RrRn|klMlOR`TF_ z3kG7}pTSd&c6J^`GlrV}*}l20VJ@y@NJOlt{Ndjl{=chL)gCl?tdSX)%QQ-0{- jPp<3TKREyVuL8gq8`zkYbiv^_00000NkvXXu0mjf!55`( diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle_32.png b/app/src/main/res/drawable-mdpi/ic_check_circle_32.png deleted file mode 100644 index bfb3eb03356a02c01cfb2d2aeca83be042fc4c95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 809 zcmV+^1J?YBP)Px%<4Ht8R9FekSHF)EK^Q%M8xcG&R8mQDiLo*kmc}cD1nKp9{{zC`KtW+;VWM8d zs6-%v1SAj&Y7?*!EolhYm>dLABe^}_^JX@;vpekW$%$X`Wp-x2dGF0PKX!I)ZN+xN z2R~!!ClC4I?glE-_wh`3H7^1r=^3D%hhGroS(K;Xv_1GQ_)qwEQO;3P&_W?@0!TpO zi}1_oboyp8nT$oF(Obb_FdPU3*3rH;o6W}4>GW&A-+y1NR-4&uwu|On_%}F(P@pfP zu+#7m{AwnXc{m!4dix%YvFyXX0)tp?gGI=JF_5F)h(sbcdcEE|kG!@p_9X_f1g0ku zRJ)N#ByM9QpKYi<8;C(HA%fTojVnAv)yp84T8Skx!6_w&qcJ^P<g43L9daB`Y<4o}y{YN~O{cBI8Ui>k6_Y zkJoCo7s@+ZRVQH46#N0<$RHx7h%m|P_4-R2n?r+c3Op@BkmM+$TrNKb_8v=SrqO7; za^P}o(k(*I+pyVez6R-?R4TQNa;w#9y>VpHH0rjYuY(ArZ$W+!WkJKEZPpz`zK`O` zWHQ#WXkIuS#qv1hy^U9=-CQpB2&JoqLZM5pK}vwY=2eYcw!5Ot!pc6!)XR*l>_S2T zH13F%UCAhbQ?b%ZsT9FX9Q(E0aw{RTV#oet;wFH47{kTMY2iofpZ=PG&ol~=Vkk(S nAu>Szn9Bb_+dh|p|5f=LvcK5{*am+D00000NkvXXu0mjfNN0H7 diff --git a/app/src/main/res/drawable-mdpi/ic_document_large_dark.png b/app/src/main/res/drawable-mdpi/ic_document_large_dark.png deleted file mode 100644 index 52f8d3654c6a78b9bf4893df7d3d478db863c140..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 837 zcmV-L1G@Z)P)Px%|4BqaR9Fe^m_3gYK^VsukmDj0R}hH_CXm?i@(G}ZT0(85k?P_HFc!r6GEP|2ZCfcAX=O?&g}EWO!a?cAg*qotCT@c^+3bnM_(@1f$Ui0886|V<;|ou+?hyqF%4RiO1tlLZQ$D+rme~ z-EL~0D`*TB{8yNpWoV&5 zKy`u(<#M@q&1Ul%jmiDUcZ4soSO!886J>0f=pg%hqf8#NXULDAjJapHqQb_mpQqc>GtZmF3StsZ1tw zbqor(iF=6r`_t+4r)8BB11hT2na}6OY&J8(;jnRbc4k?H6g26)ds!-#KG1;Wobp5= zIy#IOP z$CO{-GK}BDkJ}BKIv5P9E>)0En+2o} z6!Y%{h(Y&IyoXi#PJtLqS|3QNq4Rx(bgIaluaHzj=lcrjRFOGfA*qJW_Z8BqB6Ge% zQVpH&E2L9J=6r>u8am%sNT-U-*%jK++vE>9!ApN%>(47K6_Y?1SdYi!FJ3~(wfD8; z#|8smJovpx*>1P{wNNNrM5ED1^g0}s3%KP&8Ty|6xmK&a9u9}UXkCb*l(N835{dy5 z@BHWc7<1fgD7>YP4-6aX<~~+X3`9Al6o6!xJLJyHd#h^@Hj8BDdAyKV+}N z`NPK|E-bC|4;Q%LC?#ke{?l0Ukkcu|HF*-pUf92CunJtv9&_GndjR_l5n~(_HY|F{ P00000NkvXXu0mjfdUb(t diff --git a/app/src/main/res/drawable-mdpi/ic_document_large_light.png b/app/src/main/res/drawable-mdpi/ic_document_large_light.png deleted file mode 100644 index d0b6f2a2ff25ca209e0b21f16462a3202a53105a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 769 zcmeAS@N?(olHy`uVBq!ia0vp^DnM+&!3HEN-EW8jDaPU;cPEB*=VV?oFfi@*ba4!c z;Cwq{_qtmS0&TpO2QLOr@hIuk_$;=ENjHCj)^sI*VNI`lil(}zO>NV-ER-}IVwi8_ z{HuSwkuANn@68R%vwbFK-o1Hu=J`j~x7V7oSH_lmznp0k$l|bO&DzBuMP_|Hy<_Xz zu-B{3pDVFaJz!`&fx9bv=8UNKWxLP*todizwW#%^=JeA?-<0j%9k>3w*hVR>cYDR# zD#IroI#BIAV~$LjRpN@}mrt&_{(8mz_xs)V2%O6D<};BOn|Y>nyQ`M6xWj_aKXc4x z_eQP_yI5eM!{VxN$fnV5`U6*;#sbH*-FIg_EjlT1F~Q(R!;YO=st)E6KNfLx#0eia zoUhEdDPZwM4e=>#RV$mtGp!qnoH+&JKd)*ia-7Py<^KD#Ne};Ix$LNwm#ul-%&z_e^F`=zS|TWecmLND6IE5`DM-wrmdKI;Vst0F?S;5C8xG diff --git a/app/src/main/res/drawable-mdpi/ic_document_small_dark.png b/app/src/main/res/drawable-mdpi/ic_document_small_dark.png deleted file mode 100644 index 2dad4a5ddc0702bba00b8d0ca2253c83b390d652..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 583 zcmV-N0=WH&P)Px%0ZBwbR7ef&mc35HFc8O`qyed7V`X6KP{y(|cSY*dN2quLcmtlJ6JwWd1^NOV zKw<|5q*g^8(l(#m-^p1{mGmR&gmTi=_WAC9_QjQLh**rIMPrki+IU+xq)9Jvh_*S_ zXi`Cj!{KeK)q1pT`$DtxylSH;5~Rjrv3MH{26rT{8YB~lU`}u{olZY2%evHpGP?;# zc%CO_B$LUc)9rR&$eAJBs(3CDV6}Z9DiT>~XB2bZZnvNM{r(M^c+uP{cOAriHJR5j zjC39v?}X{e)6sbp&8`D?!3u)_VO_7+62A?GT|famo6qOJGGdP(d>`NU#d5h6j^l_h z#7U1O2!h#oJnr;*y=S(S0s)4T=en+--G8-H5MrxqrxBxmfr|%trn*NXhZNx%7c)Ucc%9>V7zUbEVUyf&k_x;K>G2{ ziaDCC0u?T0Rl2HlsshzvP^CDu^S?_M(i`CG(30f&Q?5^%Qx6HfbS^z89?0P*FL+-& z<@g?rM)&0JattmeD1f-%aS_%Vjm9axY7D$^bfE`F>aM2w)R^z)M|=lj$sOA)>HrMZYupy<&KntpH;&u#`(+rooXw3 eJa`u;XfQI&e3!+sSh^o%5re0zpUXO@geCxMuqbc< diff --git a/app/src/main/res/drawable-mdpi/ic_image_dark.png b/app/src/main/res/drawable-mdpi/ic_image_dark.png deleted file mode 100644 index 3d0143714b6a4978cf7e05122d94e08e06485b3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 225 zcmV<703QE|P)kdg0001}NklVuJ z*Z_o$EWm<84oy`TUIXAZVgiJrNAMZ&8etv00000NkvXXu0mjf+8|zv diff --git a/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png deleted file mode 100644 index d474bd577d00d2aa045685f38b1729e4b2c314e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+i22U5q5R22v2@-1_)Iaz?{r|_t z@c(}UO&^5wG3f`I3NX5v2Dl$y{{P_r{X*9Y*v@e=cBuVzj-6zmROnb2dbs~(^j5ic0>pDDE2 jzES4mqy6s}oc+sic8*Qfk2kwOj%4t3^>bP0l+XkKat%vT diff --git a/app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index b51ce3ed95a437af48d672cb4b8494807587c080..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*1A5Ry@5R21q&u~5;q+UmdnXs1e(j>>FVdQ&MBb@07N}3VE_OC diff --git a/app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png deleted file mode 100644 index cf9524a2f8e7e7e99649e7c7d87ea07970c162ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 353 zcmV-n0iOPeP)kdg0003fNklv!P!p1h0PJtQubzZgtc5^ zRLaKErOHc$zN8W9_UmJr4?!!)X-n?YG3Cqnkd0a(iovH}xxtW)eKRH8Ni*G(D4VH% zJD*U8qe9@Z#FiI4r+_9?V&=+2BR0pkS1z zi(`mKXY!x_|LvJ|8HIg))vq$SxgBFx=uvEB=h2hzi89Dy409;ve&EVFQStPx$iAh93R7ef&mQ7BCKoEryFlJ0dGcHKHjaTttUd3a00b{~K5^*sZ1;+O=-Ovm_ zfhwXvbWtUlt_9DRpy!wX_UFh;Wo-NA3J zF|5&KJPsGeLn>}1B}lL?PFT_O|NsBbO$JM!8BEN!oG^>?NLEQX zgQ>3iO~xzR=BTh=I8?Mq)9+9Xz?hgCKopV_qG57TJY(G)P zFtz5NOh#31XE=q(=M%&bP0l+XkK#q@oT diff --git a/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png deleted file mode 100644 index fcf2096dd8eaae60dc1febc820079c4112ac8fcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 350 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K;Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZK3>dAqwX{BQ3+vmeOgEbxddW?y2nj|ESCVgiR`p6>GyrkH?`REfx zoh4Zx@x?PbAG=!fVnkBc?BtS;^*_kK8-*knaxL8Yir7_ej6ERU>U=Oa^V!(Ye)!q2i zr$2=|gS@{h?*1ZaeE8`Hx7^LsJ*F~B|2ZD#9=647we_~i+b6PBGranAEbJibGpV)L tQceag|2ccd4Xej1e2nF9?&rD75c2nN&!dBd6M#Ns@O1TaS?83{1OON_fv5lg diff --git a/app/src/main/res/drawable-xhdpi/check.png b/app/src/main/res/drawable-xhdpi/check.png deleted file mode 100644 index a2e651eeea0df1d5007b638a295da9f6be82f611..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 397 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 diff --git a/app/src/main/res/drawable-xhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xhdpi/ic_document_large_dark.png deleted file mode 100644 index cb4e3f6defac87fcdf7de9b5ecf1e1079832b13e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1899 zcmds&`6CmI1I9OJB3tEn$+0D(y(V|j*s$E>D0e8=LUQayT4t_y#oJtO^a^{E9h&D$FI3{+~ zv{MF3M*zmUT3Z0BN0m4LfVjG?70NvU$Srn^ki9PRPPIl==0d-n)K%@=fZGlWwXUzH z)olZ<|JJZlJ0njb)dtcT$}mp_vNo|k;J=ELGy~nYY5i+XmUD%zVbizFBQf@fL(Y%P zSRQrKSdhSVXftlxSlI4TjUB5At>AW!tXCSslN3R7IVWNB(Nmr5?Hw+2^8S+v_iIov zl+KFF(d8A>zo8*A3Jp9&Yf3xqW-WXaReA8T*=~Tju%)c5%y{m<$7B6vD&H`!6C4~7 z5n;O^TIp8lC)w|f)58hSQLZh!cH^dd-1+#hprDoY95rh*OjOiH4n6)e%7=B+%PSij z7`V7WBob9?{JVZmH-*nW%v*M-csG=qLx~D|tj&21Rtl3Swtotz#vbf(Ye1@I52~A* zc4bp0Y2+8O#a5Soxmh#IkN@pA8g{9lsLx`tx}-p$L-nu8u9it}^RFo5zQBF6{*)ZA zvH#TheQn}J)BtU4G{i@UfX02_!9-#(l}4tfrZ_&2H8xgiewex*Xu(y>H-tJmYR&Xx z)oTEZo&)|yDa+{+nGSZhw;xWS(P%wnG8q)KVDGx0T0T8#`HZJCB?gIya^qDH_ji^p zV3CWE%jtR3VQr1YcLytk1p-0ZlsaxfM?14G!+`Z~?enK7&tIBO$ggBn8AJmhbzd(E z*;(GXqtS-w8!O#Yd%# z5hDHY#o}T8Rnekz$&x9}$-P=+4wfP;4|un%KFbAq!^B2V6QuU` z_K&g>{_MrqDBXJywq&=Rj74vVgk5+x;TZ5vczBZ6r>!a-IM7q!)exYU2NX%~Ipuy< z?i>4xw_&=1h*qjF#T8#0C`0hxojK$Lsjv%QPrz<0fzZ@NdZVFd0nQ|;^+5%~0}rNV zSSeQ0%fhI+ltSqMH7?*>$*r_}zs!;jDQ}(eV`~b!A!GVz?~|mSMbnd{v3||}7t02` zZwQ4oguI2vFqrk?9v0+P;8OYO{{H^Nct5M`Wt}#q?qbMkZxx92vop%dRLjtoIfLf5 zrlsD~@CQTba^vF}Z-zVcZsvIuNWVXD|MztAtV|-8EatMIp`|T zB4w<4iRe!t%W{e|K$|*_j*fy4^YaJIor$a1xEoZ!XaURvZl18o1`&f@E;#Fa_|*y- z-E(cSsL@}KL+T{*Cre67^uNaB7_Xw$5^CatA9dmbq-TpWL8>1wCw^Ut&%Jy&_$m56 zX9NhDIcL;sqqJYZy{&vS>@{V`ei9$%)cmf~WJ(ijmdKf!60xa34u>8kB0z&?$Q^Bv@al$)+UJ{wOg{mz-WDlzHcTwyf za#T2%La{fb7qX{ZUiDR9pU$7w00a`GZ4SaZ(EKlnyjH#aEK8q8uQrdpP&483U~vHn zYdEUa5xlARXr?y#dzN})GFVBBF70u005(qcJ3!cp5&&m z;7K<_jG!j~J#fEl1JL`STmTR}LfKhkVn7>3POfU6GF@>uNly*jIK{8UhS_?vcCYiQ zafz3lL1_#-VNU~)7ADlLPIRdOs@Eh?=g`mp5Gs_v9ZCpQzN=L^yl{Wwuf65 zB|?*DAer0O!&@`fSa&3FSJ&4cHNJbdb@cP+&ywZkWtLlFA@sbBm6_SNmygfk?OV4N zq*Ld?+!Udy%xtFf)T8;O>joq`hr`L*-P!s5&ZTTzT>BHag+(SBjsA0LYU)@+L!+5I zJVWju$2G-1nortVk~btAy8rf%iL|(u)ZoN~_oLy6zdZ8RYQG!^zQENG2GB_GatI^!cCFNg{;@w0OUi7laTm8iny;~b8gnkO_oJlvj2r9Q)q)+4a$s_0JI=4|6#NpB)vmfiPx zVQM9jmfTm{1M^NU?g|SD;VMI+VUFqy0^rbaTI8!|c<`wFm2qL@XW@|0(8|*CLNQ_e znpiD`p#Q12eQQ;e3?ppqeV;FRQ(kT*SEo8g(M5#7p_BDEYi5*)k|@>(SXF;YNp z)(I{oQm@QY$clU@e(X?^4Jxj*45NT=D(@sf`R?vYrsG}OEm3RA^71jvrluc@dwY9l zv9KIsJMs*qWjsa12)Waak>m@+(jZ7gz*zL&vhhKHa-^xL>En};AEAdIQ>4Y!C+ChM z{!TpC(&(9F7Z51GWpDBb5(PUOTrOTs;|G71J$AOWUi~euM!DMkN*O3=93+t0191J6x*|iK~FVA>pY-4TJ{zP2wHz*>UuoH(TmpS1`ZDn zEXm-Rwp$a0@ZG?Gu&^*(K){!Xo<%!zS3SpUth1vo>6K8h%6{5bW18KLaFN6!gKpLB zfiDqz_rBIs8tbs$4PvNha%sv7X+_Uq@)Tz?NuS9Ik0AxfQ4}|3*5rd!Y`&uMrzfFp z#zF&Y1R}Arl%41Iho^fDdF2xwBvqipsP87DSmc{p`dJPNxg*KkIaz6-)!}m3?A465 zbmQ1xcN@Vyjrg!t()vZ2G1Jj|)3=T9RxDSS(v^D`K|OERjuiF#)0DbOiz9gyIccEH zTayx%G4*D7+kC7FX}&oK35@Riu#X8!$SHdDQr%B!OvcXu54Qg-X%)2u@q3}WJlk8N z!;sn-PCxfPDZ4eeJjpvt;jyUjA5Cg?77@MRbX{XYQw`B-ha(a=G_svQW9Sy%@W`nV zY|pC=oA$k$;B9h|LhE|kY){U$4k+r>)Z@TmrVQ#(t2KF>P|;Q1+X~k*&p!s424zRy Tt9H9|@`?b;9&JarxtZ`UA|`ej diff --git a/app/src/main/res/drawable-xhdpi/ic_document_small_dark.png b/app/src/main/res/drawable-xhdpi/ic_document_small_dark.png deleted file mode 100644 index 801b63381f74fad6110086e3f6373d98ff6096bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1327 zcmb`H{Xf$Q0LQ;J58K#nc^J82sBY(sJC1tY-78`&4|$kQGYpj`ZLV4#quD$^)F|hw z8=IZGV%jM}>4?jen5UYD*xX)2M zahKoj3J=lUbqN_|w+k%>k>CM{BxnTyXe)g1M~=toC~x-%pb1DZbz`n;ieqDFJ;vU5 zXBjjhI4>s2vk%QeLm#B*Sd|E7PKL|(cIN2!?C*hAYfB@BFd3CRyJ^ixtQgTav5J2*HPz~LDq!^7|K8nfwfuCcb%oGQ2a zGV#I(;p^IWj;NwfODffllbhS8hDN4eugihjA>%TcJ5IadM08sunM_uyRF(%%9cu;6 z%nH2j9k(0Y+1Xj>?e(8}^XAQ82IakSxMxo0fVxW}k#KBnZQX9dEob8S7ayUkC=n5O zDwQf69v;TA77TN;*`#q!ZeCtHvqTeq;M~`vU@*A1@?C$R7^^iht4Y`we zNSd+|x8@lQa%pI4TCWYm>f`TT1G*{#;I%dWVVKN%v2vvJ3M=ivt$4E@P$Ir=QoEf{ zHDpkxyD%Rs-Xv7I3y3u{S58m!r6VJ<33a7S5FrMO4qDwOUszeWVE<~j?uU%3l=gWP zqAIF_%cU`1kWM3C4#Yl~C#BIf7GEI@j)(JEkjnd1Dhm}G+-Wr10W^gZl z%hK|{p4^DEsGds^KIXc9-UYysGE}~Q2UR}?oo5k}QiS>5(tZyH(ipTHoB*C8;nx4w z*2&r^(28MrJas)exE4IP;&j(}=9H1nsvTccZaqn*dyHtH+2u^*>wP6A>Ax_o=J8C@ zJ5XFSR~_&x55>^Mv({~nD_H$fl}Odgku?wfmW3z9l+7yP`qxTwB< zONK(BD0OvpPfV;$iO^{lM60Ut!d?N$)p3o{Xk=uRn%Z4pc4DS_%Vxg54e}95+B~uK z_@j6F#&KOn=~cg)PrhAxOUo;f=62#qO*T>nt_t)VY`%YUSk>vtk1j-Xw2(GY=nACB z6hNbf+~tqiZ7p)w{Lg9iytDzs!o`L}zj%xRQa3BjiI1(so%}8sOIl?0!QjQvHt8H+ z0@DCPXU%ZsK$BOoqw#YTxN$I-W&v)E%+Y(f1_jB+4}0sM`GUNDE?lh`4H(ltU6k=q znT{j=V|;eAuD>OzDYVhZa&(|8j3q_%3SUyf3c&e`&HDS5myxgyg!k&S UPy1UjfA;|aAFlws$RnElAE}m${{R30 diff --git a/app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png deleted file mode 100644 index 80324f70a98df0a81dee7302ab595fa691af2ac2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0DR8JSjkP61PmmRqp6eL_8{%;BT zZtrkIRc*uBelA@pj>mg%a&X>v;`){E;<6-mc~PkA#j@$=cS?xQTD$7~2d2$`4qTr8 z@miI`JKhUX@&@}I&+(n9H__iv>JnbP0l+XkK DpddIS diff --git a/app/src/main/res/drawable-xhdpi/ic_image_dark.png b/app/src/main/res/drawable-xhdpi/ic_image_dark.png deleted file mode 100644 index e11a15faf24050066206479506c4c603af359758..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 374 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7`%AFxq;$IEGX(zP(}V#q20??Bj8T zh~*o%TygUi*Pr0rvGi7C{@mb*$X|?QW?^y%g(Fy2KWIM~Bs2A|c9Zk8o^?FNJg0tJ zAC#Y$7#Sy-?89N(xAXACuE*1yuYIx3`RK&g=espo)?M%XnpM2ZpOh>zyEFN6@2!Bz z!E4P@zO850Ilu%*S!I&bc_h5P6fR{t_wogE=*$Me*xIDN%`uFow|h7Wb50~rEd0c< z|D#d=nOlL(wJ{)eL-;H{lV+{bhWimo{h8BsRvg^l(!%Kf#iX@%pUs9P3cF&|xj1rc z&a*P)^N2i(cbe63;aKdthJ|%5Qce>rwluOUrLPI-XU(ZvKQzPj(mYH9WS5Ic@H|k`g)p QjiB)KboFyt=akR{0MNCc+yDRo diff --git a/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png deleted file mode 100644 index 2642b9e09ec00be308649f62d9323f22ae2b6c6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 304 zcmV-00nh%4P)0++mR7nD4pd~x$TQLGba6~{cu-M!3I>jYZG8L#~C$dSzGCa?>% zii{qBH_l~3H}Ow_R&mWIA6&_uY{DqeDkOK@%2_vI7ibl>$R-Q|t-=u5gh8NHSX1ob zZ91p^`0a>6o%RQ$Cqo6Qpk>-nPOXnuLHxL2AA)bf8YiVe5h%F;0000mdKI;Vst E0NwCNssI20 diff --git a/app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png deleted file mode 100644 index 378b8de05cf9616deec4caee9547c81ce9ab83bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 641 zcmV-{0)G98P)#w3=^8CvQCZi|TW!FC>&tftqRUnBDry6U z#abhbRq@7D2DHQ(B#I$fDIIkIAHY8bpaba z`~-P%8|nhu;$(0L!y&C>5yGfgHNsdC?=q+iSQKlLElcJn;{>gDGbH15*6N1m8IkbWN8ta!q$L?4ojtEp)QU_2|3ei)6`Zm@F5pn3$ND bm_PUp@AA#7$3Hoc00000NkvXXu0mjfXV)PF diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_24.png b/app/src/main/res/drawable-xhdpi/ic_plus_24.png deleted file mode 100644 index 1421a562e944d3a9cbe483544985f04440b34873..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v?36kO%$ z;uw*H$#5Cl>NYP zm`Q1oE6eY!mrb*O$OjZR%woPGv)~znDfRJJK8PFVEm@e~)3!%Pww@ zf427{4=ny3>2d61`A@-Fmn9#}J{M!0(lV*G#Z8Cd_nJ-X&0g8n^{xWz=R+8|_4`|| hkjH{St7RD;u*)zk3gf%b%LH^agQu&X%Q~loCICf4Ui$z5 diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_28.png b/app/src/main/res/drawable-xhdpi/ic_plus_28.png deleted file mode 100644 index 362b614b5fe41b34795a8e120726d6acd41f3a62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1020 zcmVPx&wn;=mRA>e5n#*ofF${)hrgJGpRgtGra3tphl#e(Nx!E3;y^aWTTb=QhT z5ePw)EHupG_0}oC;2q|536YE=LZK+lX{oS&b+zi~pR&HJ#yHVb8Q?9qk{PN!2cxgyueDs5R~ z$mu*jgX8&p9*%?SWR5c_J5$@IoB)Xy|M1RoxeRl;4mkCjnuo%qdE5uDWa&Po+V0yl z{`;W-HV(9|g95Pb%5`O2$zWG9Q8$i~!MZEgm2oA5UCBh&La_);fSpPC&7gzH!#56Z?tt*eay}&~+}`>StJ$svbak6_758 zuRtk)1OmsEIAx74$6@e46am~fyS>F?5$1Az&)VsOrp8qOVjI5-@H&uu83>b_fk>H) zD{*SKxV(|=CJ8{s0OVjcn}vGN^#dQgYmy3oTYzK=FnSf(_%aZ-jSUbHn858@_5GFt zl^QNCd1Sm!a?aR_WD7vY$H%V@4-cPXe|dTN`RwfM<9Ixt4~N4=zu&(Z30(#t+3-B-TH8-HX@PfuU#408TXPEOvL|8K@W zjhlXC{yKZ+b44kDtb_n!v@rf<+~jAX#|H&xkn4c^V$27kjh_i(CiXkD4xqLulE65t zaO$IE%}K(ZA984yujQ@U>$y(m00+>j0F?xRlH3X)aWDWd@qjAe@SzVF!O!?9pb>JN zZAl-@4q?tJ6#UsB^!V@KCzU~Ut{o(UEhIs&IR2e;J@mN^ABxsVtK7Qdt~e|JyON2z zag+?!UAeA|D;excChEpfGFW%zx-#yeGMKtXrP^9wm)rn#9lPe4LD9R;)lB{BYF*|8 zsJrayfrvL0Meq8?LRGd+Wln&^Mut{}GY>=x*7f8gk!;$MRZE~M^Rjpp$vhpd`$r%^ zDwAOgTU%?isa$G030qVd#1~r>z!2M(jKbJt`{3lvpv4_t0m!D9c0h@>5VW^Wxd9R| qb7&q(Weh1D+d^dp$_hNP75EKOjaD;BN&Dge0000^q;yebT8?t2+HZJ9ca+3d1lA z!!QiPFr=90d4r|lx0ETu!^=BVwK@UX!ix&f7+y?(*6<<%G>2ywpglaRfE>cJ3CJZp zi-4TMM;DM=_^1MM3?EHEuHmBy$T@tlfZW3)E@Ai+)cDJ1U-a4JoEy{$NYL{=)EVj% zgp5fAKzIy&gnCFcYEecDB6@KF6drqC!)Xo*IE0P}mudJPCX#Sw6`(ZyB`Bd&dmtjf zD|FIsjkmxxwePaIcmz;=V>ft& zYwlEShPWhL^e(oKE5sAL_Yu-2+is9?B%COCOV=n=3gD6eZSG?P7dsCSm>6{n&@Msj zL`bUy(IYYKw}Tj({!!8}48t%C!!TCLw*UhG4C~4cD99Zp00000NkvXXu0mjf+?nRC diff --git a/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png deleted file mode 100644 index 0f11be49564fa35f2bc1d42959ca9e8347023099..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 567 zcmV-70?7S|P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00FN_L_t(&-tF0~OG9B4$MMa9&Nm#0CgXxA27_WU4Y6u4 z_%8?sF&J-C#2_YFSy1d+#(WEc4zsXem_b1igGum(eGPY4_j2#p+`W4aa(*`-_;BBz z^PDF{NY)inkb)GXAO$H%K??G`ELk86B!MiD1hP3|Kn_O;=$9h`RLT(mD(4snRdNi2 zsyRkMZXAOkcaAZT7YClmu*o7rtps^f<rh zPBM)64Qhr1@WB?n{(+k1*vT{H56B-5z$a^jn+5sDv9nLRED)U2C*_ejxujddOI?~X zqGmt)>KvH-F}i2f#L`hKUre`lajoZ9+0&Z#qyFp$*`OVf=9Fu0d1;^A;BK+E_FBF_bB*{;vg2URh4sQx6(1zEAG^V z`C&txld{@T^W&H>OT7Km?#LKejdNG5eI_Dfq}NZjSUn?U=-EXYfTWzUa0kc0y`;f; zCs*!Ynz=)S0rD)%I_nrCCGGGfp~q7SQjmfaq@e!?`UXwqj!ba}_3Quu002ovPDHLk FV1fW`>>L09 diff --git a/app/src/main/res/drawable-xxhdpi/check.png b/app/src/main/res/drawable-xxhdpi/check.png deleted file mode 100644 index 636216910b186b4096c58ac69dd2c949dd0990d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 312 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy$o1AIbUf%JbOz&cwmOQ2HTk|4ie zhBIpW8+e!b_81pOZnpa9RS%Td?CIhdQgQ3;6>Gl320Sc*n$uFA7ytjfEb;&=^9F@u zej9_i9^CdjYx#Um@jLn3@f+^{Zm88w*e9kDZ<|=Z{r_tB@ca|^pYZ?w_V)Jn`cC`n z4YhX@_N_CBFK>|7W3;~$_UDh(hr_lH)Sb@uvprDvI_N)l*L?5Ki?28T)P8>H_AK5A zk3XlF9<;X}(brF@t5y8?np^tueD03*tQW2`y~t+#x>Z3wdIG=RiRWT{H|G?evwWWS aj=L^^)pTOT4I!ZO89ZJ6T-G@yGywpo!)o`5~2@I@p+QV)PqZGLySoMoDik01#GP?XjhGW_`A{U+lJnV<+VK@ntvBFF?q zkc=Ru)JZATDW&v6>FYQ-4M?Fs=A{JyE`3wXa!Jy6>LF_b0A~Sm?sBHu{BH;XfCDio zZaGkGbBUNGt(2+&;M4g+2EVk{jYQ2No3v7@1b`3c3pv!-CjVH*YLHT@0DwIJ?C??L z6#B-y^F$su00?POjeom-^0d~iQ-Z>XeWb$5rT3E5(vPX4#yonH7CwQtBEgf;c`zVH z!gkrJcx@`@!4qkYEu<#75B}{rR0Yo<&YnaO^oSesSgCD3jAI^;@;}63CylG8{x!^n zpN0uwY0ojyOtsinNv(UUPbHRZiFzdD=v`s}aFSMGYMoEG+oiC{P9cJ%j`ns5F&?6% zH0lx}NMd$+(s$HA7m~CSn$S8WL8NVgZ56n;)ObI#f7{`O_ZT8rz-CKE(8)8PV&=^E zzbQO*a_*f;2qH~)bf8h%GFCdDgN$*x1qoWx`MWg20|-q(LzJB=ZHOR_&O5eEDyKc# zOjOxPB1sU{X-VVrNVK9i8AXFM9gAJk0iDCQRP-6h*EP7lVPlMMEkRs_plzWxR?=cLBG4yGduk#8Mz^g(2^&K5{Hul?^**SOBT7Tz&3u@3%1DyEPQW1=| z1w+r0VVXtBQ-eNACs73nN)d^uDT8tZF^5EPHX}mvVy!h!f+ENSg#ZA50a8rDa3sg!4FCWD07*qoM6N<$f}05AE&u=k diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png b/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png deleted file mode 100644 index 9e669e93174574f2d6aefa2cab5a4fb917f7ce18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4550 zcmV;%5jpOOP)Px`fJsC_RCodHoqLcK#TCYRvj{Fk2q+j3h42W9#fGOKMnD#*pdn%@M1)WpNh+EG zMUjUB0YNJe3dLA}U?P+#Vrfu8`A1EmAYh0EVqp+sunNLk9=pr#lJ9q!v-Hl+y?giW zeeC0&sI9K|Qt+Wdk@k005 zjr+2t?l7Dqsj$dw@rSiMW88F$`|Si|1KPquFOwbnLw^}bJr3tcvW%d9v%%sP0(?;1 z&F|Xj0!thP{Ghm{alISoIH_XJB(qYh)4;V5!2Md=OCyh+v;2gv7?6YjZ{QOaIB9my z&b!wsTTu@po^bIyk;n=SIO-s7E!;pRG_B*rEzJn9>-@GGkS@Rg?`pp9*vK)`2oWpm z;!y(s`khEbRYAZ5q5%uoey!`J5n4VAT>{L01I&PQK_`A*3iuWhch&M6c}XJz#}g$6 zjuBuj7$6OB05+|sILdJ-?AEPY<8kB0b#B+LU7MCITmGn7vu3RtHf-3mPMtap>ej7` zW+-os^XJd!NY9~X&z?Pca zXptBxMG5hU7)bFb%Pqq|x6oQV-~zsZO<3QwapT4f=FXklqi4^aS=U^1&5cc)HoaEo zxHe|FaN$DXv17;f?ccxupIf$U+5W^6Pi#GT^5i)cnCmEHm|9OUBHZ2;cM{@FfR*P5 z9?-328{o9wkWQUC_4+&SymN4;PM!MKuV25h(i0}Rxw$95{PN2W*REZ=an77MU#OxS zM~xKg9gyKC0^8z7gaJmO+a;(MMP-89KKySb>vV8gSfy0Ik8>N33*+qXZi)k9y!tE9-ce)uToSd!v1zJr0k2Y^#l5-kA+J|W=SehzoG7;^cipME-_ zbLY<4idSjDM|_o&mrL;-4490;1Z-TabQoEpC4f>WnEZb58)>C*yEf47Ayah_e8p$^ z+DPgr0gS*ZDuJ=m#25_(U+erXlO|1?RjXDlh^aQxv17-q!-o%V*|u%laXHQQwQW-n zk9xB%8m5d<$fBz;`NDjr#>D2@YhT)=Ns~4@u6AQ z+ETva;K740G;iL#bI^s=ElqR5?pCc@P0-fGG5KyoQI6s~>a?LKkc84&FC>u1Rz7sG z2JGjj!50#qfCZT7(1syfmf8qnSxbcBdO*0=-$A|;c;Fn|>Ouz<=q+n%t$A)9ciq(L0(t?DH?0@294)aVEv*M|zG(dC?S(Y>LL`iU4H)S(b7fTX zKo9}eWjMzQ2m(Uu|GVzGYp~)~QZ#nwY9jr+Uhd){e9Xe@HM$rAzzpob5G)N>MK4JZ z0h}eXOF%u1aSb|j=+OVtrBx>e_>VpI*y7QnN3VbM(MO->I9Bg?w+XCtz4ZW>fD*-W z(k}py+R($6Yoi4V7Tnysckf~T%$5BZuxHGe@vP)u@+Dty&mB8;>ePV&0|sQ(mXy^$ ztr9abGc)(R`|i63bglbdk?j!`yB{d62S7q-2xTtL2}YIdkSz`Iapm;3<>m9Zx;=)Lc!(xlZ<2q6J{4@@TYR2$o<9wnns~7svWg z#Q9@P5pqH8v17-w=fiQO7q8|2@yREjWY}ShW^4wOI(joZYudDFlQfPqftE*Vd6BV8T#J=&tQ1T1GTVi< zb$iVVs^u1La=6(k&pJC6BO-ync=6)C0)I+8!N*1qGd}^Pnh)sQ#j)?xQp(uhPmkWp z{cfkg&w2RahZm^42h`P{FE20G1OL}5Z?yEDDO0A*PBaOTz+bXt z$vnLYoe=QB0nETo3Wi__reF)kVZ~2C7v=1)pY@yy2W~X!8g|}RIS)ty|CiF+rFTr9 zK7B@_NQeObkQZNk@tH(|4<=wHAKAJA!!i@VJCBU}Ap&G^ zA0lPlJxuHFX@C{(+E~&9!2g-*8bH0vmMwdh-vGo?ZdtAYGu3AVfF+nl-2>uE0EP>c zT;2T4lM#1Fv!wtskT^F*@ggA-_%FTm(gFdKlpD(O5@2&cT!tWPIRYn7>+QJ$olQN! zr)7pF#CVVp0sNsWSFT)`Eb#HSyhD%?5SK~V2q4s|BTt)Mkiakm__ugrxq3n*@Lzf5 zl|@wT+L7pZtdnppfsv49sOvCGKUN{CD=sAXz-MTqXVJrku3EKfak9b3SAOE8`pdiw zg#d2FGo@EaJ4$cTJ3!lA0OJwYaBzO<(xuO+d?O*I;V2ULufF=~^RhLmE#I&KGqCe8 z1WPalTQCkQM!@CD!H*AqEbX;(=gzmS;CO~2AzyD2usVVSYq_^79@TIZ34HD6zaU!+ z<7sbM$^tX6lY${wf@ui?g4kfA2xXTLE#Si!3ga$%t+BQ~#_@-jUw)Y-sof<~cOJWy z+MS%uHbPLvq;uZe)jY2}*jig!?~OO!7_MitzfruVG$rC;#&ZV4(QqwhkDmZCAzVb9 z0t)u--TNPf2@&2c)jOuY*H}$2fCNAck)TI~+vm-jH`7l7$E0&!d2LHM1ioID{l$n8 zBOXwC%D`9MH9X8h_~{UTz!WizFCO{+Vl<94kn#|rwKQ1|WD3@-S@XQ=8!ELW>Me@j zs-^2hh8uh8$5XM7=6d*SE`9dfHt)FrcImft!UuO zwiAIewrEc%SyUFCK)tn&^_u2V{|c#f{==o`}F48 z-7yuI;EVVanySc^+7RRt0ItO(Xb2($#1xIQiqrCNK4q_`Yy97_dGqEk`t<2@y(Z&s zvZcaFEU%}BbHe5UTMcn5-A>wFcdfIcS_)LdOke{>9#-!51NT;ejXf~Y%WNdLlX3bd zN&+WH;{6k)pm~5vB}aFnXZgTgkP+264NSlWjHE#maEb9Byu$SWI~UXoGSfd(1)fWi zsGq4CV=SR;Im+x#FE}%Q{`@~{m?ynj3mAX}m}%pMH*#78I&ii7<1MOLiYp8&5gzxZTl|a zgB4+~uHq-~T^PIo7E&;=^}ntCLBKF1UP4$Nl~s(9C7*2|oE@dIWEscuurQGph0LJKL zu+t~BQ8rB*XB%`}r39bk+w`2A9QzWAJL!T2m?TAPC}KWfv+gn}Yu~G+*Gaod`&Okn z0bk@3!w$aTBfjD@zJmc+lxgTu{&I<*1ZIB>x>AaP%n9j_cMF6>ik}2p1FyBWkTN;3 zI;zy4ujkYI_~7}1Pc$%mOj*lU-NnRD0=M8ixM5|`TFQMwW&8&YZ?>%j|G+`mhQuX- zn-EA~S;pF!8#7 z)KjT*XU?2Crk7*?8>fHf>252;ZEIQn6^VgVl#qak5kS$zA;E~S$=V1pQtTKa$m1&4 zTA60Q0Y%7`01rf5wU?wUWb43H%geQ>fo)PhV$>cw_3k}?$WB)nJ klMd4eq!CCXP+bxDKhk=9IyOh$+yDRo07*qoM6N<$g82%y?EnA( diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png deleted file mode 100644 index f6f21a9cdb7ef88fca24b080ecde0d08e432ee8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3059 zcmeHJ_ct317f$SzpfuF1s_Ki_BWS~`Mm1J7RYb)owYQo0e=j?g6*1fEzu&4ET7F z4Fc~vUxg|o~QJB>B)o0m8o&O{kgiN-;}}|pHWRgi`A8!gw4^~7u>YU{=DmqiQjum zmX58F2i_MVFQrrD3V*%4Bf74s4c?%Fv*~A35Y1*N8&NW2-hS#K|I~U&PWz0`C&4M@hf741%DhA>E-!l)E8V^ zQ(Qbxy;EiZ%7MHCI+Wdo=j91}r>G$eJgn<=I)aMbOmIxA)S zHW$WZFeW^!<%3}*bHz8H)9!e30?bVmsQ#yhTy(=b!U=$rb0PJZOv#-x93Zcf(Ow=< zZk`t*FPE%XN4i+xD0{Tst^Z0l!<(HJsU5z%{Mg2wot)Jf|THS*CGZFcNY=C{p!c;JYyz^^YMd{5uH{1M)1QC zO-;>_!#}@+qYXap7O_|f*&Zc}X{#!H=J=e!JL|$3qmUy>Co`wVgg@)DMKC{Bz2+JB z_eGfJcHrRl6if ziZMx%I9@#q8oMI8Eh*Oix6<7EQnp0#Z#Lv@=5!pK(Xf$Tkh0iz;?Jg4F^oRSRM23Sl>M(;t|Idc3{^7u`%~KfJz6Lv&UESj11O{SF^2ge1Zl=d?H3~K}(Ab+Oou) z`k3RR{`c_BkgONNyc2`%98cow%&{Bz8CNP=N1=HdxnVEJkYXw3lwe%kE%uWbDxGK+j@1V1KWTkITjt}q+B~}}R&#!fe zfcRV8nD-5ms?e>)y|Y;o1JyJmX+3jQFB{LTlDc%hqPtc+DL1jeY~q=0Y~GD0qn8eQuF?MNeA3E$Y|yY<{uLVSxebdwV7b(QV*OeU1AL zYt|7$b;Y|^xc$CtXy@|svgAmmotiD`YwjSInVDJAKAkR^`pnfDeUnwa-Q_%K&KtFg zKzyJe%D1W=nu2U3GUsi35D}~e;_9!mVD~BNbmWSB$gRg^&yIce9(dw2N%YOhD3b3) zW31m~^Fi5e)pTpla-%J1;7J!k{k`pw@r0Lpq9bwGX$RxNzo?ESaNzCPj zwWEVIy{V?)rG8DHvF+mO>S}_xxOhd$=f&Rd-@mV|tgO^@lgTGoAz^l2i9es7#tR%E zS4GQV*X{Zh^Azkk%?-*!kPMh{1r`T)d|H`KgPwQ2O58$KG16Q*Elj`eaVaTN`YKc% zqN)Qy`!W`{ON);l$9uzcB#y6nu6J)VAmqBocqhDYm-hB&kh8ZtVr-MYj2=6q5|_AG z6y+R}_%75iW~HSrV~boHPGdqMPD>QWWH4juX!%0dkk}5(r~xbhsv13Mp};rbRK#Cs zE_rS<-52yY#-k~s&so=*Gv0=`&T@jnJCRw#*pKvtyHd#t^u_1)<~b^~#F#NMa#F}k zSu_ffQ0s7$F&p1Z_;u-`!<~KtBrJGXFK$n! z{&dXzyLTutYSj0r5Sm(aPLi32n;$F$nph8GI08EgB${&v$2RQD6 zT%p%m8I2=B8x@?nbzfRk#Pgo^p3|&AE0rAA?L_*?|K{A0)`Tm;Etgw96nPJ;kvuls zMe=3CpDo;3f^eeL9p{cD> diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_large_light.png b/app/src/main/res/drawable-xxhdpi/ic_document_large_light.png deleted file mode 100644 index 26c2172dbab39e20df37514f4b385c270aeb90b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3091 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≪hONJhQLgDhJpWyDWaRP7vP(mWfi!G>UNuP4cxo_S z89C{9h*SNyOd{aLv^(P6(2l= zQNvTz*wwBnMLRobO@SUqTx8^?PKLDg1qjVHY@XYEY`UB(;#O1CV(z0}xp^)w*Af)s zpV45xs8A+<3Wz) z8gtBjuTeDjnOl4C(Bu18e1CYqU%$QIKfd2dHyx}+g}_1p0EpV!SUR21;Y9ZY_)gpx zS$zEjpb#hPYd~2)WElVi%xo>qU5KC`@9gb7kY_sgbFRTk#g%$4mLx5|=e0vPWxLst zVZ&`H3OBOqv_m(z=8?7R`ga!j6O2~jIXZ9b%4O3Yii~EQsX4a*W_1{s4VWbP^-7!CcY5K29WKpM@ zwrcd)+Y5=fBNNjQ{$pZ( zdD-9PmzSdBEcJE}dJGhN7gvODG+K+(_3Ph7H%9HHA0Ho!^|svR@Oo78;<^tG4w^$k zLS#BSJDW3NBTvi0`zPJwb4;<(wJZecX_`aaHb6f%zX*8M%olX}c ze5>0^r1(3xg^hpS{)*IWaHUEoG;ziBE!HW|F5v` zKhG&QjE|3p=%CZPbz8>HrHQsZh>ndo*jTMibAbHFhZ-SbPjx_Js{4JyVqb6L=P_#fHf0=us;y%dniLS!iBe)-qCEHpbw#q~W zGfcf|z#eo}&*eLHxLmVNnBBPO=3H&wz@MP339}A7$yNj(F&I381Rs7xE+*k(059+sGGlA!i zYZ}k+)9Lu0Iz-faV@`LQrYRVa&gZGy`zT?eC_#|o z@cpr<_zn$Uq^n^d!;*g2tm|f6!T@j5mT?EygZj|yWv4z;alX3j#5)%75#yarOEc7V zLb@`ZWAyDy_nc)E1R7UtvKu&ZTAp52@f1 zVL$71<`2xF$;#7S#TTDq!{fB!u}XrD=V*~!7$RUTkz*XY!_%<|yikUR0gHm6yz6t&Aw`2&@w|KK)sydxsUwrTx;2KoW%5fVjH837_{lQ-D!t>%s> z$z5bsgE{Ar%=@ae8|=;eRTK;cGt)CFS8kl2pHF1C^H5E)g(3a~MsMjMXCr3bBfpDp zeX^dT5-e@Lj`gXSOm3ce7Ze>qVg3b@0zU(o!;p#p0&@{1Qu!E9Pw!7$;ozM0vty}7wLl`cu> z!)K}25OF$^s*r>6;(KFZwzM9_ceRA?k2xnT8(q!lNTfVC;jv>gNt8A^hyy)9g#_OgA0$LZL8FaP0BvmiaEn%}VoG2+Z)U+`gd5 zWvqpTu@#W^Si3+v4%2C&#=o68D;KbCeupTKDRmt?N?jN)qw56p<8IFO zezM0eq{q^M=}hy9jY=cy)R8YXSyy-8ej4!2XkWFgF6F+Pjk))oe96g+K10Pfjb;kc z)^y!g;o8Pc>fzEC7GAco&aAT``hzz?C%*!)wQ{g5yLN~4FKcomtpET3 diff --git a/app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png deleted file mode 100644 index acdb6d0b90461fae61ee213450f04e62b4a1cdb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhaw=6Jd|hGg7(d(E5A!9c_zPrS%TmEJ{NOhDBP3@;{nu|*z9T{5}Ie$~CQ-PJLfj%(`^ zGK*MdI$W5dtf7&9Aw&ORPN3tx&6k>*4!1OIxxeW@>q_5gQ4_8`e#752Q=!RD=F64n zvOflW`5e3UmSnK2>aV-ycyhn|FZ+zYPh)50X3bRO09!R@?bI*%@y1gwJAt^Ku6{1- HoD!MDq zCwNDzb?wsD)l0sx2K^M_*8D6`$)MO(&k`81<7h22_ul>lRdZ>N#AN9W7mg~dtI5rA zvXE9Qe0FZa{qHshJ~hrPemJSnOLOUzpye{ZpE3GJZcr=BUN<9Hj$Puzq`uc{9$T98 zv-14#;Hph1ka)nm^2;xQ1J3^@c*%bM%c$?MGW}nJs%Gg^{vVEBwcpuQ!k#fOvIsa} zBN~eJir8u%iLv^wYcMS@wD7tm!zi14XaC9~mNVP-1w3b~IN!TgU&JJn;auhMfBTBk zpGzy788UtgvwS@1U!BpRjn<(t4SPe|*1X7WGG5lly-=oP@+G~T8@6{PXUT86w{jixhgQYB^thYtYsz2kV5nX_r!Z6AsDHMA3{btI_Uj3i zcUlU7!Z~l1e^0m>e3Yr?nc2E>xyv8B-7U`VrVZTT`Eh({xw)? zepUHD|Nis;43AjmFG%^$aiXs6>ITI!=~@5MBftUwO26>q8m|@f&vEk zXhJXy z)&q8Jdy{=h?m>-(r*@dK*d3epCi{|HgU+qz4*BYtIwp6bB0wo2#+nm8I>3B8iEGdk zTNzJQxB%cRn_*v)JJ1{dbN|paSFGhz>`QV3I>zMAKdKnfM6N&`Di|k|EDy3@h4PVK zk{o2e3gsieq-8-3jN^&)U)Wy&nw9O-LW-|?GEfU&#WGOH2p`=L$UkuoJ#eZRDaa7g sq|daVQ|=KHU?d>_qaXz-NI@l=ck_!~>**H-(*OVf07*qoM6N<$f*#evGXMYp diff --git a/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index f3e153b45eb7886c314afd8642bc9018c1f2b5bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 283 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhaw9(lSrhGg7(d+8wOAqSC`hqkR( z1O(pr7kRR-^fM4B-pJrB!18rLU;5WcwbqN9K8JrW^zk~kQ@@Q%*UUm`d)(nn1I4KI z5$WBNZm6sYkec37%CT|!!~%}SX+}cp1pU2?gz^k0^oX!`dZ;RJuH;csnA-GWNs++J zBMgC_G6EBiFkSSp5$HU^66g_g!CF(k6j7})P zi3R_eVwcPdw#%5()K(Lq&bsyI0x4_Bbf>AG4m-Kby09c-#l^%UGs{zXGMryqJkPLY Y&6Z+Q#YU>7!%OMjhljrJ69USmEyue8VFxO z@OA@%#EoF$!j*W9&_4+lS>l zC-{|@bO`kEBc0+Wp6|TC7;Btzg>J4mWtCAifdt>+VG>|-K(IMD>Y#Z)$; z2#n!jDlW-zfi+BKC0yWVOy(q9ppMD>6)s>nR=^6FFM!Jd3#3TWPY*rxlO)9=hj``- z{Kg}OTNa2TJm$6aviVQ^VC0=0LEP)$7Rc?qfG)xiroA=u4=_a{0 zLEuIXU*UV7hJJzI$Y5qR286bO-+Kp>Ba&FB(1!x7m$QTR%v68x@5;E|j>G>Jk}P9(n2 zA>c70mTrZpl*Mu$#*JU#K;#xtAurNL@e3^YQa#jAp(^%;Li_?B`cm(r%D68-9lt=* zms&=Zf;di#TR;@g+i@fHVLS68Q5w4kR^R1n}!(Bmx zhswjxYCm2V>@XC0@Gr7!;uTYs84{s`f0isYb1(UxGAn8%f6enHPtCt(^*ExyG-C|l z;4r}JOtZu>p1tU{0{=f&ppMC0-h(G+tC-B!;R2($ckbDx_%<<>^=L159riGh?{QiS zNQDhtOu}UyNA3B2k`+#to(cKTPzlf#bU8oEEdZ@E6Au4UI@!r00000NkvXX Hu0mjfL28c_ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus_24.png b/app/src/main/res/drawable-xxhdpi/ic_plus_24.png deleted file mode 100644 index d63c48c7145d591dab4fc313375b0df64da993ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ<{->y95KI&fq{|F z)5S5QV$R!J2YH(u1RO3(COb}PP>t-=jbxJT4b0bhY~Oh7&-8+usx37MmJVT|E6TId zza%LnfZ+$>Mx)|$XQFaHZ+I+T!OqA`Dr_t^ul=z6ex^j{45MO+JKmvpxAWcom-y|E zpB>M?jjL|$k+MrV;BdfULF^LcM@Btwp6~RyALhQ_?MTrB%bYCc($&A@W^KP(^u)5H r=14;$snFqf{e*janqk`PIy>0^d{X4{+hKPY7+egVu6{1-oD!MPx*QAtEWRCodHnq5xhMi9q=0fy|JBM*^y;AwAza+BO8;t;t*oUIY!9{%4qTjo6L11fzzH~kk4fOL!fh!gm3<+;gaMU( z^Y+?*yw}x+7XzX|s;;)I0hmHe+nT%gvL>K0z#}6;Tcw~1=Ch4ar<7CywML*oTtrG6 zMF5CC1+`)W)qz4Ih%!)F3DA?E9P}9I+S)5qL>Wto?d(u%%1{6SMKG6EM^8ha6-{P7 zS}DK)j8A*!%qj|O)*!;-%1&Y;JD1nj*FV3!yxf`o8T5`m#=;!tqS#g;wE=Bq=>7dY zU|SOU1e9#X#(}mdy#W!>=Cpr!c-R6O`sinlfa0zSsI_INPT>aVMLO5Y0mVogRdzO?$ME$A9~|(GoY%VCJi2ZmI2bF~ zbBd#6A$op({!?mC-__OC!O6+V3OznPenJlq4?8!Q+V{1wM;D95^Xcj7dbwP#&d$zO z7Z(@5nvJqwmx123M7wJpjtn4`HC)?1+s5y@CV$jEgZ!iI?H|*xrerKQUQJ&`95m8q_O{80yZ?p0R29&724$(pt@M=?pJ8(|BV}+-lJ}MLpLR03Mki( z$D5m*U-o%ZvYWz}7`=TT`upwe?H~KPDY0kM_*X>l4T(6%099u4Q{pJx_4ujs&3`s= zH&1~A^YZfYH(USTpt@0v${@4p6D1BA8UJj;KW$~|yMfA(5@!+u=ZJAIbip3HSj)Jl z)*-Hx0z#e zfs4`?$7ju*B=wcHJDraKD#O%a+Sa`Hlw3OS7weFpc&_T4J+q3gfwKnjkrsELg^#8K zb)ba_6n5ts>OczDp$@b#fx_-;%upW1 zA0R7Efmpyr7@A8w_Ag+4v8uL>ry z)|8>Z0U!#n6<@(w*^&e$6eDI(zrK!`8kdbjM)+)eG zdFyGhe{+d5l*PD40!(KTZM~C^L0WemQVUQ(lA)BkDM6?UsK&`w>ewJEUt2T%tp#Wd z1W1U$>2g3H&;&g$tAI&>1Y48D@KjG@6jVV}yNZ3J!PXFzVp7=`Qw+3kj`uqOC*TB} hfD>>6PM{Eh{{Y>tJ-8Yp3W5Lt002ovPDHLkV1i;$BDDYj diff --git a/app/src/main/res/drawable-xxhdpi/ic_reply.png b/app/src/main/res/drawable-xxhdpi/ic_reply.png deleted file mode 100644 index 119006014d5e9c4b06670e88ed1f18633475172a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 775 zcmV+i1Ni)jP)X1-Jn> zKm+Cu+))F$hujHwTtHGlQb1Aw1)u_O0WQFN->?}nVF-$|(!Sd7d1iKW@Ql5BpZ$$i zX&L|k0000000000004l891e%qUPz)`_?K4JwcJy;r10(X&C05#M%58M0>XwbFQE4D zr3FL?Usgbr@FfLA3SUk@wD6?_L=0a>K-BOh1Vj$MxPa*47ZpJG1qBd(K>>tcP=JN+ zp5m!5(KnCTE5E|tn94( zNP@msDPUp4&aIU@zn1DTZQ$@9b7SRt?w-*S{$IF&a})7%F#;-G?QBiLR$f5mOHH4+ zPp9k+E#Z$^LG>nKkP;AVleJGhA^QZ~H82KmWM5LFV+bnT6F%mg6PSdLn3zq#2bm!# zO-_Ak9TD)qLw#Z+R-Sh`hN5hfaKN5$CbtR-CSjf9UA64X&|nfaGIPMpb3*^rrYIeK z@!x-wu5YcJD9#1#l+9y83H@_-rB)Z0@ZN`x%yqf2x?D-_gq4OPtBXH$@w!}OuFs2x zp>Bp7mzon&Yt31o)l1&o?`FE`dkH%c3Ar1^T3Wz^oAxdIASB^yrms7Z7m#jxpIV-Z zgzM(_BgkX%9(^u52`@o{aZGsowEEd3iOpgJs7dhE(KF^IulF{s{jYNTepm3um3qRM zVdy3Wzs-4us4Lw1R3zN~=Q9uf^Av}-I)NcTLBjBlUg10LNl!&cJQFT0;ZeZ56nsC& zDLI*k@&Y~Keo48R^LS$0h%xddzFy^Yb2U9cc%KkUKr{)m2eYC`kUkj~L4y3z#M&h+ z;%sQNiCLB!|8?O>_%)r1c48n{Iv*t(u zRV?##aSW-r_4c-PhDe~uv5y}GLz0AQ9xOB1#Nw-*lqe`7vL#Ymdg2$mgQ6lX9)j7y ztCn~vYcx$2(_EvZF|A38wOJyvM!_+#S%IyyzPs@5*|hSsvd!hs<{kdIZ2yPm$~)EP zW;QrH7nL}Zz-Bz-AWxb>vm^?Wv2yMP8`WMQzQE2M`6?lGno&gSQm(MGK`hZ{f|ysQ zYBuVou57rPtl4liy|djkiL>1_t+UN_Bg^4Mkx7Y#Itw>cO!J5k5)-j>@e|UKTs_C% zr(kCDny<3gWL8g#_bHml7@xRylUC(Pj=%d3PCL)_x!tqv#Rt}FdSRCDce<2++fcUo zP)%yfI-SJ4h{QC@!>e)<^db}I8PBjgW6Pb;_?UG4Uy8Re&M2mbo@DMR|~`s=m1X)mV!Z(KJ&eeFAy+M7Y9 z=UB9t<~2Poa(%Q>_2$Me9c!A?jF)d)Yvjdz_Bo?=jsCMOvlc~1lx>mE`Mf&e*1kKR zmmax$R;}t~a)TomPhnqR-scJZx|g~3nX6XaIPH0CX3|2<8{JP_=dZc#5i9dd>vCu2 z@ujB!eo3ZjF|L`irS47m>N54~GrAYfopviG{(NMbQbPVD+w{6V=QYn*@})2Jos>&> z`GD#E>vg|9{+$TylP=BNZOYRr`eQ@NQ$yM0TIn6Zn?G&;(YI}iu$J&-*Qv$hdq)x7C3Yt*m#Fru#*x|1LSi hH1|A~G;;d2{)!r=)PGEw(}2mC!PC{xWt~$(696s-XAJ-V diff --git a/app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png deleted file mode 100644 index 0f54e607a3a771c22fbc0e0676bee061d4f02e06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1393 zcmV-%1&;cOP)>#8hh^~!0QVt; zm4$K>0ub>Q0M->4e}_K+Fb*MX*ptjC-UpEJYpD7A_!#pKS-jjCXy|gZKcGP@n>$(qh%)9bT9#K{QM-SqaZ{aQ7>Q9aPb`R zN^^|0l=rTAK;y45?YrP*ZA7F2{>FMAceARf`rE0@0>oAApFP3O_%;s83pv zA4$S)RvzU}cy5W;5CV5~RQ~yChK{Etq4`TA{I8X;&k)NvW!f^6vGpyY&qy-rkwo0m ze_Br_S!I$kyAWP1B}ol0l`I@2Df{BAw=_fq)pHFfO?w z6?H|OQ8#;77}f-Q9}*rC7fL8*sOgCtTG4N2Ll##^_c!*SB!ur|DVKA>JY^Kq z3~>z?^m#3rZkAAnfKKC6XPI*N*z|So05BKcyGQ~W$pEckaE{?(uaCaKoWY!cl~nLO z-4DV$spUb^y_bj8B@-}~3SFr7?Tgw9$&DesQ;dMM@L~#KlaRtSzb$G@$T(SBGhHl! zzm!5YS|@1i`VjjB8U$o5Eh3D6eLwQzPzXnDFw z0&3hJpyf#`LlXE-d1F{@QF0J+6TpUbj{lLH09Mm#A%Nh?DVSP`JjkS(F*Nh?DVxHIk_F47sdI}xRwiA@b*fqQsVjL-6( z9Ks?5TSV{$ne$!|3%(?2Pk=AV8xzn~P!CHfKtRx1LAb8KEK>r4hb9gqAXjK&@Zj8G zp@|-zo=>XTpfQ&W(CP$zgGOe#`YlS<1oXCqR*RUCLA$=uEBeiB=ziluHNRQ{LSLVd zQw(O}i~z|Qr@3WwVM^Oei^U2>T~VjOnbpj;5&;&7F*(nOxX2kbLHmv4BH7v*orNH4VO{b-+je3$MCc%lES?g+N`EJa@%wZN462jTVgl(=AsHq{8w&$)CQ z3)!aHOw&(F&pQL&iP#5#A6=Xh#~ocvFPLBRbOD%a#(#s> diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png deleted file mode 100644 index 8241bd663c81104f5d9e001ee3f27682aae7e1ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7187 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 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png deleted file mode 100644 index 911a1c2f49e305567eea4d8908176d05cae9d08b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4576 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 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png b/app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png deleted file mode 100644 index fdcf64d6bbd2bf74f4b79fe1e55aacf8bb65aca6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4559 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 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png deleted file mode 100644 index 182067089d55368dad4db8487690cfd46a8a1689..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3022 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 IVD1V30qwi71poj5 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png deleted file mode 100644 index f7a70d8471f91729ce47378114acbdce350d86b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcgmV3H5hE&{od&80Ikb!{1#W=Us z_hK(p8Mxm1xWZYb~XVqj#_;@R_} z;f}IG!nFn`{#jLwt@EaQW%{+o{qL%q4ePpddHne R&g%tEa5IfQowI9-hvuwR$tP^fy(>8sSO2|8{wRtGpvc@#B$n~B~Rwm&yIIB)&jy;7gW zTKL?7mzK@v|L<(N|7b3=;GHmT*|XKxR_ zisKEuaTo73N#D@a7GnOhx@e+huI&M-u%#?A#q8EMMfY92V!WoXj#HDtTEZ^x>C~Zf8IWQby>8Ib0dT5wN0yQ*dG`x>m;y#Wc*hW`8x0OY*zD)2SPUjd28<` z%OxHB?A^isVY&9DKSD{2WgAXtCo%nb*|kec?7;kW6+k(LjH+Fa;+@JJK3|^tK+MxXFXli2*NgVS zce*$hNuLo@ada)wwkl-hV=5FsC)E4*jDt)D|B?8wr!IWgTTscqYIn4V0dUAW1Y}9z7KOhqi^sPPIw7!dLw*NrLf;=eP`^JY9k?(#X=nCd zcgzlKvHty0=g_lyrE}%$KcD@NJa^2A$V!HX=v7zCg9d&s2ko((e7)78&qol`;+ E02^zJCIA2c diff --git a/app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png deleted file mode 100644 index 23a9c2efd07537d261952851094d5dbc47b2f753..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1504 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 diff --git a/app/src/main/res/drawable/ic_arrow_down_to_line.xml b/app/src/main/res/drawable/ic_arrow_down_to_line.xml new file mode 100644 index 0000000000..16a951e483 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down_to_line.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml index bbf9960747..45317f4ab7 100644 --- a/app/src/main/res/drawable/ic_arrow_up.xml +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -1,15 +1,7 @@ - - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml deleted file mode 100644 index eb232541d8..0000000000 --- a/app/src/main/res/drawable/ic_baseline_add_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml b/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml deleted file mode 100644 index b985a9d03c..0000000000 --- a/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_delete_24.xml b/app/src/main/res/drawable/ic_baseline_delete_24.xml deleted file mode 100644 index 3c4030b03e..0000000000 --- a/app/src/main/res/drawable/ic_baseline_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml b/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml deleted file mode 100644 index 13186deefc..0000000000 --- a/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_baseline_photo_library_24.xml b/app/src/main/res/drawable/ic_baseline_photo_library_24.xml deleted file mode 100644 index ee0f6931c5..0000000000 --- a/app/src/main/res/drawable/ic_baseline_photo_library_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_reply_24.xml b/app/src/main/res/drawable/ic_baseline_reply_24.xml deleted file mode 100644 index f49d97d888..0000000000 --- a/app/src/main/res/drawable/ic_baseline_reply_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_save_24.xml b/app/src/main/res/drawable/ic_baseline_save_24.xml deleted file mode 100644 index 1a8d86d20c..0000000000 --- a/app/src/main/res/drawable/ic_baseline_save_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..c799210277 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..72b8d31748 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_circle_check.xml b/app/src/main/res/drawable/ic_circle_check.xml index e88b286a9d..93ec1f4362 100644 --- a/app/src/main/res/drawable/ic_circle_check.xml +++ b/app/src/main/res/drawable/ic_circle_check.xml @@ -1,15 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_clear_messages.xml b/app/src/main/res/drawable/ic_clear_messages.xml deleted file mode 100644 index e79703910d..0000000000 --- a/app/src/main/res/drawable/ic_clear_messages.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml index 4c6eec2c14..922366928e 100644 --- a/app/src/main/res/drawable/ic_copy.xml +++ b/app/src/main/res/drawable/ic_copy.xml @@ -1,13 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 1a3e8b4001..0000000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml deleted file mode 100644 index 48fa95783f..0000000000 --- a/app/src/main/res/drawable/ic_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml deleted file mode 100644 index 1e2de4e757..0000000000 --- a/app/src/main/res/drawable/ic_disappearing_messages.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_edit_group.xml b/app/src/main/res/drawable/ic_edit_group.xml deleted file mode 100644 index f647fea3ea..0000000000 --- a/app/src/main/res/drawable/ic_edit_group.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_external.xml b/app/src/main/res/drawable/ic_external.xml deleted file mode 100644 index fb4803977c..0000000000 --- a/app/src/main/res/drawable/ic_external.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_file.xml b/app/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000000..9c6e8099d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_file.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_filled_circle_check.xml b/app/src/main/res/drawable/ic_filled_circle_check.xml deleted file mode 100644 index 99589252b1..0000000000 --- a/app/src/main/res/drawable/ic_filled_circle_check.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_gif.xml b/app/src/main/res/drawable/ic_gif.xml new file mode 100644 index 0000000000..249b90dfb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000000..2fa41535ec --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_images.xml b/app/src/main/res/drawable/ic_images.xml index 4e8b9abe3e..0b977f9fd0 100644 --- a/app/src/main/res/drawable/ic_images.xml +++ b/app/src/main/res/drawable/ic_images.xml @@ -1,11 +1,11 @@ - + - + - + - + diff --git a/app/src/main/res/drawable/ic_leave_group.xml b/app/src/main/res/drawable/ic_leave_group.xml deleted file mode 100644 index a6a235aeb7..0000000000 --- a/app/src/main/res/drawable/ic_leave_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_log_out.xml b/app/src/main/res/drawable/ic_log_out.xml index 1ae65b31e1..ac40db0696 100644 --- a/app/src/main/res/drawable/ic_log_out.xml +++ b/app/src/main/res/drawable/ic_log_out.xml @@ -1,18 +1,9 @@ - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml index 67a21c3500..301f129f77 100644 --- a/app/src/main/res/drawable/ic_mail.xml +++ b/app/src/main/res/drawable/ic_mail.xml @@ -1,14 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_message_details__reply.xml b/app/src/main/res/drawable/ic_message_details__reply.xml deleted file mode 100644 index c9e1591a53..0000000000 --- a/app/src/main/res/drawable/ic_message_details__reply.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000000..786750a42a --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_microphone.xml b/app/src/main/res/drawable/ic_microphone.xml deleted file mode 100644 index ec9f8e76f2..0000000000 --- a/app/src/main/res/drawable/ic_microphone.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml deleted file mode 100644 index e3dea6f2a2..0000000000 --- a/app/src/main/res/drawable/ic_notification_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pictures.xml b/app/src/main/res/drawable/ic_pictures.xml deleted file mode 100644 index 967d0a65b0..0000000000 --- a/app/src/main/res/drawable/ic_pictures.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_pin_conversation.xml b/app/src/main/res/drawable/ic_pin_conversation.xml deleted file mode 100644 index b2ff304b35..0000000000 --- a/app/src/main/res/drawable/ic_pin_conversation.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml index a300f234cc..5450e0139c 100644 --- a/app/src/main/res/drawable/ic_plus.xml +++ b/app/src/main/res/drawable/ic_plus.xml @@ -1,13 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_question_mark.xml b/app/src/main/res/drawable/ic_question_mark.xml deleted file mode 100644 index d0c4088dcf..0000000000 --- a/app/src/main/res/drawable/ic_question_mark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000000..9db621c381 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_search_conversation.xml b/app/src/main/res/drawable/ic_search_conversation.xml deleted file mode 100644 index bd9eaad36c..0000000000 --- a/app/src/main/res/drawable/ic_search_conversation.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_square_arrow_up_right.xml b/app/src/main/res/drawable/ic_square_arrow_up_right.xml new file mode 100644 index 0000000000..5958525ade --- /dev/null +++ b/app/src/main/res/drawable/ic_square_arrow_up_right.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_trash_2.xml b/app/src/main/res/drawable/ic_trash_2.xml new file mode 100644 index 0000000000..098c393fb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml b/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml deleted file mode 100644 index b41f2cf6d8..0000000000 --- a/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_keyboard_selected_background_light.xml b/app/src/main/res/drawable/media_keyboard_selected_background_light.xml deleted file mode 100644 index e0afb0fc4a..0000000000 --- a/app/src/main/res/drawable/media_keyboard_selected_background_light.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/view_quote_attachment_preview_background.xml b/app/src/main/res/drawable/view_quote_attachment_preview_background.xml index 2044a98c1a..0fed3b8c5b 100644 --- a/app/src/main/res/drawable/view_quote_attachment_preview_background.xml +++ b/app/src/main/res/drawable/view_quote_attachment_preview_background.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 0e91855589..f83b8d29b7 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -105,11 +105,11 @@ android:id="@+id/attachmentOptionsContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/small_spacing" + android:layout_marginStart="12dp" android:elevation="8dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toTopOf="@+id/inputBar" - android:layout_marginBottom="16dp" + android:layout_marginBottom="8dp" android:orientation="vertical"> diff --git a/app/src/main/res/layout/document_view.xml b/app/src/main/res/layout/document_view.xml deleted file mode 100644 index 558750852b..0000000000 --- a/app/src/main/res/layout/document_view.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_keyboard_icon_dark.xml b/app/src/main/res/layout/emoji_keyboard_icon_dark.xml deleted file mode 100644 index cc906e4d06..0000000000 --- a/app/src/main/res/layout/emoji_keyboard_icon_dark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_keyboard_icon_dark_selected.xml b/app/src/main/res/layout/emoji_keyboard_icon_dark_selected.xml deleted file mode 100644 index 1abaf57d49..0000000000 --- a/app/src/main/res/layout/emoji_keyboard_icon_dark_selected.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_keyboard_icon_light.xml b/app/src/main/res/layout/emoji_keyboard_icon_light.xml deleted file mode 100644 index 7d7f2fe269..0000000000 --- a/app/src/main/res/layout/emoji_keyboard_icon_light.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_keyboard_icon_light_selected.xml b/app/src/main/res/layout/emoji_keyboard_icon_light_selected.xml deleted file mode 100644 index c833b4191d..0000000000 --- a/app/src/main/res/layout/emoji_keyboard_icon_light_selected.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index 379e655f49..d2ddeb3fdd 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -117,6 +117,6 @@ android:contentDescription="@string/AccessibilityId_delete" android:text="@string/delete" app:drawableTint="?attr/colorControlNormal" - tools:drawableStartCompat="@drawable/ic_baseline_delete_24" /> + tools:drawableStartCompat="@drawable/ic_trash_2" /> diff --git a/app/src/main/res/layout/image_editor_hud.xml b/app/src/main/res/layout/image_editor_hud.xml index 6a9538d603..18ca44aea1 100644 --- a/app/src/main/res/layout/image_editor_hud.xml +++ b/app/src/main/res/layout/image_editor_hud.xml @@ -29,10 +29,11 @@ android:id="@+id/scribble_delete_button" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center_vertical" android:background="?attr/selectableItemBackgroundBorderless" android:padding="8dp" app:tint="@color/white" - android:src="@drawable/ic_delete" /> + android:src="@drawable/ic_trash_2" /> + android:src="@drawable/ic_circle_check" /> diff --git a/app/src/main/res/layout/media_keyboard.xml b/app/src/main/res/layout/media_keyboard.xml deleted file mode 100644 index 20ef09afea..0000000000 --- a/app/src/main/res/layout/media_keyboard.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml b/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml deleted file mode 100644 index a80ee8cb6d..0000000000 --- a/app/src/main/res/layout/media_keyboard_bottom_tab_item.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/mediarail_button_item.xml b/app/src/main/res/layout/mediarail_button_item.xml index 1cdf3e6ff7..5db29aec9c 100644 --- a/app/src/main/res/layout/mediarail_button_item.xml +++ b/app/src/main/res/layout/mediarail_button_item.xml @@ -1,8 +1,8 @@ - @@ -10,7 +10,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:src="@drawable/ic_plus_28" + android:src="@drawable/ic_plus" + app:tint="@color/white" android:elevation="4dp" /> diff --git a/app/src/main/res/layout/mediasend_activity.xml b/app/src/main/res/layout/mediasend_activity.xml index f8e2143bf0..fcbbb435a1 100644 --- a/app/src/main/res/layout/mediasend_activity.xml +++ b/app/src/main/res/layout/mediasend_activity.xml @@ -62,7 +62,7 @@ android:layout_marginStart="32dp" android:layout_gravity="bottom|start" android:padding="12dp" - android:src="@drawable/ic_camera_filled_24" + android:src="@drawable/ic_camera" app:tint="?android:textColorPrimary" android:background="@drawable/media_camera_button_background" android:elevation="4dp" diff --git a/app/src/main/res/layout/mediasend_fragment.xml b/app/src/main/res/layout/mediasend_fragment.xml index 7224cabd19..ce2be65dae 100644 --- a/app/src/main/res/layout/mediasend_fragment.xml +++ b/app/src/main/res/layout/mediasend_fragment.xml @@ -33,22 +33,6 @@ android:orientation="vertical" android:background="@color/transparent_black_70"> - - - - @@ -119,7 +94,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitCenter" - android:padding="10dp" + android:padding="6dp" android:contentDescription="@string/send" android:src="@drawable/ic_arrow_up" android:background="@drawable/accent_dot"/> @@ -139,13 +114,6 @@ tools:visibility="visible" tools:text="160/160 (1)" /> - - diff --git a/app/src/main/res/layout/preference_external_link.xml b/app/src/main/res/layout/preference_external_link.xml index a067caca98..bb456a66ca 100644 --- a/app/src/main/res/layout/preference_external_link.xml +++ b/app/src/main/res/layout/preference_external_link.xml @@ -1,7 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/prompt_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + tools:context="org.thoughtcrime.securesms.PassphrasePromptActivity"> - diff --git a/app/src/main/res/layout/view_deleted_message.xml b/app/src/main/res/layout/view_deleted_message.xml index f52f7b7a1b..c45a4372af 100644 --- a/app/src/main/res/layout/view_deleted_message.xml +++ b/app/src/main/res/layout/view_deleted_message.xml @@ -13,8 +13,8 @@ android:id="@+id/deletedMessageViewIconImageView" android:layout_width="19dp" android:layout_height="19dp" - android:layout_marginStart="18dp" - android:src="@drawable/ic_delete" + android:layout_marginStart="16dp" + android:src="@drawable/ic_trash_2" app:tint="?android:textColorPrimary" /> + android:src="@drawable/ic_mic" /> diff --git a/app/src/main/res/layout/view_open_group_invitation.xml b/app/src/main/res/layout/view_open_group_invitation.xml index 8567c06f64..6bc3acab2f 100644 --- a/app/src/main/res/layout/view_open_group_invitation.xml +++ b/app/src/main/res/layout/view_open_group_invitation.xml @@ -23,7 +23,7 @@ android:layout_height="20dp" android:src="@drawable/ic_plus" android:layout_centerInParent="true" - app:tint="@color/white" /> + app:tint="?message_sent_text_color" /> diff --git a/app/src/main/res/layout/view_quote.xml b/app/src/main/res/layout/view_quote.xml index 8f1ca06e5e..273c3b5064 100644 --- a/app/src/main/res/layout/view_quote.xml +++ b/app/src/main/res/layout/view_quote.xml @@ -42,7 +42,8 @@ android:layout_height="20dp" android:layout_centerInParent="true" android:scaleType="centerInside" - android:src="@drawable/ic_microphone" /> + app:tint="?message_sent_text_color" + android:src="@drawable/ic_mic" /> + app:tint="?android:textColorPrimary" + android:src="@drawable/ic_mic" /> diff --git a/app/src/main/res/menu/menu_conversation_item_action.xml b/app/src/main/res/menu/menu_conversation_item_action.xml index 1df21f7fcf..872ff8e5af 100644 --- a/app/src/main/res/menu/menu_conversation_item_action.xml +++ b/app/src/main/res/menu/menu_conversation_item_action.xml @@ -12,13 +12,13 @@ diff --git a/app/src/main/res/menu/menu_message_request.xml b/app/src/main/res/menu/menu_message_request.xml index 6b9b66f225..1a0df71a29 100644 --- a/app/src/main/res/menu/menu_message_request.xml +++ b/app/src/main/res/menu/menu_message_request.xml @@ -3,7 +3,7 @@ diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 8a8217f266..53646d5acc 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -4,8 +4,6 @@ - - @@ -46,8 +44,6 @@ - - @@ -133,8 +129,6 @@ - - @@ -304,12 +298,6 @@ - - - - - - @@ -325,12 +313,6 @@ - - - - - - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2f09f9557e..b0f8b7f0b1 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -35,15 +35,13 @@ @color/transparent ?android:textColorPrimary @drawable/ic_baseline_done_24 - @drawable/ic_baseline_delete_24 + @drawable/ic_trash_2 @drawable/ic_baseline_block_24 @drawable/ic_baseline_forward_24 - @drawable/ic_baseline_save_24 - @drawable/ic_baseline_photo_library_24 - @drawable/ic_baseline_delete_24 + @drawable/ic_arrow_down_to_line= @drawable/ic_copy - @drawable/ic_baseline_reply_24 - @drawable/ic_baseline_check_circle_outline_24 + @drawable/ic_reply + @drawable/ic_circle_check @drawable/ic_baseline_select_all_24 @drawable/ic_baseline_call_split_24 @drawable/ic_info_outline_white_24dp @@ -54,8 +52,6 @@ ?colorAccent ?colorPrimary ?colorAccent - @drawable/ic_document_small_dark - @drawable/ic_document_large_dark ?danger @drawable/ic_audio_dark @@ -133,9 +129,6 @@ @color/compose_view_background - @drawable/ic_document_small_dark - @drawable/ic_document_large_dark - @drawable/conversation_list_item_background_dark #ffdddddd #ffdddddd @@ -175,8 +168,6 @@ #ffeeeeee @color/core_grey_05 @color/black - @drawable/ic_photo_camera_dark - @drawable/ic_image_dark @drawable/ic_movie_creation_dark @drawable/ic_volume_up_dark @color/gray13 diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index bcc755fdc0..8894d3138f 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -129,7 +129,6 @@ interface TextSecurePreferences { fun isNotificationVibrateEnabled(): Boolean fun getNotificationLedColor(): Int fun isThreadLengthTrimmingEnabled(): Boolean - fun isSystemEmojiPreferred(): Boolean fun getMobileMediaDownloadAllowed(): Set? fun getWifiMediaDownloadAllowed(): Set? fun getRoamingMediaDownloadAllowed(): Set? @@ -241,7 +240,6 @@ interface TextSecurePreferences { const val MEDIA_DOWNLOAD_MOBILE_PREF = "pref_media_download_mobile" const val MEDIA_DOWNLOAD_WIFI_PREF = "pref_media_download_wifi" const val MEDIA_DOWNLOAD_ROAMING_PREF = "pref_media_download_roaming" - const val SYSTEM_EMOJI_PREF = "pref_system_emoji" const val DIRECT_CAPTURE_CAMERA_ID = "pref_direct_capture_camera_id" const val PROFILE_KEY_PREF = "pref_profile_key" const val PROFILE_NAME_PREF = "pref_profile_name" @@ -732,11 +730,6 @@ interface TextSecurePreferences { return getBooleanPreference(context, THREAD_TRIM_ENABLED, true) } - @JvmStatic - fun isSystemEmojiPreferred(context: Context): Boolean { - return getBooleanPreference(context, SYSTEM_EMOJI_PREF, false) - } - @JvmStatic fun getMobileMediaDownloadAllowed(context: Context): Set? { return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_MOBILE_PREF, R.array.pref_media_download_mobile_data_default) @@ -1353,10 +1346,6 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(TextSecurePreferences.THREAD_TRIM_ENABLED, true) } - override fun isSystemEmojiPreferred(): Boolean { - return getBooleanPreference(TextSecurePreferences.SYSTEM_EMOJI_PREF, false) - } - override fun getMobileMediaDownloadAllowed(): Set? { return getMediaDownloadAllowed(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, R.array.pref_media_download_mobile_data_default) } diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 9569765ba3..d76012739e 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -4,8 +4,6 @@ - - @@ -36,8 +34,6 @@ - - @@ -109,8 +105,6 @@ - - @@ -233,12 +227,6 @@ - - - - - - @@ -254,12 +242,6 @@ - - - - - - From c36f3305aea9d3758b9918caf9353bdd0c6854b5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 7 Jan 2025 01:08:38 +0200 Subject: [PATCH 003/867] Feature/lucide icons pt2 (#863) * Starting to import Lucide icons and clean up * Removing unused icons * Lucide icons + removing unsued stuff Removed the whole EMoji/MediaKeyboard classes as they didn't seem used * More Lucide icons + ui tweaks + clean up * comment * Wrong tinting * delete icon * More icons * check icons * edit icon (ic_pencil) * edit icon * Search icon (ic_search) * settings icons (ic_settings) * back icon (ic_chevron_left) * icon forward arrow (ic_chevron_right) * icon circle dots (ic_circle_dots_custom) * icon read (ic_eye) * icon disappearing messages (ic_clock_x) * refresh icon (ic_refresh_cw) * globe icon * message icon (ic_message_square) * icon message request (ic_message_square_warning) * group and invite icons (ic_users_group_custom, ic_user_round_plus)) * icons: lock, unlock, audio/notification (ic_lock_keyhole, ic_lock_keyhole_open, ic_volume_2, ic_volume_off ) * icon mute / mic off (ic_mic_off) * icon appearance, recovery (ic_paintbrush_vertical, ic_recovery_password_custom) * icons: help, help circle, qr code * icon block/ban --- .../components/DeliveryStatusView.java | 104 ------------------ .../securesms/components/FromTextView.java | 4 +- .../RemovableEditableMediaView.java | 79 ------------- .../securesms/contacts/UserView.kt | 12 +- .../conversation/ConversationActionBarView.kt | 4 +- .../start/home/StartConversation.kt | 6 +- .../start/newmessage/NewMessage.kt | 2 +- .../conversation/v2/MessageDetailActivity.kt | 2 +- .../v2/components/ExpirationTimerView.kt | 30 ++--- .../v2/messages/ControlMessageView.kt | 2 +- .../v2/messages/VisibleMessageView.kt | 10 +- .../groups/EditLegacyGroupActivity.kt | 3 - .../groups/compose/EditGroupScreen.kt | 14 ++- .../securesms/home/ConversationView.kt | 4 +- .../securesms/home/SeedReminder.kt | 2 +- .../keyboard/emoji/KeyboardPageSearchView.kt | 6 +- .../securesms/mms/AudioSlide.java | 2 +- .../onboarding/loadaccount/LoadAccount.kt | 2 +- .../securesms/preferences/SettingsActivity.kt | 16 +-- .../recoverypassword/RecoveryPassword.kt | 2 +- .../securesms/service/KeyCachingService.java | 4 +- .../org/thoughtcrime/securesms/ui/Carousel.kt | 4 +- .../thoughtcrime/securesms/ui/Components.kt | 9 +- .../securesms/ui/components/AppBar.kt | 2 +- .../securesms/ui/components/Button.kt | 7 +- .../securesms/ui/components/QrImage.kt | 2 +- .../securesms/ui/components/Text.kt | 17 +-- .../main/res/drawable-hdpi/ic_audio_dark.png | Bin 1977 -> 0 bytes .../drawable-hdpi/ic_block_grey600_18dp.png | Bin 502 -> 0 bytes .../drawable-hdpi/ic_create_white_24dp.png | Bin 233 -> 0 bytes .../ic_delivery_status_delivered.png | Bin 493 -> 0 bytes .../drawable-hdpi/ic_delivery_status_read.png | Bin 275 -> 0 bytes .../ic_delivery_status_sending.png | Bin 293 -> 0 bytes .../drawable-hdpi/ic_delivery_status_sent.png | Bin 288 -> 0 bytes .../ic_file_download_white_36dp.png | Bin 656 -> 0 bytes .../res/drawable-hdpi/ic_menu_lock_dark.png | Bin 664 -> 0 bytes .../drawable-hdpi/ic_message_white_24dp.png | Bin 188 -> 0 bytes app/src/main/res/drawable-hdpi/ic_timer.png | Bin 1045 -> 0 bytes .../ic_volume_off_grey600_18dp.png | Bin 470 -> 0 bytes .../res/drawable-hdpi/ic_volume_up_dark.png | Bin 391 -> 0 bytes .../main/res/drawable-hdpi/icon_cached.png | Bin 656 -> 0 bytes app/src/main/res/drawable-hdpi/timer00.png | Bin 318 -> 0 bytes app/src/main/res/drawable-hdpi/timer05.png | Bin 390 -> 0 bytes app/src/main/res/drawable-hdpi/timer10.png | Bin 397 -> 0 bytes app/src/main/res/drawable-hdpi/timer15.png | Bin 378 -> 0 bytes app/src/main/res/drawable-hdpi/timer20.png | Bin 428 -> 0 bytes app/src/main/res/drawable-hdpi/timer25.png | Bin 431 -> 0 bytes app/src/main/res/drawable-hdpi/timer30.png | Bin 422 -> 0 bytes app/src/main/res/drawable-hdpi/timer35.png | Bin 457 -> 0 bytes app/src/main/res/drawable-hdpi/timer40.png | Bin 453 -> 0 bytes app/src/main/res/drawable-hdpi/timer45.png | Bin 419 -> 0 bytes app/src/main/res/drawable-hdpi/timer50.png | Bin 485 -> 0 bytes app/src/main/res/drawable-hdpi/timer55.png | Bin 495 -> 0 bytes app/src/main/res/drawable-hdpi/timer60.png | Bin 436 -> 0 bytes .../main/res/drawable-ldrtl/ic_arrow_left.xml | 9 -- .../main/res/drawable-mdpi/ic_audio_dark.png | Bin 1023 -> 0 bytes .../drawable-mdpi/ic_block_grey600_18dp.png | Bin 359 -> 0 bytes .../drawable-mdpi/ic_create_white_24dp.png | Bin 178 -> 0 bytes .../ic_delivery_status_delivered.png | Bin 321 -> 0 bytes .../drawable-mdpi/ic_delivery_status_read.png | Bin 221 -> 0 bytes .../ic_delivery_status_sending.png | Bin 218 -> 0 bytes .../drawable-mdpi/ic_delivery_status_sent.png | Bin 234 -> 0 bytes .../ic_file_download_white_36dp.png | Bin 479 -> 0 bytes .../res/drawable-mdpi/ic_menu_lock_dark.png | Bin 430 -> 0 bytes .../drawable-mdpi/ic_message_white_24dp.png | Bin 148 -> 0 bytes app/src/main/res/drawable-mdpi/ic_timer.png | Bin 669 -> 0 bytes .../ic_volume_off_grey600_18dp.png | Bin 347 -> 0 bytes .../res/drawable-mdpi/ic_volume_up_dark.png | Bin 336 -> 0 bytes .../main/res/drawable-mdpi/icon_cached.png | Bin 454 -> 0 bytes app/src/main/res/drawable-mdpi/timer00.png | Bin 233 -> 0 bytes app/src/main/res/drawable-mdpi/timer05.png | Bin 268 -> 0 bytes app/src/main/res/drawable-mdpi/timer10.png | Bin 275 -> 0 bytes app/src/main/res/drawable-mdpi/timer15.png | Bin 258 -> 0 bytes app/src/main/res/drawable-mdpi/timer20.png | Bin 281 -> 0 bytes app/src/main/res/drawable-mdpi/timer25.png | Bin 293 -> 0 bytes app/src/main/res/drawable-mdpi/timer30.png | Bin 287 -> 0 bytes app/src/main/res/drawable-mdpi/timer35.png | Bin 298 -> 0 bytes app/src/main/res/drawable-mdpi/timer40.png | Bin 309 -> 0 bytes app/src/main/res/drawable-mdpi/timer45.png | Bin 274 -> 0 bytes app/src/main/res/drawable-mdpi/timer50.png | Bin 313 -> 0 bytes app/src/main/res/drawable-mdpi/timer55.png | Bin 326 -> 0 bytes app/src/main/res/drawable-mdpi/timer60.png | Bin 296 -> 0 bytes .../main/res/drawable-xhdpi/ic_audio_dark.png | Bin 2807 -> 0 bytes .../drawable-xhdpi/ic_block_grey600_18dp.png | Bin 622 -> 0 bytes .../drawable-xhdpi/ic_create_white_24dp.png | Bin 268 -> 0 bytes .../ic_delivery_status_delivered.png | Bin 663 -> 0 bytes .../ic_delivery_status_read.png | Bin 330 -> 0 bytes .../ic_delivery_status_sending.png | Bin 339 -> 0 bytes .../ic_delivery_status_sent.png | Bin 359 -> 0 bytes .../ic_file_download_white_36dp.png | Bin 653 -> 0 bytes .../res/drawable-xhdpi/ic_menu_lock_dark.png | Bin 858 -> 0 bytes .../drawable-xhdpi/ic_message_white_24dp.png | Bin 247 -> 0 bytes app/src/main/res/drawable-xhdpi/ic_timer.png | Bin 1414 -> 0 bytes .../ic_volume_off_grey600_18dp.png | Bin 543 -> 0 bytes .../res/drawable-xhdpi/ic_volume_up_dark.png | Bin 583 -> 0 bytes .../main/res/drawable-xhdpi/icon_cached.png | Bin 872 -> 0 bytes app/src/main/res/drawable-xhdpi/timer00.png | Bin 350 -> 0 bytes app/src/main/res/drawable-xhdpi/timer05.png | Bin 473 -> 0 bytes app/src/main/res/drawable-xhdpi/timer10.png | Bin 477 -> 0 bytes app/src/main/res/drawable-xhdpi/timer15.png | Bin 436 -> 0 bytes app/src/main/res/drawable-xhdpi/timer20.png | Bin 526 -> 0 bytes app/src/main/res/drawable-xhdpi/timer25.png | Bin 544 -> 0 bytes app/src/main/res/drawable-xhdpi/timer30.png | Bin 488 -> 0 bytes app/src/main/res/drawable-xhdpi/timer35.png | Bin 599 -> 0 bytes app/src/main/res/drawable-xhdpi/timer40.png | Bin 585 -> 0 bytes app/src/main/res/drawable-xhdpi/timer45.png | Bin 531 -> 0 bytes app/src/main/res/drawable-xhdpi/timer50.png | Bin 637 -> 0 bytes app/src/main/res/drawable-xhdpi/timer55.png | Bin 669 -> 0 bytes app/src/main/res/drawable-xhdpi/timer60.png | Bin 577 -> 0 bytes .../res/drawable-xxhdpi/ic_audio_dark.png | Bin 4643 -> 0 bytes .../drawable-xxhdpi/ic_block_grey600_18dp.png | Bin 855 -> 0 bytes .../drawable-xxhdpi/ic_create_white_24dp.png | Bin 317 -> 0 bytes .../ic_delivery_status_delivered.png | Bin 1063 -> 0 bytes .../ic_delivery_status_read.png | Bin 445 -> 0 bytes .../ic_delivery_status_sending.png | Bin 473 -> 0 bytes .../ic_delivery_status_sent.png | Bin 473 -> 0 bytes .../ic_file_download_white_36dp.png | Bin 1059 -> 0 bytes .../res/drawable-xxhdpi/ic_menu_lock_dark.png | Bin 1402 -> 0 bytes .../drawable-xxhdpi/ic_message_white_24dp.png | Bin 347 -> 0 bytes app/src/main/res/drawable-xxhdpi/ic_timer.png | Bin 2203 -> 0 bytes .../ic_volume_off_grey600_18dp.png | Bin 754 -> 0 bytes .../res/drawable-xxhdpi/ic_volume_up_dark.png | Bin 797 -> 0 bytes .../main/res/drawable-xxhdpi/icon_cached.png | Bin 1437 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer00.png | Bin 715 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer05.png | Bin 809 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer10.png | Bin 836 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer15.png | Bin 736 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer20.png | Bin 881 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer25.png | Bin 932 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer30.png | Bin 836 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer35.png | Bin 947 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer40.png | Bin 972 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer45.png | Bin 847 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer50.png | Bin 1010 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer55.png | Bin 1045 -> 0 bytes app/src/main/res/drawable-xxhdpi/timer60.png | Bin 912 -> 0 bytes .../ic_block_grey600_18dp.png | Bin 1149 -> 0 bytes .../ic_delivery_status_delivered.png | Bin 1554 -> 0 bytes .../ic_delivery_status_read.png | Bin 539 -> 0 bytes .../ic_delivery_status_sending.png | Bin 596 -> 0 bytes .../ic_delivery_status_sent.png | Bin 567 -> 0 bytes .../ic_file_download_white_36dp.png | Bin 615 -> 0 bytes .../drawable-xxxhdpi/ic_menu_lock_dark.png | Bin 2067 -> 0 bytes .../ic_message_white_24dp.png | Bin 436 -> 0 bytes .../main/res/drawable-xxxhdpi/ic_timer.png | Bin 2973 -> 0 bytes .../ic_volume_off_grey600_18dp.png | Bin 921 -> 0 bytes .../main/res/drawable-xxxhdpi/icon_cached.png | Bin 2118 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer00.png | Bin 773 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer05.png | Bin 974 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer10.png | Bin 1015 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer15.png | Bin 909 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer20.png | Bin 1089 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer25.png | Bin 1170 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer30.png | Bin 1029 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer35.png | Bin 1239 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer40.png | Bin 1267 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer45.png | Bin 1126 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer50.png | Bin 1346 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer55.png | Bin 1414 -> 0 bytes app/src/main/res/drawable-xxxhdpi/timer60.png | Bin 1260 -> 0 bytes .../drawable/conversation_attachment_edit.xml | 17 --- app/src/main/res/drawable/ic_appearance.xml | 16 --- app/src/main/res/drawable/ic_arrow_left.xml | 9 -- app/src/main/res/drawable/ic_ban.xml | 7 ++ .../res/drawable/ic_baseline_block_24.xml | 10 -- .../main/res/drawable/ic_baseline_edit_24.xml | 10 -- .../res/drawable/ic_baseline_mic_off_24.xml | 10 -- .../res/drawable/ic_baseline_search_24.xml | 10 -- .../res/drawable/ic_baseline_volume_up_24.xml | 10 -- app/src/main/res/drawable/ic_chevron_left.xml | 5 + .../main/res/drawable/ic_chevron_right.xml | 5 + .../res/drawable/ic_circle_dot_dot_dot.xml | 15 --- .../res/drawable/ic_circle_dots_custom.xml | 11 ++ app/src/main/res/drawable/ic_circle_help.xml | 9 ++ .../res/drawable/ic_circle_question_mark.xml | 15 --- app/src/main/res/drawable/ic_clock_0.xml | 27 +++++ app/src/main/res/drawable/ic_clock_1.xml | 27 +++++ app/src/main/res/drawable/ic_clock_10.xml | 11 ++ app/src/main/res/drawable/ic_clock_11.xml | 9 ++ app/src/main/res/drawable/ic_clock_12.xml | 7 ++ app/src/main/res/drawable/ic_clock_2.xml | 25 +++++ app/src/main/res/drawable/ic_clock_3.xml | 23 ++++ app/src/main/res/drawable/ic_clock_4.xml | 21 ++++ app/src/main/res/drawable/ic_clock_5.xml | 19 ++++ app/src/main/res/drawable/ic_clock_6.xml | 17 +++ app/src/main/res/drawable/ic_clock_7.xml | 15 +++ app/src/main/res/drawable/ic_clock_8.xml | 13 +++ app/src/main/res/drawable/ic_clock_9.xml | 11 ++ .../main/res/drawable/ic_conversations.xml | 9 -- app/src/main/res/drawable/ic_eye.xml | 7 ++ app/src/main/res/drawable/ic_globe.xml | 17 ++- app/src/main/res/drawable/ic_group.xml | 13 --- app/src/main/res/drawable/ic_help.xml | 12 -- .../main/res/drawable/ic_invite_friend.xml | 19 ---- app/src/main/res/drawable/ic_lock.xml | 9 -- app/src/main/res/drawable/ic_lock_keyhole.xml | 9 ++ .../res/drawable/ic_lock_keyhole_open.xml | 9 ++ app/src/main/res/drawable/ic_message.xml | 10 -- .../drawable/ic_message_details__refresh.xml | 9 -- .../main/res/drawable/ic_message_requests.xml | 15 --- .../main/res/drawable/ic_message_square.xml | 5 + .../drawable/ic_message_square_warning.xml | 9 ++ app/src/main/res/drawable/ic_mic_off.xml | 15 +++ .../main/res/drawable/ic_more_horiz_white.xml | 10 -- app/src/main/res/drawable/ic_next.xml | 8 -- .../ic_outline_notifications_active_24.xml | 10 -- .../ic_outline_notifications_off_24.xml | 10 -- .../res/drawable/ic_paintbrush_vertical.xml | 11 ++ app/src/main/res/drawable/ic_pencil.xml | 7 ++ app/src/main/res/drawable/ic_prev.xml | 8 -- app/src/main/res/drawable/ic_privacy_icon.xml | 12 -- app/src/main/res/drawable/ic_qr_code.xml | 27 +++++ app/src/main/res/drawable/ic_qr_code_24.xml | 10 -- .../main/res/drawable/ic_question_custom.xml | 7 ++ .../drawable/ic_recovery_password_custom.xml | 7 ++ app/src/main/res/drawable/ic_refresh_cw.xml | 11 ++ app/src/main/res/drawable/ic_search.xml | 7 ++ app/src/main/res/drawable/ic_search_24.xml | 9 -- app/src/main/res/drawable/ic_settings.xml | 8 +- .../main/res/drawable/ic_shield_outline.xml | 12 -- app/src/main/res/drawable/ic_speaker.xml | 9 -- .../main/res/drawable/ic_user_round_plus.xml | 11 ++ .../res/drawable/ic_users_group_custom.xml | 11 ++ app/src/main/res/drawable/ic_volume_2.xml | 9 ++ app/src/main/res/drawable/ic_volume_off.xml | 13 +++ .../res/drawable/padded_circle_accent.xml | 13 --- app/src/main/res/drawable/session_shield.xml | 10 -- .../res/layout/activity_edit_closed_group.xml | 3 +- app/src/main/res/layout/activity_home.xml | 2 +- app/src/main/res/layout/activity_webrtc.xml | 8 +- app/src/main/res/layout/context_menu_item.xml | 2 +- .../main/res/layout/delivery_status_view.xml | 45 -------- .../fragment_conversation_bottom_sheet.xml | 6 +- .../main/res/layout/fragment_create_group.xml | 2 +- .../res/layout/fragment_join_community.xml | 2 +- .../fragment_user_details_bottom_sheet.xml | 3 +- .../res/layout/giphy_activity_toolbar.xml | 6 +- .../res/layout/keyboard_pager_search_bar.xml | 2 +- .../layout/layout_conversation_block_icon.xml | 3 +- .../res/layout/media_view_edit_button.xml | 8 -- .../main/res/layout/mediasend_activity.xml | 2 +- .../session_logo_action_bar_content.xml | 8 +- app/src/main/res/layout/share_activity.xml | 20 ++-- .../main/res/layout/view_control_message.xml | 4 +- app/src/main/res/layout/view_conversation.xml | 8 +- .../res/layout/view_conversation_setting.xml | 6 +- .../res/layout/view_global_search_input.xml | 2 +- .../res/layout/view_input_bar_recording.xml | 4 +- .../layout/view_message_request_banner.xml | 2 +- app/src/main/res/layout/view_user.xml | 5 +- .../main/res/layout/view_visible_message.xml | 5 +- .../main/res/menu/menu_message_request.xml | 2 +- app/src/main/res/menu/settings_general.xml | 3 +- app/src/main/res/values/attrs.xml | 2 - app/src/main/res/values/styles.xml | 8 +- app/src/main/res/values/themes.xml | 4 +- libsession/src/main/res/values/attrs.xml | 2 - 257 files changed, 600 insertions(+), 740 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java delete mode 100644 app/src/main/res/drawable-hdpi/ic_audio_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_create_white_24dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_delivery_status_read.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_file_download_white_36dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_menu_lock_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_message_white_24dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_timer.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-hdpi/ic_volume_up_dark.png delete mode 100644 app/src/main/res/drawable-hdpi/icon_cached.png delete mode 100644 app/src/main/res/drawable-hdpi/timer00.png delete mode 100644 app/src/main/res/drawable-hdpi/timer05.png delete mode 100644 app/src/main/res/drawable-hdpi/timer10.png delete mode 100644 app/src/main/res/drawable-hdpi/timer15.png delete mode 100644 app/src/main/res/drawable-hdpi/timer20.png delete mode 100644 app/src/main/res/drawable-hdpi/timer25.png delete mode 100644 app/src/main/res/drawable-hdpi/timer30.png delete mode 100644 app/src/main/res/drawable-hdpi/timer35.png delete mode 100644 app/src/main/res/drawable-hdpi/timer40.png delete mode 100644 app/src/main/res/drawable-hdpi/timer45.png delete mode 100644 app/src/main/res/drawable-hdpi/timer50.png delete mode 100644 app/src/main/res/drawable-hdpi/timer55.png delete mode 100644 app/src/main/res/drawable-hdpi/timer60.png delete mode 100644 app/src/main/res/drawable-ldrtl/ic_arrow_left.xml delete mode 100644 app/src/main/res/drawable-mdpi/ic_audio_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_create_white_24dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_delivery_status_read.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_file_download_white_36dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_message_white_24dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_timer.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-mdpi/ic_volume_up_dark.png delete mode 100644 app/src/main/res/drawable-mdpi/icon_cached.png delete mode 100644 app/src/main/res/drawable-mdpi/timer00.png delete mode 100644 app/src/main/res/drawable-mdpi/timer05.png delete mode 100644 app/src/main/res/drawable-mdpi/timer10.png delete mode 100644 app/src/main/res/drawable-mdpi/timer15.png delete mode 100644 app/src/main/res/drawable-mdpi/timer20.png delete mode 100644 app/src/main/res/drawable-mdpi/timer25.png delete mode 100644 app/src/main/res/drawable-mdpi/timer30.png delete mode 100644 app/src/main/res/drawable-mdpi/timer35.png delete mode 100644 app/src/main/res/drawable-mdpi/timer40.png delete mode 100644 app/src/main/res/drawable-mdpi/timer45.png delete mode 100644 app/src/main/res/drawable-mdpi/timer50.png delete mode 100644 app/src/main/res/drawable-mdpi/timer55.png delete mode 100644 app/src/main/res/drawable-mdpi/timer60.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_audio_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_timer.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_volume_up_dark.png delete mode 100644 app/src/main/res/drawable-xhdpi/icon_cached.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer00.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer05.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer10.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer15.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer20.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer25.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer30.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer35.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer40.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer45.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer50.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer55.png delete mode 100644 app/src/main/res/drawable-xhdpi/timer60.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_audio_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_message_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_timer.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png delete mode 100644 app/src/main/res/drawable-xxhdpi/icon_cached.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer00.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer05.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer10.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer15.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer20.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer25.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer30.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer35.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer40.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer45.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer50.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer55.png delete mode 100644 app/src/main/res/drawable-xxhdpi/timer60.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_message_white_24dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_timer.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/icon_cached.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer00.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer05.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer10.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer15.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer20.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer25.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer30.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer35.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer40.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer45.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer50.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer55.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/timer60.png delete mode 100644 app/src/main/res/drawable/conversation_attachment_edit.xml delete mode 100644 app/src/main/res/drawable/ic_appearance.xml delete mode 100644 app/src/main/res/drawable/ic_arrow_left.xml create mode 100644 app/src/main/res/drawable/ic_ban.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_block_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_edit_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_mic_off_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_search_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_volume_up_24.xml create mode 100644 app/src/main/res/drawable/ic_chevron_left.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml delete mode 100644 app/src/main/res/drawable/ic_circle_dot_dot_dot.xml create mode 100644 app/src/main/res/drawable/ic_circle_dots_custom.xml create mode 100644 app/src/main/res/drawable/ic_circle_help.xml delete mode 100644 app/src/main/res/drawable/ic_circle_question_mark.xml create mode 100644 app/src/main/res/drawable/ic_clock_0.xml create mode 100644 app/src/main/res/drawable/ic_clock_1.xml create mode 100644 app/src/main/res/drawable/ic_clock_10.xml create mode 100644 app/src/main/res/drawable/ic_clock_11.xml create mode 100644 app/src/main/res/drawable/ic_clock_12.xml create mode 100644 app/src/main/res/drawable/ic_clock_2.xml create mode 100644 app/src/main/res/drawable/ic_clock_3.xml create mode 100644 app/src/main/res/drawable/ic_clock_4.xml create mode 100644 app/src/main/res/drawable/ic_clock_5.xml create mode 100644 app/src/main/res/drawable/ic_clock_6.xml create mode 100644 app/src/main/res/drawable/ic_clock_7.xml create mode 100644 app/src/main/res/drawable/ic_clock_8.xml create mode 100644 app/src/main/res/drawable/ic_clock_9.xml delete mode 100644 app/src/main/res/drawable/ic_conversations.xml create mode 100644 app/src/main/res/drawable/ic_eye.xml delete mode 100644 app/src/main/res/drawable/ic_group.xml delete mode 100644 app/src/main/res/drawable/ic_help.xml delete mode 100644 app/src/main/res/drawable/ic_invite_friend.xml delete mode 100644 app/src/main/res/drawable/ic_lock.xml create mode 100644 app/src/main/res/drawable/ic_lock_keyhole.xml create mode 100644 app/src/main/res/drawable/ic_lock_keyhole_open.xml delete mode 100644 app/src/main/res/drawable/ic_message.xml delete mode 100644 app/src/main/res/drawable/ic_message_details__refresh.xml delete mode 100644 app/src/main/res/drawable/ic_message_requests.xml create mode 100644 app/src/main/res/drawable/ic_message_square.xml create mode 100644 app/src/main/res/drawable/ic_message_square_warning.xml create mode 100644 app/src/main/res/drawable/ic_mic_off.xml delete mode 100644 app/src/main/res/drawable/ic_more_horiz_white.xml delete mode 100644 app/src/main/res/drawable/ic_next.xml delete mode 100644 app/src/main/res/drawable/ic_outline_notifications_active_24.xml delete mode 100644 app/src/main/res/drawable/ic_outline_notifications_off_24.xml create mode 100644 app/src/main/res/drawable/ic_paintbrush_vertical.xml create mode 100644 app/src/main/res/drawable/ic_pencil.xml delete mode 100644 app/src/main/res/drawable/ic_prev.xml delete mode 100644 app/src/main/res/drawable/ic_privacy_icon.xml create mode 100644 app/src/main/res/drawable/ic_qr_code.xml delete mode 100644 app/src/main/res/drawable/ic_qr_code_24.xml create mode 100644 app/src/main/res/drawable/ic_question_custom.xml create mode 100644 app/src/main/res/drawable/ic_recovery_password_custom.xml create mode 100644 app/src/main/res/drawable/ic_refresh_cw.xml create mode 100644 app/src/main/res/drawable/ic_search.xml delete mode 100644 app/src/main/res/drawable/ic_search_24.xml delete mode 100644 app/src/main/res/drawable/ic_shield_outline.xml delete mode 100644 app/src/main/res/drawable/ic_speaker.xml create mode 100644 app/src/main/res/drawable/ic_user_round_plus.xml create mode 100644 app/src/main/res/drawable/ic_users_group_custom.xml create mode 100644 app/src/main/res/drawable/ic_volume_2.xml create mode 100644 app/src/main/res/drawable/ic_volume_off.xml delete mode 100644 app/src/main/res/drawable/padded_circle_accent.xml delete mode 100644 app/src/main/res/drawable/session_shield.xml delete mode 100644 app/src/main/res/layout/delivery_status_view.xml delete mode 100644 app/src/main/res/layout/media_view_edit_button.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java deleted file mode 100644 index 861281c999..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; -import android.view.animation.RotateAnimation; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import network.loki.messenger.R; - -public class DeliveryStatusView extends FrameLayout { - - private static final String TAG = DeliveryStatusView.class.getSimpleName(); - - private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f, - Animation.RELATIVE_TO_SELF, 0.5f, - Animation.RELATIVE_TO_SELF, 0.5f); - static { - ROTATION_ANIMATION.setInterpolator(new LinearInterpolator()); - ROTATION_ANIMATION.setDuration(1500); - ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE); - } - - private final ImageView pendingIndicator; - private final ImageView sentIndicator; - private final ImageView deliveredIndicator; - private final ImageView readIndicator; - - public DeliveryStatusView(Context context) { - this(context, null); - } - - public DeliveryStatusView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - - inflate(context, R.layout.delivery_status_view, this); - - this.deliveredIndicator = findViewById(R.id.delivered_indicator); - this.sentIndicator = findViewById(R.id.sent_indicator); - this.pendingIndicator = findViewById(R.id.pending_indicator); - this.readIndicator = findViewById(R.id.read_indicator); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0); - setTint(typedArray.getColor(R.styleable.DeliveryStatusView_iconColor, getResources().getColor(R.color.core_white))); - typedArray.recycle(); - } - } - - public void setNone() { - this.setVisibility(View.GONE); - } - - public void setPending() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.VISIBLE); - pendingIndicator.startAnimation(ROTATION_ANIMATION); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.GONE); - } - - public void setSent() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.VISIBLE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.GONE); - } - - public void setDelivered() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.VISIBLE); - readIndicator.setVisibility(View.GONE); - } - - public void setRead() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.VISIBLE); - } - - public void setTint(int color) { - pendingIndicator.setColorFilter(color); - deliveredIndicator.setColorFilter(color); - sentIndicator.setColorFilter(color); - readIndicator.setColorFilter(color); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index bb23723dc5..7967d41349 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -75,8 +75,8 @@ public void setText(Recipient recipient, boolean read) { setText(builder); - if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0); - else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0); + if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_ban, 0, 0, 0); + else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off, 0, 0, 0); else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java b/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java deleted file mode 100644 index aac02118a8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import network.loki.messenger.R; - -public class RemovableEditableMediaView extends FrameLayout { - - private final @NonNull ImageView remove; - private final @NonNull ImageView edit; - - private final int removeSize; - private final int editSize; - - private @Nullable View current; - - public RemovableEditableMediaView(Context context) { - this(context, null); - } - - public RemovableEditableMediaView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RemovableEditableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); - this.edit = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_edit_button, this, false); - - this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); - this.editSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_edit_button_size); - - this.remove.setVisibility(View.GONE); - this.edit.setVisibility(View.GONE); - } - - @Override - public void onFinishInflate() { - super.onFinishInflate(); - this.addView(remove); - this.addView(edit); - } - - public void display(@Nullable View view, boolean editable) { - edit.setVisibility(editable ? View.VISIBLE : View.GONE); - - if (view == current) return; - if (current != null) current.setVisibility(View.GONE); - - if (view != null) { - view.setPadding(view.getPaddingLeft(), removeSize / 2, removeSize / 2, (int)(8 * getResources().getDisplayMetrics().density)); - edit.setPadding(0, 0, removeSize / 2, 0); - - view.setVisibility(View.VISIBLE); - remove.setVisibility(View.VISIBLE); - } else { - remove.setVisibility(View.GONE); - edit.setVisibility(View.GONE); - } - - current = view; - } - - public void setRemoveClickListener(View.OnClickListener listener) { - this.remove.setOnClickListener(listener); - } - - public void setEditClickListener(View.OnClickListener listener) { - this.edit.setOnClickListener(listener); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index 8cfd58f097..6f6f196e5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -55,7 +55,7 @@ class UserView : LinearLayout { val address = user.address.serialize() binding.profilePictureView.update(user) - binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_radio_unselected) binding.nameTextView.text = if (user.isGroupOrCommunityRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { ActionIndicator.None -> { @@ -63,14 +63,14 @@ class UserView : LinearLayout { } ActionIndicator.Menu -> { binding.actionIndicatorImageView.visibility = View.VISIBLE - binding.actionIndicatorImageView.setImageResource(R.drawable.ic_more_horiz_white) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_circle_dots_custom) } ActionIndicator.Tick -> { binding.actionIndicatorImageView.visibility = View.VISIBLE if (isSelected) { - binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_radio_selected) } else { - binding.actionIndicatorImageView.setImageDrawable(null) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_radio_unselected) } } } @@ -79,9 +79,9 @@ class UserView : LinearLayout { fun toggleCheckbox(isSelected: Boolean = false) { binding.actionIndicatorImageView.visibility = View.VISIBLE if (isSelected) { - binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_radio_selected) } else { - binding.actionIndicatorImageView.setImageDrawable(null) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_radio_unselected) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 93ec9d0d5c..4ff1e9bcaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -116,7 +116,7 @@ class ConversationActionBarView @JvmOverloads constructor( settings += ConversationSetting( subtitleTxt, ConversationSettingType.EXPIRATION, - R.drawable.ic_timer, + R.drawable.ic_clock_11, resources.getString(R.string.AccessibilityId_disappearingMessagesDisappear) ) } @@ -129,7 +129,7 @@ class ConversationActionBarView @JvmOverloads constructor( } ?: context.getString(R.string.notificationsMuted), ConversationSettingType.NOTIFICATION, - R.drawable.ic_outline_notifications_off_24 + R.drawable.ic_volume_off ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt index f65dce4974..ab870d2811 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -63,13 +63,13 @@ internal fun StartConversationScreen( val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) ItemButton( text = newMessageTitleTxt, - icon = R.drawable.ic_message, + icon = R.drawable.ic_message_square, modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew), onClick = delegate::onNewMessageSelected) Divider(startIndent = LocalDimensions.current.minItemButtonHeight) ItemButton( textId = R.string.groupCreate, - icon = R.drawable.ic_group, + icon = R.drawable.ic_users_group_custom, modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate), onClick = delegate::onCreateGroupSelected ) @@ -83,7 +83,7 @@ internal fun StartConversationScreen( Divider(startIndent = LocalDimensions.current.minItemButtonHeight) ItemButton( textId = R.string.sessionInviteAFriend, - icon = R.drawable.ic_invite_friend, + icon = R.drawable.ic_user_round_plus, Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriendButton), onClick = delegate::onInviteFriend ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index e825d41280..46e45045e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -161,7 +161,7 @@ private fun EnterAccountId( .fillMaxWidth(), style = LocalType.current.small, color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_question_mark, + iconRes = R.drawable.ic_circle_help, onClick = onHelp ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 0832da7241..767641d754 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -275,7 +275,7 @@ fun CellButtons( onResend?.let { LargeItemButton( R.string.resend, - R.drawable.ic_message_details__refresh, + R.drawable.ic_refresh_cw, onClick = it ) Divider() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt index d173dacfef..c17a4bc114 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -15,19 +15,19 @@ class ExpirationTimerView @JvmOverloads constructor( defStyleAttr: Int = 0 ) : AppCompatImageView(context, attrs, defStyleAttr) { private val frames = intArrayOf( - R.drawable.timer00, - R.drawable.timer05, - R.drawable.timer10, - R.drawable.timer15, - R.drawable.timer20, - R.drawable.timer25, - R.drawable.timer30, - R.drawable.timer35, - R.drawable.timer40, - R.drawable.timer45, - R.drawable.timer50, - R.drawable.timer55, - R.drawable.timer60 + R.drawable.ic_clock_0, + R.drawable.ic_clock_1, + R.drawable.ic_clock_2, + R.drawable.ic_clock_3, + R.drawable.ic_clock_4, + R.drawable.ic_clock_5, + R.drawable.ic_clock_6, + R.drawable.ic_clock_7, + R.drawable.ic_clock_8, + R.drawable.ic_clock_9, + R.drawable.ic_clock_10, + R.drawable.ic_clock_11, + R.drawable.ic_clock_12 ) fun setTimerIcon() { @@ -36,13 +36,13 @@ class ExpirationTimerView @JvmOverloads constructor( fun setExpirationTime(startedAt: Long, expiresIn: Long) { if (expiresIn == 0L) { - setImageResource(R.drawable.timer55) + setImageResource(R.drawable.ic_clock_11) return } if (startedAt == 0L) { // timer has not started - setImageResource(R.drawable.timer60) + setImageResource(R.drawable.ic_clock_12) return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 22563b1ee2..1ac46c767a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -106,7 +106,7 @@ class ControlMessageView : LinearLayout { message.isMediaSavedNotification -> { binding.iconImageView.apply { setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) + ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_down_to_line, context.theme) ) isVisible = true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index ba0650934d..ecbf497b27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -408,14 +408,14 @@ class VisibleMessageView : FrameLayout { // Non-mms messages (or quote messages, which happen to be mms for some reason) display 'Sending'.. if (!message.isMms || (message as? MmsMessageRecord)?.quote != null) { MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.sending ) } else { // ..and Mms messages display 'Uploading'. MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.uploading ) @@ -423,19 +423,19 @@ class VisibleMessageView : FrameLayout { } message.isResyncing -> MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.messageStatusSyncing ) message.isRead || message.isIncoming -> MessageStatusInfo( - R.drawable.ic_delivery_status_read, + R.drawable.ic_eye, context.getColorFromAttr(R.attr.message_status_color), R.string.read ) message.isSyncing || message.isSent -> // syncing should happen silently in the bg so we can mark it as sent MessageStatusInfo( - R.drawable.ic_delivery_status_sent, + R.drawable.ic_circle_check, context.getColorFromAttr(R.attr.message_status_color), R.string.disappearingMessagesSent ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt index d50d0692ae..f7dd0895b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt @@ -104,9 +104,6 @@ class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() { super.onCreate(savedInstanceState, isReady) setContentView(R.layout.activity_edit_closed_group) - supportActionBar!!.setHomeAsUpIndicator( - ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) - groupID = intent.getStringExtra(groupIDKey)!! val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() originalName = groupInfo.title diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 98a482b4df..d8ba19b7e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost @@ -65,6 +66,8 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun EditGroupScreen( @@ -221,7 +224,7 @@ fun EditGroup( onClick = onEditNameClicked ) { Icon( - painterResource(R.drawable.ic_baseline_edit_24), + painterResource(R.drawable.ic_pencil), contentDescription = stringResource(R.string.edit), tint = LocalColors.current.text, ) @@ -439,7 +442,8 @@ fun EditMemberItem( ){ if (member.canEdit) { Icon( - painter = painterResource(R.drawable.ic_circle_dot_dot_dot), + painter = painterResource(R.drawable.ic_circle_dots_custom), + tint = LocalColors.current.text, contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) ) } @@ -596,8 +600,10 @@ private fun EditGroupPreview() { @Preview @Composable -private fun EditGroupEditNamePreview() { - PreviewTheme { +private fun EditGroupEditNamePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { val oneMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), name = "Test User", diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index d12d99b3cc..faf459d206 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -96,7 +96,7 @@ class ConversationView : LinearLayout { val recipient = thread.recipient binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { - R.drawable.ic_outline_notifications_off_24 + R.drawable.ic_volume_off } else { R.drawable.ic_notifications_mentions } @@ -129,7 +129,7 @@ class ConversationView : LinearLayout { drawable?.setTint(ThemeUtil.getThemedColor(context, R.attr.danger)) binding.statusIndicatorImageView.setImageDrawable(drawable) } - thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) + thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dots_custom) thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt index a413d09d86..967681a143 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -53,7 +53,7 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { stringResource(R.string.recoveryPasswordBannerTitle), style = LocalType.current.h8 ) - Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsSpacing)) + Spacer(Modifier.requiredWidth(LocalDimensions.current.xsSpacing)) SessionShieldIcon() } Text( diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index f34974667e..bff3de970a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -108,12 +108,12 @@ class KeyboardPageSearchView @JvmOverloads constructor( fun showRequested(): Boolean = state == State.SHOW_REQUESTED fun enableBackNavigation(enable: Boolean = true) { - navButton.setImageResource(if (enable) R.drawable.ic_arrow_left else R.drawable.ic_search_24) + navButton.setImageResource(if (enable) R.drawable.ic_chevron_left else R.drawable.ic_search) if (enable) { - navButton.setImageResource(R.drawable.ic_arrow_left) + navButton.setImageResource(R.drawable.ic_chevron_left) navButton.setOnClickListener { callbacks?.onNavigationClicked() } } else { - navButton.setImageResource(R.drawable.ic_search_24) + navButton.setImageResource(R.drawable.ic_search) navButton.setOnClickListener(null) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index de3d1c3925..4b9cd2f8c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -74,6 +74,6 @@ public String getContentDescription() { @Override public @DrawableRes int getPlaceholderRes(Theme theme) { - return ResUtil.getDrawableRes(theme, R.attr.conversation_icon_attach_audio); + return R.drawable.ic_volume_2; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index 57cfb1dcab..cbde5a4183 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -88,7 +88,7 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) Icon( modifier = Modifier.align(Alignment.CenterVertically), - painter = painterResource(id = R.drawable.ic_shield_outline), + painter = painterResource(id = R.drawable.ic_recovery_password_custom), contentDescription = null, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 0b6bbf904f..9554787c48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -445,24 +445,24 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } Divider() - LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_privacy_icon) { push() } + LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole) { push() } Divider() - LargeItemButton(R.string.sessionNotifications, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { push() } + LargeItemButton(R.string.sessionNotifications, R.drawable.ic_volume_2, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { push() } Divider() - LargeItemButton(R.string.sessionConversations, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_sessionConversations)) { push() } + LargeItemButton(R.string.sessionConversations, R.drawable.ic_message_square, Modifier.contentDescription(R.string.AccessibilityId_sessionConversations)) { push() } Divider() - LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_sessionMessageRequests)) { push() } + LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_square_warning, Modifier.contentDescription(R.string.AccessibilityId_sessionMessageRequests)) { push() } Divider() - LargeItemButton(R.string.sessionAppearance, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_sessionAppearance)) { push() } + LargeItemButton(R.string.sessionAppearance, R.drawable.ic_paintbrush_vertical, Modifier.contentDescription(R.string.AccessibilityId_sessionAppearance)) { push() } Divider() LargeItemButton( R.string.sessionInviteAFriend, - R.drawable.ic_invite_friend, + R.drawable.ic_user_round_plus, Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriend) ) { sendInvitationToUseSession() } Divider() @@ -471,7 +471,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { if (!recoveryHidden) { LargeItemButton( R.string.sessionRecoveryPassword, - R.drawable.ic_shield_outline, + R.drawable.ic_recovery_password_custom, Modifier.contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) ) { hideRecoveryLauncher.launch(Intent(baseContext, RecoveryPasswordActivity::class.java)) @@ -480,7 +480,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Divider() } - LargeItemButton(R.string.sessionHelp, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { push() } + LargeItemButton(R.string.sessionHelp, R.drawable.ic_question_custom, Modifier.contentDescription(R.string.AccessibilityId_help)) { push() } Divider() LargeItemButton(R.string.sessionClearData, diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt index 56ca84efee..fef1bb62d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -108,7 +108,7 @@ private fun RecoveryPasswordCell( .padding(vertical = LocalDimensions.current.spacing) .contentDescription(R.string.AccessibilityId_qrCode), contentPadding = 10.dp, - icon = R.drawable.session_shield + icon = R.drawable.ic_recovery_password_custom ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index dc22bf5e83..47170c126d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -248,11 +248,11 @@ private void foregroundService() { .put(APP_NAME_KEY, c.getString(R.string.app_name)) .format().toString(); builder.setContentTitle(unlockedTxt); - builder.setSmallIcon(R.drawable.icon_cached); + builder.setSmallIcon(R.drawable.ic_lock_keyhole_open); builder.setWhen(0); builder.setPriority(Notification.PRIORITY_MIN); - builder.addAction(R.drawable.ic_menu_lock_dark, getString(R.string.lockApp), buildLockIntent()); + builder.addAction(R.drawable.ic_lock_keyhole, getString(R.string.lockApp), buildLockIntent()); builder.setContentIntent(buildLaunchIntent()); stopForeground(true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt index d94cfc929d..6cd950c7fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -182,13 +182,13 @@ private fun HorizontalPagerIndicator( @OptIn(ExperimentalFoundationApi::class) @Composable fun RowScope.CarouselPrevButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) + CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_chevron_left, -1) } @OptIn(ExperimentalFoundationApi::class) @Composable fun RowScope.CarouselNextButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) + CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_chevron_right, 1) } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 0cda62e133..5274defc0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -309,7 +309,7 @@ fun PreviewItemButton() { PreviewTheme { ItemButton( textId = R.string.groupCreate, - icon = R.drawable.ic_group, + icon = R.drawable.ic_users_group_custom, onClick = {} ) } @@ -321,7 +321,7 @@ fun PreviewLargeItemButton() { PreviewTheme { LargeItemButton( textId = R.string.groupCreate, - icon = R.drawable.ic_group, + icon = R.drawable.ic_users_group_custom, onClick = {} ) } @@ -580,10 +580,11 @@ fun Arc( @Composable fun RowScope.SessionShieldIcon() { Icon( - painter = painterResource(R.drawable.session_shield), + painter = painterResource(R.drawable.ic_recovery_password_custom), contentDescription = null, modifier = Modifier .align(Alignment.CenterVertically) + .size(16.dp) .wrapContentSize(unbounded = true) ) } @@ -627,7 +628,7 @@ fun SearchBar( .background(backgroundColor, RoundedCornerShape(100)) ) { Image( - painterResource(id = R.drawable.ic_search_24), + painterResource(id = R.drawable.ic_search), contentDescription = null, colorFilter = ColorFilter.tint( LocalColors.current.textSecondary diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index b08bb0c6f6..2f3b3a547f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -171,7 +171,7 @@ fun AppBarBackIcon(onBack: () -> Unit) { onClick = onBack ) { Icon( - painter = painterResource(id = R.drawable.ic_arrow_left), + painter = painterResource(id = R.drawable.ic_chevron_left), contentDescription = null ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index a65b341a54..d06c5a94d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -267,7 +267,12 @@ fun BorderlessButtonWithIcon( color = color, onClick = onClick ) { - AnnotatedTextWithIcon(text, iconRes, style = style) + AnnotatedTextWithIcon( + text = text, + iconRes = iconRes, + color = color, + style = style + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt index c5f927b4ab..a6071d6683 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt @@ -41,7 +41,7 @@ fun QrImage( string: String?, modifier: Modifier = Modifier, contentPadding: Dp = LocalDimensions.current.smallSpacing, - icon: Int = R.drawable.session_shield + icon: Int = R.drawable.ic_recovery_password_custom ) { var bitmap: Bitmap? by remember { mutableStateOf(null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index f18069f080..9f3994ce75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,10 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.appendInlineContent @@ -25,8 +21,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -40,19 +34,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.borders import org.thoughtcrime.securesms.ui.theme.text -import org.thoughtcrime.securesms.ui.theme.textSecondary -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.bold -import kotlin.math.sin @Preview @Composable diff --git a/app/src/main/res/drawable-hdpi/ic_audio_dark.png b/app/src/main/res/drawable-hdpi/ic_audio_dark.png deleted file mode 100644 index 6b865ebd6dd6aabb51f1544c7645875606a349bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1977 zcmZWqdpuNWA0LD>u5%G%kZEXUvMw{5gxnf4!{izj(vF#IDQvolE<%p`G*&CO7^X(F zWtlg-BvB`({cLhAES9Ee2U{*h5+-}R{qg?sKA+Edp6~Php5OO8m-Bm`>_C4nT`jy8 z0)fzFdozOIR<`~C8{oR28UG%E&`e=7cI{16dH0y60+SG7rP@KdiK`3a|CO#W<->ub z!IP%R6J}ywRVVcN?EiD$;Ke*15}e zIXX?O$e%c5Og=tU#={L;BDS?i35|dIP@rr7_|6%aea7URsET`@?1&mEJ1Ozl`fW0_`M4lWN_)5J!n=Oh}PuomXDGlZ;w)cAf8hd4DL$Nqeqy3 zFx`jNKsW=+@>?Nbm1?0su$@y$gsvhy0Tt4p1PFsrL-JBPL6Z;} zD15o^t66I^qAx;cv;EKS+Q)ao-)lyinY`8*6B@#?IMI7*$lR~LtF6CcVSnB+y>45B zChCYbF5u{01<=Y;1GZ`K$@a&i@BurfvVV5Mpr*e``<9d>{uZ9(HTa(=^tOYq5jay>mp9w%PRJPIW~N~EW-9Sm0m zO}^0)d@^5?PX?Kro@%|b2{P%<@6pEv0CN^r>X(*<7PWbw_X@#FaD-!Ld1%@S{&tdk z2;Cjtva%@Gfmc#?j9}m(QV9=+>a$~@$1NZ_qPyt74%^P~bfof&-`AJqJ~ z^aGOV*j-KgG)Ab6D+r*_iw|8}bD|Wor;N}b5iFv9(#!92sfBUNZn9!jF8+8QprfHY z+QiKFB?YZmPYSfwm*(_|9JG8+M+ETn6{EI6%_t@X%KOVb)0K4~p#znJ;2nXH%HgqS!_;Vfz4@t zU~}G!iW!BWNO&7162^Y0p3r92R9|G)L`}>4P3#yopSl?0M)&N%SuR$V-7*eRAoZE& zD@kJFMdECuYVL`Iu~~(o%y|FAYR1L#V47!0*Tmk*hT^d1({47bC-B`k#ckza`ur{U7qu8vyL0UyqD=?`hJaHi3O^+kDe_I!2Jkz8EB(334LW;eH_ zP{Z33p^*}4zu$5_0`f)6ks+ryMBEHT@NQ+j2VKjcLE~XKX_1Q$#VI(Cru@@O;gv>Mk($I3(t6SPg8DxQZLdkp3wc%~y89s-)IFzW+s_{pt7CaIhPy0$ z>GXG41ChC8DY*4ZbZiG>$QqullLLkDt|=%&J)^xzZA zzE>kb7?dXv)8YYpv9qeM!n2$!Mz9qF(myA#B0-3D4Dc3CvW#r%2FVmCAI;D1AF{~@%u0~004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00C}EL_t(I%eB-!OIu+S0N{5lYAjSshyH^u{S`tngP`bM z5ITzBGfQz3Qm^_)LY6}O7+m}<)%XEM<6t};qP@BH=BAXEGe7V7;5{cve-r87v6>Z` zmLn}YmQ9UkZ|%Dkh3MKfTg4V!2;oj!=BsbodP3-^k78du3LR@|g_74@3Vo|(Y{8>& z;$2C%^Frw78S|blgcGky&_|u8F3-&r*}l*z%``wi%()V>VODc1tW}^7k`1A2GG{Bo zU11Qvqhwl7Sjt%@w8x+%IS?8-Yvol}puzLaoE-_9V^Ojt92T=}k44E|G24#teJo0j zi@hxmO>#7P?Os^S*_5uZUWpRIjfvr3vMXGguR!lzKbM%Z*n5YF;UWw&HTru*WS+-N-a sRCT!~Eoo>v)YPyzSe5^FC;uRR0-x*y&q^|(SpWb407*qoM6N<$f|%#f-v9sr diff --git a/app/src/main/res/drawable-hdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_create_white_24dp.png deleted file mode 100644 index ea806946d64f8302d157a23d15524e66d1883d6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 233 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8Lp22U5qkcwMxFRkS}WFX@3a38DV zL`$CfG>LhPjE;wlbrd!JR@C|}+vPx$r%6OXR5%gEl~GCqF%X7#l|tV=a3+DGr`20bI$HyWc|w2ax~nF{To5)Jb_f5L}Gp+0&=FKC2XA(3UQr7$gv zS03f-2YFcN*@HWLH+X216vmHJ9;AFKs1vJ=Nr|+RXdzK4jHR17_+0sr@|JEH_5Oamv%8O)$cC7AsLksW7B}idBR)v1h6sefrq4G~uOznE%;FwQg6bBa+ za=DaWIks5npjmsfpq*TzLkF5)%Mdew)hBU{4ruu=i}&%?1K3>q7-NCkk**4d9#@ zz`mAQ=L(}x5c>lLnI(J)#+%Qmxc{h3N#7^=Ag}+d>k}`dCtGYd5|o%LXYY^J-eqBzND}-{n!$FiJ$jA-~3dqFWoig z#-^Lbn``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{w_}!$B+ufvlI979&+GmmAB`O^*WW~;O22$ zDu+QjAh?zH1l!7$5{(9oITLygY(DqX`e>)p(i4;I=9cbzzBx5vCD&9>tI7JBBAx41 zB#iA&%svvwzVhB|R=;1}Umy4hoz^+Vv297lLiKAewy90GJb8jO@0&~U%zeKd-EUP* z_10C`Uw>ku=u*$c|76l*bZp+8Uy~ST&HW~+DxTyp$>*V4#lg3G myR460^_a+UmaXW_O8YGvRV2%|J?#Vfg2B_(&t;ucLK6VJj&B11 diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png deleted file mode 100644 index 96a7b6340c5cae696d933ec282f824e2c1d76062..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh1|;P@bT0xa&H|6fVg?3*IUvlacGk)SD9BhG zUpttNyGw%N~9Ch1yeR)vkB7C9A#{nJ(QTV%E22vhj}1+m=@_T{pdWtSfj=m7>#S3j3^P6qJU~QEvBj9LkX$2}J0VdIwqU|RMHl!`Z+fN!S zrY%+4PXjEZE!Emj3yjm2s_mx<*4LKSDz=|CSXW!BwqFV`KjDoBr`mq0z?v$jUMaHV zv->vDpBcaK|KE+^6W`XL3BVrXm}d}mC?}0$q$c@CN=a5S6wDnU^qqNW$t_699UKbA zp7`A2g3}kskHa5**W77;aHa!FY0RR7GfgVIOVAkgV0000wq8JJ>n!UpZiV7O)N2 z1FQpc{}~vP z^rer01;ATiVa5$hI@gqdv3z8v_3ugA86jf(F0dnG$0Y5I%$o>P)q#h=mK^w^q+<~x zwnu@Z89Od%Yh>Oa1dIclbKp@)$0I~+j{rTfk0kYy5SjiLrm6#-0AS`Eu)QBRnJ=XD z?x}O6_ffh4&r7-<4ID0^O#WKo5{j--7MCu=15puW1f;Wjd095o#J`g?6q#E_K(aWD zk1Rd~#*#uM8YW~rSvzAxQixj!lvsBHN_y8h@NdGVQ9|#5$2ra>U}+f>ns(qOa7fa( zynp5aSAg)Vvq=G|IZ3VJ_hQCv&&{o8YJ&QXCIyU0Ix!JL+ZTY)i?tI_VH3B$gp&4F zZ{nVL#bb0Mu&nRSEdZL_17&zlitPkc*uzXd>B#aD{}0000|k1|%Oc%$NbBnmt_{Ln>~)z2VJuK!JxPApF~{ zc?%wRI{Q!9Rd90|N2Z#t@QkheJ@*%_QVr&E>|T(&q`zZd{BfDpQ47|6ysA5a!6B(J zz;%N4iR?&W5jL;u&Sy2>EHnt%rTO@nhk$=|)4!V5yPF$Ql3r=8P?YZqI3uhflia8< m!&-o+n6)YChy54N3!FwPBM)0fo)iE&hr!d;&t;ucLK6V`utrt@ diff --git a/app/src/main/res/drawable-hdpi/ic_timer.png b/app/src/main/res/drawable-hdpi/ic_timer.png deleted file mode 100644 index c820ea2b0ae272a4a574fec2e6be5ec61d319f4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1045 zcmV+w1nT>VP)Px&&q+iEgl<7U+$h?L%0L^d#l8blLEK zn?z@ALRSuGTGp1__RMc z=}X~Y-Qzrz-@uxbfS`iC27Cohj{D-`*hsJn*wwdxhKJ*ZYk{(T#^F)$pE0o`=<$}X zKi7Ga>v=K+!wT>-aA+dec(^kr{MrBqfRigT&MXxYRDoYXBDGzm6?7~xsL+9!QHVQe zI7+xaBPk?6ZJJ!Inv|POY`4)vRZj3*ID3Ks?IWF`!I>R*Ovq0CLXS)jTZ}VOg`-1P zKeLm)k%SN@oW=L+j8%&N-b?A#Rl!WlSW_UV0 zKLvc9?v>ua2qT=bfUjag9U3-<=)=yF814}ed#KY>z|}R!$)q|o;A)h$>4b%@(`L14 ze@Lrr%rshW^ud^F-J#C%)o!4Z|QvH91<>kH79MXa5 zMAwf+n@6-&58T~IJAPF@yY2_R=t1yR1(YYV{KEN!w?cN$qb~X~l1xU=nt_vXlFU`OF8_2S6<1e62&#|yEwKyP`iTq_!d^3V?W91w~@C$Rr) zpqd>2$CRf)3#bDnpaQ4^S{!-^+ymNZt!TJ3Pqp98O&~V{Ez9|eww2r*O(fF8Za=sU z(!Uy0q}<2b9QWtQdh;WB70C|(Jq|X3jX;z0dw?-Y-5_*mKMRf~)Ohj_v;_J$r8ZfL P00000NkvXXu0mjfO|Idw diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 6cf04dc7205eebc7573b212571339df2d1addd94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 470 zcmV;{0V)28P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00B-(L_t(I%f-~OO2beT#_?0EgS$2v1?!*>AUHIYj+Q=% z;-vgX;kt`)nRM%gN3$`Bu+LBuYCaEWqU&Jq~dOwUkyzhMcSMG*QZ z-Eww9m_nh#%g?(dNj^EuP;;P9gtDAh9OE#_z$RBkeKQWr!fJ6%jl%?=H|LIp?KYNG zXmOWGwPYz63_{bAUMAJTU6Qo>mX@v6N7D1MWi!u`(~kDE7v7)d3%JPx-x-B8zyJUM M07*qoM6N<$f-CE~UH||9 diff --git a/app/src/main/res/drawable-hdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-hdpi/ic_volume_up_dark.png deleted file mode 100644 index ab9c27c5afb3ba4b3bb49a307e2e000f6db50e84..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 391 zcmV;20eJq2P)pw&u5C9PcweT9Iig}8ex z1}_%}7R-K&2z$ERz;}k(`MHxcH2fVLvRtnKa)#{F(#8&)ATVNE7k>m^V4N^389g|` zkOGVwmL&1uDqT*=-Bc|K;^9Yqld&monX4QR(SL2O`Q~`)PwNB`%y2~@r(a?8CWxSI z6ZJ@|LcAA{v6>k4C z-XGGwGjbs`~>%8>6A`-AAX diff --git a/app/src/main/res/drawable-hdpi/icon_cached.png b/app/src/main/res/drawable-hdpi/icon_cached.png deleted file mode 100644 index 777153ba4db532af55b8de58ae806878bd1c3809..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 656 zcmV;B0&o3^P);(0Sgd{{8Ia0@z5^Mxd@I(<}C+dNs zg&=65h^g$7z*PxEBmNai63*v?IaxNh&aBym^Jn2@_GV_kdFK7T+Z`*EXP1>{JfUj= zWipu(HqxQgKwGXh_LFuS&7qWKS%+RX>AEuJK%)LuBM=d z`Q5Mj=4rc`f2t!g1*#*^3aUg>GZBI>T@@UtHpWv(YU(>`;h9~(fa(b(JC<}Dqd38( zU|lbOQ>s-3^KyE3E;*5N&w8ibWLz0ml^ubZoQsj?y>5}mv zfF!Os^;H2>n>dq&4GPG_jGS}sfppg0XovFdfEM%-_MCJkNm-vWt!@W2Ipn{QZxssb qYQf`pn)wrG44{1@Add_9I{gAfm*^!fbxh9y0000P)Px#_(?=TR5%f>RND>0FbqseI}R*RM&SdARr*7B=qO12fCXTjeuTTqj@+nkHPR{0 z`Fx2{jdLR_git7r_(hm=%qhn`vj-XgA8?i7PlyR!6*bfkASV!{2L6NY!j>TBNP%dH0^- zMt&!cy$-CU$6+s8dPx$KuJVFR5%gUlrc`jKomr6WOD#2?tp^mO-Y{%(4|AvIRi;WMS;X2sKFJ<0RT}_ zQ_+M50g`!Rc~+ZfjpCA(M*n+e{`dZ5*IAZWP1BU7$?w4%cmUVn$Xaj2RDva#*+f18 z7eEa^fz~b9wMVgtAM!l^#;}BYHV=2>9BoAY2tMGy%!h~}j1tI1f=@eHfqlzswLEB? zxy4jOKLJ`(>*RBw39^DjyIFuRj)ZvO(NAy*Ub|@di3EAOiR3(&n>BWa!I9!U@DA?& zEXGiSNuLSr4E%tDlt;Co-bslu$KQO#^N9Eo^Zy`cK3$6$I32oO)pWIG!R)#g$27@@ zHo|`hD)nez=2 kHEd~e9oX8QSTm>i3le1XJzqW#M*si-07*qoM6N<$f^ea$MF0Q* diff --git a/app/src/main/res/drawable-hdpi/timer10.png b/app/src/main/res/drawable-hdpi/timer10.png deleted file mode 100644 index 710c6a07553b9c37d352d2b31090cb4b9fc48e2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 397 zcmV;80doF{P)Px$M@d9MR5%gMld(?2Fcd`_XxRG+2!VtU%$1UnogZN9iWs}_0ag1AbZ1~;VWE*2 zU}8f^EUYZ3?LbE=(sLs_iizF8hNUAv-*?}Ot=O%WXXJSvTbXzQAMga1klI{u#uURN z46TW{2Lrf<1!&yEw)J>t3?n%8CDA)@0aN&duH!=}kK!Z(3;cWV4s+NoZ7g)0Q7QZ) zz@Nf5Ol036&~6^!SwhpmzXg4Kx3Gj4X-#!bo`Ms21C1NlaSTlh3J%~7G_-@`S`A_b zy;{NuVd!QBW>FJrS00000NkvXXu0mjf??bOr diff --git a/app/src/main/res/drawable-hdpi/timer15.png b/app/src/main/res/drawable-hdpi/timer15.png deleted file mode 100644 index 47767b4cac957f62126250e8a5c6c827cff90b34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 378 zcmV-=0fqjFP)Px$G)Y83R5%g6lsj$$F%X6^Vk@`+6)8{(q~rq2qv05I6gfkV0OAT!l!lHYfIxx^ zP(aB6f}li6isk#!dc`JQM;-guZS8^cHeXtba^l>ez#;!IsFQHb7knl!RE^Vw6#CZ_ zIbUUFE`iKPs{+Keoj=bh_4(uKq4aiEd|laR_w0#bC~dMOEB@Wb1y62l{n&JA?bvw? YM`<Px$W=TXrR5%f(lf6myumFTz=G9+ zJEjU;K-)~jE!cq*c!zK3LTn!Ij-dlhUlF|uoA3%xP#f|gxT6de0txPx$X-PyuR5%fhlf6mqXC*2GL^}&X1ug7-0lOfgSljpjl01OjzJX|Z z4T@l885{AZQ$hSgtt_&Bci20dh1r=5d^>l}IdjQuW~0b4(lo89OnnBga19Bps8>1@ zs=*aBHALNj12}?DkZ}%m%{XTQE!c3G=p(QR@9+hSg&cxCO2H^uke`4T_=YJPVduf96IKH0j*#a4>u`d|%XfRCX8QP(2(5Klk+*WvWpyAtrKjZNq z@y08jfU#(@L(yfPEy?!Kt0i78(_jqb-kj!YUZI~rH&;9r!CjEC-XCK<3O|rUJc55e znJV1CJq&3x>V1&0WHgH4g(R~kI~aSiiMbAMaABHc;y|AWo7y0s#WFl)^`vdoUtsz^ z+G+eh=)ngp=-$HLh9SR9&YNlg&D^m#juUubeTX*)+pO<2=bW>K#-o-C+tDDkec7tX Z{10J{T%Cuuel-99002ovPDHLkV1ks7yp#X{ diff --git a/app/src/main/res/drawable-hdpi/timer30.png b/app/src/main/res/drawable-hdpi/timer30.png deleted file mode 100644 index 4cffeef9932c11c251ee842944b22687bfefb3b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 422 zcmV;X0a^ZuP)Px$U`a$lR5%fpls!(vKoCaPA)%q6Nt;TL89c%{?AchZ zMbv;>=vxu<9t_|LzCj~`4Xg251WG#a1@Y@aNAL+My5s0@q+4+UISce5Jir&Mr!p4W z&LDM^n4k|qokfz@n5dfx+{;mR1VQkI_5^mR(GAKb`gDsXK8RiV;+1?9yM`xiP4}c% zDccpluw1LTzU+#J+=y(%_0&1EuIsA2YLW5&Z%0LC=~QpL15xt&arD7^n~Mq&)7h@V zg|_xfk!=>0t^zqBIj1nD#t>@^&fHIgSmviV$*kviLg*!r+b&>}X%Dz*$`IHbrcHh(M%CIS_JAJ8A$8MJ+we|um1)CJ~Z Q;Q#;t07*qoM6N<$f`hlXJOBUy diff --git a/app/src/main/res/drawable-hdpi/timer35.png b/app/src/main/res/drawable-hdpi/timer35.png deleted file mode 100644 index 3fdbb5e902ce587960e3a5d9e549b5b35473a73c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 457 zcmV;)0XF`LP)Px$gGod|R5%f(l)Xv=K@^3v(HJWct281kksu-nc7iC;-WRY7641`VHxNl)z(%bs zgIX(sh+<)7w9qJ4iZ-J7(~0YM7H5++v&(`PKJJ}+&fa7vGfAZ^;dx%hw5bo^0~)Xi zqehF&m@;qz6=R|9`6hb~4!K;LXhz0(BxPu39 z0~-syPBmyGDDW5H8IEL6Q|Q|qLtTxe;W*AU-W8Z&#Osx}q3zyIlGpLNNnDRXop##Z!St>CTGh_^1m)Sp% zW>9cN5OVMayD>us&>w$&uGNRQ1-juQe+~4yVIH(EuP|;rou*Jm$dYoPgL=mLR2e+T zLlJu65XxMW201zyP=qzOf)~*L&^c_tr0vNcTJT|Clw?{j00000NkvXXu0mjff(5_= diff --git a/app/src/main/res/drawable-hdpi/timer40.png b/app/src/main/res/drawable-hdpi/timer40.png deleted file mode 100644 index 43d4607b414e7ff239409051f75a9c722baee91f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 453 zcmV;$0XqJPP)Px$e@R3^R5%fhl)Xv=K@^2|qe(0!omEgFAeJ^F0aMuf0v19LL9A_j07)9bKKKHn z>5T*wD@$#HVy6M|4+$0)S-+#~u;b2b9QbzcoO|ws3HNRgI93!z`B*0Iz!O|S3zp-Y z_l+qJm(Yk!#C6z%BX|R?E>vQV^NpbiYpx{v1Z=bIWsnR%b-!|8XKDmyuyQG zlkfz1)YTdh&718T5iP&aEdLLH)z$)k?rZ)Hl{dy0i)m)X|SQ~&c$}VYA00000NkvXXu0mjfnNq%g diff --git a/app/src/main/res/drawable-hdpi/timer45.png b/app/src/main/res/drawable-hdpi/timer45.png deleted file mode 100644 index ddac60676cbabb5cfecd9f826ed554f6d014f883..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 419 zcmV;U0bKrxP)Px$T}ebiR5%f(lQB*LK@^5}2~;-TU_}!PTWip`#8X&lp`{n#0B{5fIDmQtOfYf* z5;YXY*l40gO)Llv%lE_1Br7v%q3sINu1AbmR)+*N0Bw9aOZE(c!RL$pkzT^by>_Cu~_66K$ty z9XST*Bhbovk{1|gHMcO3Bd>*F_>8s=Rcgdx-b9x!UBnx)3s-z8AH}XSDH3Xb)uPD-=4L zrzY_%mx+331Uq?)p^d;^NKxwwN0D3 z*#y*pZK%RGT!Ze3OsAuhU56Vu=A<8a`;CGz=ro7$lcKSF^B?;weF`NsIH@OwBqRU; N002ovPDHLkV1jaIt@!`| diff --git a/app/src/main/res/drawable-hdpi/timer50.png b/app/src/main/res/drawable-hdpi/timer50.png deleted file mode 100644 index ba5cbce03066f96198f6fb227e473cf3104c086a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 485 zcmVPx$pGibPR5%fhlfO$sQ547D3oVC+#x@};h-h+?z=ze;&_B=?B0&T-^gkqxQ4lml ztwDQ*{h%cpgDk`?oXRK(A@lY5n%A!KOb>kCJ@=gNx%YVQ)o}vM_x-R5E%rw-8_f^5y)mG?IUYiWVwC#uhOT!3w-U6(;Pw?UTEOS|353hi@lc*R8@fu`yW1 zf3Wjy{uT3bh)*6y+miJu3%ug&1m&#Q2!2r=-GOc{VkLWWsTn|+fNPym0%WP4sx1*U z>3XH_ds@haEa+gPmhN8{Z4AnwGx{|q?PhJV_qL%gh zTZ~u)QlRd&aoh-8<7gzI)em4AbYquLfEws2crXMPkmgpO7&{vadaw_A_3H8y^x75k bf+~Li&b_6hbWy0V00000NkvXXu0mjf>4VUJ diff --git a/app/src/main/res/drawable-hdpi/timer55.png b/app/src/main/res/drawable-hdpi/timer55.png deleted file mode 100644 index 2eeaad9c9f2dfbba064c76cb5b99cce1ecb003ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 495 zcmVPx$sYygZR5%fplf6qqQ5c84B^opsZ7zK%xI~kSNZ}paTN_(KqK2lH{)k!|Y-}u| zu^`FjrZOri8p5Rysc4Ak_WUmQ-j3HR$Ca_5q;2FN*6S6_lGl&ht60|`>r#fl~Ou{QXz>H__cN7^8 z`7AWx9@0Syrv{IZQxLrdl5$WKGRSF|c6Q>XFth{7x?hW(fF_*7pdSxwa?2noYjvH( z8;qER_;VmxG2^C9E(IS@)gZ09XBPN2@k4lmQ9l;e}l{4qp5>TPP!DU?__iSA428KPkp&h)Wu zkW~7bLNPsyZl~jv++LhI=wasnJB->1(1{)gJvbTA6KTPCkmzAhllN+Mv)$JaloBX{ z5?V!D*8lGiVo9igcCUqteu3p1(j+u}3v6gZ4RptJC^{W|3ti4SEO4T?=xAY>1v-Fj l(5=@K(CSy9=VV89{s7)$ochNdd9DBe002ovPDHLkV1j46);ItF diff --git a/app/src/main/res/drawable-hdpi/timer60.png b/app/src/main/res/drawable-hdpi/timer60.png deleted file mode 100644 index 85fdb05febb206e7e8639a7fcff298f2fe4c673d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 436 zcmV;l0ZaagP)Px$Zb?KzR5%fxlRHWSQ51$V;-j#!P7^d@VdDxCAK8gb#L5M@5hG*^?jV}%K~Yc? zlr*9S1Vt_4_nAARVaZhF0~6oj02U3go=NZ*@b|%-XQ-(shENTG3)b}{fWtm%6zgak za1Ad|jdhd#oJXi6VsOyL8W}=g1>daqlZw*-tr==l3wmZFlZYK!h&9}_z*n40bqE{u zyhiiH{a42`&cuP7)QFl2IK22?G?%t>9^=Z$(yD6{He+S0000 - - diff --git a/app/src/main/res/drawable-mdpi/ic_audio_dark.png b/app/src/main/res/drawable-mdpi/ic_audio_dark.png deleted file mode 100644 index 256c04bab669172b0cbac7c53a73f0ed51ff4e0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1023 zcmVR>tBK0S$tMO+X?xinj>HMi5a^@Io3zLD2OQBI~NSghbg$z{h`J z&i-cP48HH2{e1s|-_y*?8R`F7(ywK(}mxo6JrJaZC?vmnF?5N)Fso6m8pis zk{hQND^nGVvmTyKtW0$*c-_U*hLsr+Yr6x!H%`avadnf5q`ZOMUb1G?K6XK`u>vYm zxOsd1V6@(K{xo1^Lag$(c+y{pJ7%WuSeY0r*i~Ld+$%Qq87nK7#@emE6nDg+uUMIC zSjlFG#68W=1Xm6;$`GUu4} zTkH!~X2Mv>79XM9s>}O;m8pOgZ{F7^_fGe*GL^96UFjssJ)3nIIOYElA?squicuU}YwZ z#VI#Q;b#4;)O+=@Zt&rl`wWxE;txBea8D@pkIQOf-KKtZu=vru6l|L{r50Pl;;0nv zh*B@Mg~dZsu$@YsYz^yA+ofRdE48C7EDlS-_9^v9Ygp%86WnmUQg5|}#mm7B1HT&H zDVtlvIvc)5yr$GbYgqAb;)6;(+8);D!4*4{I@lhT@B`u(!+X9(tUH4{J`H{ko5Ff3 zxZ`cZ`=mvz5B}q;=~9bWCxSZ;8Q$p@vCaf{Jg(GSdsu@174KE*>ISibchB3E+VyXH zcW}Zm5Cc*4=zIFhHC@bG%)F29_4 z)?J_ca^}vp-(&mQuZ40^1$l&Eh*!xYxJM*Z9${l*>}C*I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g$s z6uj^0;uxZFzV(tLS5tt%v5)MIm#o$ri+FO#seV+cKP9}S&5$wzOBvom1qh_LM*A%Yn3nUab2)K3#NKR3(5L;9r5N29+X|upl zhQymav!gpCw;P;Tnv|{h?D63$R(6x;`i~Z$oMqd)LrKPZ$pQoOHS^P^WCS_Mu(d47 z;97QDq9vm655z_vgzS_o4+f>AGAF=>2bzg7wA<6Pgg&ebxsLQ0HCUf A5&!@I diff --git a/app/src/main/res/drawable-mdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_create_white_24dp.png deleted file mode 100644 index f5ddc2f9211cfb25d79d59a4ac87bdb3f1d96cda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iN>3NZkP60RiKYkpkN;o(fA>FQ zq184o{;&V9VDQ{txKK*#|6vF7#H08A2REe1h8)&DbgKT(|KIkKC$gBd{{Oq*yP-&d zA2VIJ2P2bK%x=KvkS;OXk;vd$@?2>@iBNgn_J diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png deleted file mode 100644 index c66da8360b1b89ee7c249b6f19fe2ea967c7b9a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 321 zcmV-H0lxl;P)Px#`$ zqg2EdC61t%R^U0yH}qc2F*g+4+@jU%BAUDSMxiyHS`NU@7oN85rMm;&3p zN_u$qExBqG>f-Anv8cJveoBn-067RDoN_#zQ`&Y^>l8Y{#?t`4@QsD6Q>K T-Mq@>00000NkvXXu0mjfYHEr2 diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png deleted file mode 100644 index 072dac7b300219d6b232f9bcaf01e40e6addbbe1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 221 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7Bet#3xhBt!>l*8o|0J>k`9+>Cjv*C{Tl)if8w_|{q?;I26c{-@4sd!% za7}OsXlPMn6lrMoY{)K>KYgQa+M1Km*PeX6);VR3T*L<^_GvS&1abU4sU6IgFk@eE zUdW0)M|f@ClqNjedwFqMapOf{{(Xx|y*4cFev_f{QSo%6w9lkqd1EeFrI+zu+(6qI NJYD@<);T3K0RS7AM&keg diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png deleted file mode 100644 index f9b7fe3b79803d020f7d4b7438ea70f87c722f72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7Bet#3xhBt!>l*8o|0J>k`T3qMjv*C{OZy#p8w_|{y_*_5SBS_cFv=LP zv#?t*h=efn9pDuDp<+Ab(%c^>t-s`+z28x%RvEanl7V*zLo@H1x=favJ0r#81%qeu z^e>O%k9aM%p_y;ag9Qpg7JW6>$|tQ9-}~ohDSwo)|I%Mcd0*EE7({%IH3M4B;OXk; Jvd$@?2>=QNLY)8r diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png deleted file mode 100644 index 26fceea79c193fcb29f8c7a21e5386418c4b5fac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 234 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7Bet#3xhBt!>l*8o|0J>k`SqSIjv*C{y%RR_HYfEIU zucU75`8*jr$<$h_YEtvp094b$ep;m`(}J_puWP$yxfybqaWl9C&($#tG*iCq8T=CT z;mKtv!xKENZCY#6hvnQ1`I+v(UuZgpZ`+YRES20crzYszH^x+Tw^HW>sBYPxfKuYr z(4^;_Qx_VqpOAJV1u{M7Ln|m;i6#Y*3v>a#2EVIr#%|-AOdZR@AgKHO z1b&T19N47XW91)s!`KA5+TfTv7MHt&E^xp-_!DfvEt<-=8@zUp?F&vU4ori`6%Kp| zKw?1tc);@&8A}9|10{mV0pdW3VRC^|P-2)8pdlzROes(dN(>_b3Ii@}r=VKKfl^^2){HP+e z77OBEz-HicAqdrs#9#sW!rFr0)VLYik{CafD(YXaq3TvybzdDPpp>cu`VIg9|Nja4 V>Maba)7bz3002ovPDHLkV1mFp+{FL@ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png deleted file mode 100644 index 530ca8c6e6ddcf5e641f55471e68584b712eeffd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 430 zcmV;f0a5;mP)HJq@gf!4H5n;2LNHW`W`G-HfD-xDc@bY&QZ|q2$}Zq@?Hf zB)02-Enp(Fq+QZO9Ei98qrfik1hh)J`yAYMGjIdc17ni*$_bDOiVVm}>WK<$I|pqpy>!B49I YZ=~j~Aql6X(f|Me07*qoM6N<$f~7~cL;wH) diff --git a/app/src/main/res/drawable-mdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_message_white_24dp.png deleted file mode 100644 index 6979bbe5b16749f4cc75af27a0e05aab58e1ef9e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj(Vi}jAr-fhfBgS%&#Y_6Y^%$` z@@V>hP4@K-H{@rtUNMkii%Z(va4X?~u7ar(`+U~LjwVtr&I%oDTUqrpG8s=4#5;+Z wTv&8y;jV`_bQY|7_(Ck;Zo#hbUnU0`o-}L|H~7z)4YY>A)78&qol`;+0O4OVJ^%m! diff --git a/app/src/main/res/drawable-mdpi/ic_timer.png b/app/src/main/res/drawable-mdpi/ic_timer.png deleted file mode 100644 index c999a9e2c66baa967751ab233cb7915fe6d53722..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 669 zcmV;O0%HA%P)Px%S4l)cR7ee-md{HTK^VtfLC8Z|gb5k1BJq}80=sp|LzfO-Of*XH;Nd-V5IyIA z5OwGjgrGM;DvdmO2t5Z1K_Ehq;30WaNT@f|@$+40w%uX&Rri4p^ZfWeGrRBZ%)U{> zIn5aJo0c8;2FoxXMbQ@H*^Nos;4PH>E^RFfI1XlbnPU9$5i{c>&MmU~AAAY_dt}5ln%* zsKJb$!d2J-*(((pFTi(@RWc1Z0Xlb4d-$b9MlcJqcS#b5SkFWD6I#=?kdH=pqUR!; zjx{(};IF0AZzTU2WH0?%C`MO~)y5n!mUNZlHZh;maEH2nTfL4qLmEGaZUtoHTHr(M z8Z+z*T}=Ir)hxMOy%FtbqQ}{=K~G*2b!|L7IN8HIoy()S6)TPTsyp~&gJvfPjc6vU z?scf1&Y`<#wN)*k9Vlx_i+rDY6Fl(qp%~pIknL-MAF*ppcSu|$$9%nZ&UNk3FLsi9 zMJCJXac|qE7Fe;O{v2_Uv^(@x^%Gr#8fas56`3vF!yF~ho)K+v4QCo;3u$o?pE*9p zGjaSR{Ukb|Y6}xd;hYZrlXeb$Puer>By?Omo6Ho?5YXuMiH;Tb46>aRD&Wp^-Rl1% zH!DpdG+nR&&gviSsD{9OGWDR|!(-@yW@v$K7=by^%OTTGJdmth&q3G@`bKDh(@bxm zUXtKPKRRV0y=G6L4{pPCsDb{vXoG4G&GENon=xp|xySQ|VHCgt00000NkvXXu0mjf Dj6N+Q diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index db6550370c8ec3c0bd4412cfb1457036cafb7e69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 347 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+0wn(&ce?|m6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g$s z6uj)|;uxZFK3OAGfk(0(ALpe-FRqC?O=V@w zYzx|U>B*(Gj+*sRxQ|+w7_V>&Up?-yeGGG!Q-}Gq>y1Xo1^L6evdJDSDaZHtM6sN$5w-rZ~)l>8Pfnjhf3(XD{lB&w{*H06;W o>$j!X60A0d^d4E_#K6Rm==fCqy;RA4p!XO&UHx3vIVCg!0NGuBSpWb4 diff --git a/app/src/main/res/drawable-mdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-mdpi/ic_volume_up_dark.png deleted file mode 100644 index 5b8a65b56ddc411bd3b4401fa199a3cd3485dd19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 336 zcmV-W0k8gvP)kdg0003ONkl9{Sp?YhO4iM5 zx&f(!yugMR5Y|dgrAY1M0oKFcjhx+;qG#ndpz4_+V989lastldz?vQ-K*N>gtebU< zPnDm*ms-)A|NSWM3c7A_s&300009EdB*C_&H#+81y7c7~Vh@F)8G2q$Fif zCf@b=>Z*I+bI!faeUek3?&+QT{eHgpd(Jy5@0W)P%I`Q%4ev}r2b94h?7_5B>N+Ui zC?I(o3Fe>{-ryCAp%AWM0?DCCq7{(5k-%*zhXgFcJygIj#Nh$Dk(}xzCcz>>BX9sc zBzgHtg1m(JCbYv6Yoj)TBZ8V>kOXU)36Ko%+bSHh-fSbNCrB#2B!O(KZy@M$TX@cT z!;b~J08g7bneRY&0aaBJ&_RCfvSH%#)X}&;C$qMx6QXtp#ES!McPm@rTOF`(d@t8 wznG#$G1FBccl?Z{Bqf`VU{@=MP`Nhr11({f8MFXb9RL6T07*qoM6N<$f>Z&_H2?qr diff --git a/app/src/main/res/drawable-mdpi/timer00.png b/app/src/main/res/drawable-mdpi/timer00.png deleted file mode 100644 index 0cfff1ec1effba07d86f6d5ca12c88223855f2d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 233 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k#^NA%Cx&(BWL^R}i#=T&Lo`I! zP735YCL5~&Gvdco@$d@miV5G*IX*D=5&+2 zpkC?urYoWD-*oQSw5_+aD7w8kN+PQ#bWekMo`d)F)dly}ndQ$uol;zPawc=&;-$vE z%MEpAM5`5CSg^L6byNKXsl#*U@2^@Y=;rsM@LK-SOE)8<&vDLHna}!GU{i@*jk>nb g$t4pV%>I~$DFs(V3z|(806Le!)78&qol`;+06qU%jQ{`u diff --git a/app/src/main/res/drawable-mdpi/timer05.png b/app/src/main/res/drawable-mdpi/timer05.png deleted file mode 100644 index 4e4502c6dbaf1ee6a8fff76f55092964f0cc859e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 268 zcmV+n0rUQeP)Px##z{m$R45glk@ST|RraNn0(810M!3Nlc2JkPF(S6mTZ51(37ux%_L{@1pA3-oq9p>8?4uGg?Z7n2I8y;K8nbN^@jNH z`hPjce7*F$8+F5_Nq&yiH&Hi?wTLG8hs%R7=g)Vtlk%L&b#hV7i2BQ+7{36hx4Qg$ S2l$8p0000Px#%}GQ-R45glkv$5+KoEtqVhGp>0l~svPzcE>Jcb8I(GM8%32fjDH<&vyiJY4_ znMvsHK||$;{tEhx&Xo|&NNQNX+7)v@xxo$|aDb`Pk?4RX9b#R>9Zs-RkEnAE<5U=O zW6X=@mUs=d|07Nvxa<7pA`P_K+s|v25jwq76-|91eK>a!)aCs7PMU_+yXoczpKHs} Z`3*tHxL+?Bmlgm3002ovPDHLkV1i#*c546t diff --git a/app/src/main/res/drawable-mdpi/timer15.png b/app/src/main/res/drawable-mdpi/timer15.png deleted file mode 100644 index 0f2a68de401560530d6ad7aba3a61086b6a7fe46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 258 zcmV+d0sa1oP)Px#yh%hsR45gVk}(d#FbqZO3MmT*K-__eMEa{u=uA@|>*l7o~Kl$-8-c_W%F@07*qo IM6N<$g4D8XqyPW_ diff --git a/app/src/main/res/drawable-mdpi/timer20.png b/app/src/main/res/drawable-mdpi/timer20.png deleted file mode 100644 index 8d5f3b58d81d5d1210471dd079ac608be34d5b1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 281 zcmV+!0p|XRP)Px#(@8`@R45gNl0OQko5nPS2EMS7g5a^r^7!O56Zb0&+83t}0$sXy?+H;>T9|I~ fG-|JNYCHb{qU+Je7?#uV00000NkvXXu0mjfJq>jx diff --git a/app/src/main/res/drawable-mdpi/timer25.png b/app/src/main/res/drawable-mdpi/timer25.png deleted file mode 100644 index 9527b342085466cb6f8dc5c7655b6b5014f373c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 293 zcmV+=0owkFP)Px#-$_J4R45g7l06E-KoCZ=LI`M~h=Mmr7m`M=5NmrcVC4mJ01JCBl0vi+6>J2L zA!6eJ;+wd$1d}X0=9}Nmu<<;5ilPW05f_`m3r?^Aqjk;_j9_OQ47v+*IKvJ4Widh~ zN3OF$kl(-&e7Qb>a&kkh4wW1CuxzWT#VM9ua$T^t6v<(=3x5QkPBo~S#0-7G`WnOb zpf=d$Db8W70dxx2)}={?@Px56VGpp9?F0PK81v9+YRPTU@+ogfVG6%P^8evv*Z}0k r?=aEY(h`sdjK#squaQ|Dquugfn5)k_CRgEm00000NkvXXu0mjfam{$^ diff --git a/app/src/main/res/drawable-mdpi/timer30.png b/app/src/main/res/drawable-mdpi/timer30.png deleted file mode 100644 index 038d94a9e5c5b20a51ee4ed59a63a3cddfb5a5ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 287 zcmV+)0pR|LP)Px#*-1n}R45gVk}(RxKoCWW&=TO{(ja$%E5Hu0oKDS3nVk&|rWz(85 zW)>uVg&h)SEmLV}ipQ|lNLN_E*N280MN>SOW)<^#!i9vtIIFC4mgDRDccE|3--e_v lu&oHIrS)r^U<8RPegP~yry^lJiyQy|002ovPDHLkV1li{b3On7 diff --git a/app/src/main/res/drawable-mdpi/timer35.png b/app/src/main/res/drawable-mdpi/timer35.png deleted file mode 100644 index 2d3d3f7de41fa9f36ff4719596ee0a3d96375c03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 298 zcmV+_0oDGAP)Px#H$d9GK3MfcgT?P?OJxpiVCF8VSz^A|w0@Fw6B){Xo}lpwO~&iP5_7tdtZyw3K4M zPPx#?@2^KR45g7(K`-;KoEdo*GNpXAn}n{+1e4Qyn@=&a{((aV6TNer5CWIFj^TB zV`JqB?0i(%<3GR>L^S!_VPrfO%#Tn!}2&?%5i2;0a z8^%_?M%+ZQFhvt<+%bnyXqGpbB9LGRxpNQQVxxYhCfDoKNL+ElODXlx4SJ+WUSkQ_ zbsOhN_He--WAxF6+>BhOe1qh}#WVOLVPJ+6-U#8tMRukS1w80s-ga_-00000NkvXX Hu0mjf@8o}* diff --git a/app/src/main/res/drawable-mdpi/timer45.png b/app/src/main/res/drawable-mdpi/timer45.png deleted file mode 100644 index a5b2b454f0d683f399690c713bb4b0dd06374315..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 274 zcmV+t0qy>YP)Px#%t=H+R45gVkvk58Komt8Vq#%yp)P_1x1p<`u(7l^7P<)cA+f+h5?X0ogbQ#1 zcrFfuOf)#j$-K|KkIW+o?5V112(eP`Ej-~2D+n8%yD3I+v<5>wh9%^1g=sA&(B#ND zy?~y~2gn*jOj1wwv@WF|LIH^@_63L+km_^0Z(?Iir;bi`Ky`ai0?@CXm9zqPVmB Y7e)NZ-oyh``v3p{07*qoM6N<$f^G?I@c;k- diff --git a/app/src/main/res/drawable-mdpi/timer50.png b/app/src/main/res/drawable-mdpi/timer50.png deleted file mode 100644 index ac0dfc30a6b63501adaab35dfd6286292dec2f87..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 313 zcmV-90mlA`P)Px#^GQTOR45f?kvk8;U=)UZlEx@*gT=@ukw}boiODGGXtMYNVm4UCA2CQwh|4Gj zzd>S9Mp4g8eQB%7lXHE~X-^Z0;PHJw1vQ1(A>3gHJ#c<~){?BkED#v{BDCQIS15m) zaUA$ew(5{WEeTt=fzkRtJYfcfP_K2#OC-Tm0;#(&feQ@a@14nwz>~m(jK*OrFo!c_ z?TT-$PVS{9U85K)Ho~!ddh+T`;vXUM(Z-m)nqp6rcqL^A*0EwFq^>hBz?DG30mkwp zok0-Pme}txaMGZJrf6dhO3;EeXka=rjD&um3%ZgH@qi6zfZhBC7ZDO1N8|fJ00000 LNkvXXu0mjfqQikZ diff --git a/app/src/main/res/drawable-mdpi/timer55.png b/app/src/main/res/drawable-mdpi/timer55.png deleted file mode 100644 index dc887764a425b38a52c02c645f1cdbae9aa26e90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 326 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 IM6N<$f@OpV-2eap diff --git a/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png deleted file mode 100644 index 548f6638cda6d8de8e7daa4c63c3730fadcc6622..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 268 zcmV+n0rUQeP)9EVNdSAfCblXfIyFhye?=vr?=+g@k;tNtYRjI8T*q z@y;ykj41=OvBw=NRCMw^+z{KMs+Q*nojmPCM__`ijp$ooj;ysrxQRjXwSey-k&p1g zF!@`00}c{-eC3ab5ERRIaZO`yjFZzDVn@{E^4Sq#(R_dizGwQ}Q?z8a=PgE#M7QTH z`i{i5rx5iY&Y!gN4a-~J@_&&hoI$<|I<6?C?MW$5N_kSslTx0P@|2S|rUX6_C#Vvd S#;?o(0000Px%QAtEWR7efYm%VNjF%*Rvkf_qouC_y>L3sq807dB_+E<7oDoUhzjzn}+DMBbn zo0p(TL5FDQNXL(s?;CuHXLe@Rrhr`OtnKS_?>(NeJl^KcjIGz}CDZ%hDR>AL;4b(A zK7iLv)4ZkaY=ZCHXYdic2k(gaL|bkoUVvlp15DW^6A#QvTf&b#<}|m9+ino|z*nGs z1E*jG9)X<%`}fU8Tgo?wxy-3qV{cXwgd^}1XlKCJG-=FN@l9mTl$)ljbu?OlNmr^E z@p6=uIcxb@XxFtys%5?Ylftn8@tR7+RPl#mVoXg%Y1Y2kQw&b@VrM~~YvEP#hq;K~ z4WBiEcGCIiEP*@U%{IGd45;Nt;b@6J0M={u(YmLst#9d>;yH5SR&29c{-1ml zJYet!Sg*5G^PGXceHC5+ZS|Wn{|m2*AK8eDCu6NP{I-l&I{@NsMv zGbU*2mHCvko+!pee0W7-lAzuzo7|)%%|$&)!KHi`qP51&8JymxKJ3Ey@QnqTFwMVm zo_;Ccuy!ox&wKP|lMf|iN$NyY1PqyN7vY$nP*j=h+xU^doN{k2WKo#5$@&{zg8up6pkHs?IG2bJM<@UQ002ovPDHLkV1oEQK#c$Z diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png deleted file mode 100644 index af79508abf145eefd645d8144207d8e172fca3d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 330 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=7;{#hZF={|7S2;RxN3{!C1CH zHo(csKz0V-7G`e`&S?#@D(?Bae5MIejqrDVS0_Yt<>m|I>{s_fPJ=QLo1{(c!#k7ytd-G!@qWOT6Z-H`o&Oad*r8 zWjzOdSY`h>8!XSh9V`)_)XRDG#BM3mv@2>&dL9!px6foWJu&6M)w6Ri|1lBhtbf7E YS6{sOc5&fppjR0@UHx3vIVCg!0QYx(AOHXW diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png deleted file mode 100644 index 74b8694dd4b155d7ccf7771669f007fc1228141e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 339 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=L*#EUlGr%XN&Fp;4 z^B*-CZ#%8yi+qg@H6^~AOt08rB7FW%M#ti0GpmgsGLP~fp1xIV&V$CJV?r+{WM$u9 zHsy{ouhhhI&qZPc-Hxrg)seSznZ&PSZkID09z}?XoIewHv1azwbEaK;r6gBRki7G+ h=;brn?DYTb=J5{BMw9NHegX6|gQu&X%Q~loCID5Gh8q9? diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png deleted file mode 100644 index 094d8b34cea107ab71ae47f31829f6566bf9eefa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 359 zcmV-t0hs=YP)8jrB1&Cigdx{YIZvop1IIiJA!arfO_r_TbF?$dy>;Xn$|zmjdzzASt2ygpgQlzE z=;nCtXbb}1-U{s+$ats20^i+@s9$N$42@E6&!VbRs_5&^2a)te^RUHm=RY`8m?&p; z{p9?Bkx0?J#S5w{$yktDAoXq5uxP(U>DrSSP5OKM4sR#mK^?n_Rc!zO002ovPDHLk FV1nE)lz0FD diff --git a/app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index d508aa948f87eaa850540b6551d0a4690cadfb0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 653 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2U<&bcaSW-L^JezluG(5v7xuPK3`QkRr)3W_UPt~K8Jz>CVlq*q{kCq zv~ESw*|<-J7M`C(rtG^@eNsBrdiH|(t(&brE1hj{w#!Nj$UMyYUNS`J(5A?Odv2va zwAtXsGH@{k2s9ws^guIX4_EvqDf#$n1IddlTUP(%pY*$?FYLzStVfro)>tmvC8RY& zbm4l{|KeZFOP1Aq{p0z`y-2*RasA<^Hz(TZPcr$sAgE~XweyC!RDRO=0IP1dWXaqAOZH59qhhZX!(260a>*Gj z`SQXC8kSE#7p*zDV|}vS{kgrlg*q%-r-X$1nTT9*3M#d{7%^eFin!&*CIx=ayrLvk zM^n|Er&emNXj3V-+{zm1m8Eptc|z-ww4yyMnJT7JA8TfGE_qh8hxP2lE3I?HnwELI za`V%6^awI9isIU(wo=-1Eof{Hz}&iv9GCL^R`#BJCf=*C8ePb43FEEk9O*gb_s=Zo$&{A5mt;OE z`NeOs+w4bmm&JDP{V~5%FO0FqgBcXX7;*7If`Q@x|H(bCy!T3r*aMRpgQu&X%Q~lo FCIBrU6K?goEX>eoHpqUENR=T8I>E1=~BVhgmq z0HO%?tOd+$968GNcxmb)XbIxXT9u!Ihp?*z`a>i zQrFPR0VHbwE+crJ{IDbkFr%5-G2mJTf(zruI#!1|01N>SGg*#G8qQ0;^S_ym0>?A{ zLy{g99iymU9RR#kec9`nm-~82-tUFX%v=}70dI?r`LgJ(RjJhhz$d;gHOt$Qj+C9X zncV^or{cyXZ74fVo`0nPlK%e;EJ!{0grw_v6UqK>X5Iqc3LWzuCV@M^6-hrr zpB*HEW;P#q3~Ud5aD{O%fIX6ahKv~sV8lOaLLOLC%!s5*A!COE@Q)KO`+l)WNkvYZ z{+Vwm0Pn%h_Nn5L+N>K#-mV1yvw| zzK?hcd<0eiy9(YKb(NAPX~0$Bbb9}ud{J0t00F9Edsa@0w;`g%O1)cQ3hNCWycnUHx!03p-{DJE9H kAid3yTw(=;P#dI}KcY!uP&c@g>;M1&07*qoM6N<$f?Te9CIA2c diff --git a/app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png deleted file mode 100644 index 8eedc8a387dbf13708e118fbd0e484d28842ac40..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 247 zcmV-U0NB*Z>1M22^#xNf|KC7teBlI)C{A!Rz(4IY8inL#F&M0Lt=F x0B!jQfV%t^fWG`1K%)FNfJ}M#rQQGo$_EQs*g-jx_gVk|002ovPDHLkV1jc_U_t-@ diff --git a/app/src/main/res/drawable-xhdpi/ic_timer.png b/app/src/main/res/drawable-xhdpi/ic_timer.png deleted file mode 100644 index eacdf73a070c816306b6bb71e96fa0fbc5392159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1414 zcmV;11$p|3P)Px)KuJVFRA>dwnq7!hRTRg&nogOM1J>k6#w3CUkqhB8HIgd z4MRn}gcNDM)^kr020=Iw2x3Nwls!ZW^)R3XGfiQZAC)!z{*(9Kb^rI=d-px(PIngk zoxRpx>%Z5&XP^CbEL}6pGCe(go+hTi1egThfy3ZvsZ_ea-?=Sx;BSy6e_%WUo(7Bl zw_ecLP2df%B&AXO8g+oGiu^?BHLx^=GnWtK^T8-M4j^yzCV|VJ7#^#SO0W<(2rfG#`fq0Y z9KGU&2Pv}uMIw}(k>&Je0VJf{(`=r>5LKI#o$K} z<%354dF_zWO>Vc(jjMo8_C=2H!W<#x}id8;tNX(^J*` zIF1MB1(Xj2y%WWcjbx)bu-=T{nx3R`zP*g&i{`wAUu=unim0b7c%6pODSIsUn^RjV zTt|%WG`gNfVS(pCl{)aL84Ilg#?@(c zlM$ZKOME@h#wf}vbL_fGa#~-aX!h)%#}?oc*`2Tqcbh&J)F&*7L*%aO`kC$U&e%1| znp%4;pld3*EC7C>8LT8kG^gR`aE*HqnZ%cGws@-2<7zI&&Mp@bA-1UwQ0KcEz zSCP8a zi`&mz)1*#Wdw`~G7mb499JJLC2=qlp<1b6JIvxPN&8-qNC=s&?yb1h3g=t3g9=7}{ zlQhReq?hL%u(?4#GM@z6n(JCdI1O~wqOb6R4oW&}XuGZ*Z?kjq81*~Z>-b>{a{-|v z+kqC6*%B?JvpP{|%q|7`Sl!8vXdkAnO!dmVzz>}iN@Lm~4uX$>-)vxNDLR831KJ5M zOlma4Dd`=^h^sM?fG)%IWo84=IKLI>i((JZM>+ks8ja_1p!Yzj;W6 UFZy1P=>Px#07*qoM6N<$f}k|0rvLx| diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 3a074ee278228ca90c7a1a988878134456466468..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 543 zcmV+)0^t3LP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00EatL_t(Y$L-d=N<%Re!0}vAQ1l7>0tdm-(dkk*!Nr%T z)>Zf`=n@t8x;QvUL2z|&s5mHHTy+p1Kzsrbp+)a;Xxf|HTudr%N`@vS{AkWOoKuDR zou&Q;l#qUVc`Ob}I3c6@&E*bFR!KroN`{nDd*)LRgnJf4Pg^t8nsa3zF}qJgeXt!0 zwoe@(eQ1JxUJX_cWo_M|hPk#Ng<_H$Lv4l9Uo+G?3dJbb4x||3+&pkGR7XCg3e>F$ zdlD+hjRMvEw#AIuP4R{xtd>C)w6z`)gp8WS3om`@5J6~`LD~e-T{a~MEwi5{L+CIb z3)xgRgG=W0;G7~LA!EbJ;E}n1iXbM6k&sJWIZ@gf46>~vmog&(URB6~F#*y}* zd7hoRik@rQf@Z1H_DVE*Ke2#}iWcj-{)J*WWfJLg;)RA_(-IKW-LB6YZyba`+B z{aoc+q6E3T2oTuKBf1rwPVnq?fW&!L2w3BAlHJPyiQ}{g_&|Sx-Rl6Iz#0K}66{_F zXj~+q&EE2t7Xgei>=d3ra6iHF>W64qf8N={Jl-nxvX|EZ&|%C8+<;)*^~Tod0NCJ+ zieJQ=vj~6|`^3aaybdE40q|5zJOaT5n}DKwr}VLech4pOZi<=HcrUC1o{O1Ncpt3- zK8u;R@D{8BR>jQAct5NHR>aIv`+!ek<^o#eE=0EXwj|1 z6<0oX($oPQs)eaFu=>6msDRkmiC(N753M3I>G*;(*CN}{<7Nix<1RY>?c1z V`ZWr76#xJL002ovPDHLkV1kTN6VdS&B)rKoXHesZo@Lh1p1~CW@G{nGFjG3mZxeDWVh$3v3Xv zQK-qnSLECHzJ7n5Tdym|HZ=`L}o# zP^nbLGay~~DttaZBOaiqeBQw?;r&q*ee^YOUIajV1cWx?$MLZ_`v?Ozj_~nRrZ<+T`cp(9hA49eK_^cEXzc}_iK0b=ikts1xI5r0npIt>1 z6>tpTtqJgN{62#BLhPN+A#?&CyYQX(=tTTU;x-2t0I7L^>W^vK2Yz%wXfTZsIqJkK zye)Bl5jodgL^yWdE+?w64w>0YUjyY7fb*orU3Q~ug>cV-#a03 zw;mGY&m`^IT+Pkf(fb6KRub88bLb%m@b!-a4j8t*%k`zHHsbLyIl4af6aeF%aNLv`zMYUYXh->ym~&* z>xMWjZ=F<+fHxFs_sPtnmhLZ;5uuKxT1^3relJ;jEC3r3+J}H1R{>v0%zHp-1ZlJ8 zAOf_0a_WHAI88SMWX_blDO3vZ(Y~?+K1Q)fjj{s{qo<9l!?;$PS`KW-^+2zOdut2b z0o^sHCZCPDDe6J~1#Z2b3N2tRfcMgdIer$_UXYcA7SIZy?%+cV0ooJFT-Ri&3M!x$ zma;1eiG!!C*b^LDfcSL*dd4Km%vsOx zv$o_F-wXl5y@_AOwd=M5U+hA@nF3t-4#{g)2TB3e5hy$0Z7w(#`ma0ikU^X6IB>qw z9@BxemxzDs=RMRfBj&0vCXRL1B0#URQmvkyx!YuRI)WEXyFq~^wf0DJ1&BesL#K6& y-fe}j0YFYe+Px$7)eAyR7efgR?!Z^APii5@F%tee%vK<$^1uO@D3}o+OVPHL6XLDT(1QN*mV^x zgm9--gxFMwIM@vHIRpLJ6y^bv>vFv4gFHs15SE-MSWwta@Jo&teFXHd2{zNik7O1Us09d|Bx|t z9<&8kJZO7nL=DmMi4S1cRgnS;4z$dx!eOA@WmhPu%@Af|J~&o3aO)o&SuXs1JefS9 ws}qm@>&YCg0!$1+4%Y6`H?smn((1o04^xwBwTz(`GXMYp07*qoM6N<$g3Ke6l>h($ diff --git a/app/src/main/res/drawable-xhdpi/timer05.png b/app/src/main/res/drawable-xhdpi/timer05.png deleted file mode 100644 index 5536951ed19a95d74f370bc16e18530993d9caa6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 473 zcmV;~0Ve*5P)Px$lSxEDR7ef&mN8BPF%U(wNJNx0G$fKOQBZOP3L2;pSKts-T!5M?Qa}k32cSlr zAUB|+fC4ItAhf){vK>C3Z!{`}o_c4yNJDxz)MC34{vEWiVpHBIxxS{@h~ za1CtS#c?V=Af?@owb>l;#5v7lA>7bmhxG*9f={5B#oBC+cm$?koD)*t2a0!KbK2?} zhHGE}6lYlj1O{kZMp$aVMfGM*c7pdU#w4P+)kw+WurKpe+Xj;}fv z&%i@ia09Y??ieGwM@_e@7#1`fIn6oa;iXS6ug+1Ug{0;L(jS2JK6CNZS0~5`|7&3f*fYIob5Qi6 zg~mi859eh=8KP=bgvLaIS8`Ms3RS}{;oVXA{2X?D;y>fvRs8zF(FLH@o=aOi>eZy} zQY1f_@tvX3Px$mq|oHR7ef&mcL5FP!z@+!Q$prmkP-sI{FAMF3uKw00(DhUqN(magag{)I}T| z9GrXv@dex!i=zfL{yw=ko)0<4)Hv@tY#(5$&UoL7|9DYV`yycu5Spoo1Px$Zb?KzR7efQl|fF!Fc3v!MB7C<0WC_TmSeDI$(}R728pY%t0xhJG~3O^`Eydysv>Nmy9b6*s3 zoYp&|sMV3k5=nya3}mNU(X4tCGgp34PbN~$n-BB%xAtipN5{Vf9gci)6+}w&?c3<& z&x!7DwWwICWeTt9kbY{OUu-06i?U^(%yzhAa&!yJMqzxVPjU*;kA7H8p8kQ#t>1G* e<|166%>M?Qp@8t+lT76R0000Px$$Vo&&R7efImQ5=}Q5eVPDq>|NF;jOonw9tf%Erc4_z1EhHa0$jY%C--H8G~F zM7EM_)vRqSl@d}@#>@EquQPYf=)R7ZfBibodCv3V9L~9%ObRs`jXL2k@Bme~fU@H_ zw~Wa}As6-_Z)Y(N(ko8I4%xAhHo-+xsbUA>>oJTnF2NC~f%F7(c6_K!U=~V{3k@<0 z#HiUNNFT5qmy>#(q7JNqq$V?MMR2&FW68EHq@0ky0n#%J`{_*f&5bD{QY*_)HzpYL~t6i}tRmHSt#su1teMhHccn&2Wj5 z?10!Un2w5Nz0cIFrD{OZlu6WZ3WcCX_?r#xgVsZRBzD6JJc6X%Z$iJF#C!y#YulgL zCUF3E;2oqpSOD*L$~X_wN#deGffN{nb5$~`GETywnJ;>IOmxu>preaM-YnceQ`(tc z*{`PuL+`oNy52!Qa)giEURZ}3NZWom6BmB`*r_0ObQ)zC^c(!ULH9HtoE-7`I3<7T z3D$8*2lnomd!Nj2KE1+H(e_8$1iKLW(YKR{)0r#Qwf!C06w%Gqzb=XO33DAb|K#up Qr~m)}07*qoM6N<$f?z-Kvj6}9 diff --git a/app/src/main/res/drawable-xhdpi/timer25.png b/app/src/main/res/drawable-xhdpi/timer25.png deleted file mode 100644 index edf88b9efc64bee6d59bff480f87a00e54379649..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmV+*0^j|KP)Px$+DSw~R7ee_mQO1MQ5eQu<;I3mC}eICW#b#Lv9U$iE0(&}K7v?SDJg`M{7F_e zK7uT5WJ_cvZkOZtOlLUb-ZOW`ed^aa^S;mfo@wTuGdI_eLB^O8_A)%fEnLBVE|+^^ zOfMP*um=UVitB1B5!{6?*PA%Uylp!jxJF`m%K+mnoPrjRUSP)cd*>K?Ul&cG%|@cNiEm-l08OAk$Gn zVH-SxH0>_J^+_0Yj~xO5WIF8dFMxCp&02(U9i%7dbQjJ%r-Am+4M;1n8qSRQVw_5B zh#}Pn2j3V-df}4B`NZZ*V$$Q1s(v!v26&>cnLe4Z_u=|tbBqCaKl-j2-ym#(cjLMK i#5qm$``2HW)cOWVg-{4VLA)IR0000P)Px$qDe$SR7efAmc2>?K@i99L=p>MK%-pn>k)hg1raOpA%czQs|Xfi5d4Bz*x1?F z+1qHXh=m|QkOWn+vWByO(M(~(^2^o<2~HKS(>KL>JJ+z zl)*97iy2(I>uL2XW1Y2+c9T027Mp0inM_7X<%#?~WfMyOZ zLF^m0eL261Q+O9-xrDWWA~-UTWvLAWA}`3_1F?7LKRp++p<>O*nnQAG*yTFNgE;GO z3jMBmG;TtTdFU#$aoK@til2j6V=OQyR(7QctFehedhUF&cmKvbCQR&$QVGfq5K(hd z8?>XANRv!Jk6bd&LG@>Mo>xthDV@JWJwc&>Z@R34hyQA@t_`kxBWoM zXV6g**uZ&IWF&hJJ6{9h?!(TvV>Ujj0kIu$AJ?{k9T7S|IIafxWdB+klLif2r*uSA z_Q}*bW`W5j$S3ntoo|dQrA3PYgc-=j*R7v<60000Px%5lKWrR7ef2mOV=xQ51%E1SK|FgdlN)yAWbXA@~boV-qkRBr364qofT%`~kMwEU5&=d=L=U#7v%d_s-0nkKH>X`@+MVd+vMAot>R~cfC>p z`M!UNy$L_y5cXli^Sl$T`9-4&yoah?#W+iUNXmQL_I8aiubEC;wvpU`VS?*(*a0md z{eoHB-!aBmhIP2p(PSp@Xt4v3{=o}ZP2qW(daw+V7I_kDLZSkCEalil!2$KJK>7`% zVL5{j%A)l!d5wU2eUe=@LQyXURoDXOGk2{^k4cdJLjE_))!3e=@fWRtmip|89>%Mt z*TWw6j9PK$4_f!{`^P1MOEVaVEa?bOKTM}*(iwb%rsqYG1!|_~O}b?~gKx-qb!NGR z`4;>%MUPw+`0g6-HfV2>r zy9D@xH|?Cb`@xCfB}j+h-0^spWHyQ)*U6j0q~~~H%u~sEdyHoc_Ka6A!=gQ(8DrY8 z3bu*o;2vm)igB2SIs_-dcjCFshRGzx5qJy1?=4|RX@PezlI)|gmz4~16zPk43^One lH(?MOa0J2MmNVi^{RcBPx%14%?dR7ee_md`5%Q5c4eA~uvlA@_!ejsJj@jV;20m12vvKS3<4loUd@^P3| zz_obMC(6#40^HxQ@diXaea8#{7Qk7-KVRLW|Yp zCGfOD7NjqjjjQ=rp2iEB!P9V{3BeTTwKNA332&&M2k8<1dU2yasI-}|XUI;E<6I{e zoKF%+!yd%G^L&wI;Pv|C`VLJNTWMh$XF!@SFRhieI_qWntd3F=th_z9e%HM%XJ9^* z%0f-?4Z&(5k1uDoj+(N#3CMTB2^bCf8buBCmtb_ppf_MD;)WV+g3?^h&Y%;_hNZ*} zj26)=WT8%*pI)o@eQE~lAnA21LRF};$N;rwp5qgb5uP-d9h70rfrDWL9^^!Cun#&OyFnjN4Ya`| zWI#Koq%$CR(AZXou>o{sYJxavTeconj%^afDXil%1q09nIxpVg1uozmGW^?e$2C$v Xssf({Y8@ZF00000NkvXXu0mjfoRb7o diff --git a/app/src/main/res/drawable-xhdpi/timer45.png b/app/src/main/res/drawable-xhdpi/timer45.png deleted file mode 100644 index fff7ec324f3f572cabdd4cadbedf03ab82765189..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 531 zcmV+u0_^>XP)Px$%}GQ-R7ef2l+Q{6Q51)r7S$qJS1L-P5744#NRVn11ieDGiF}p7MI=H0prAJH z+O%um#+FgI3dDj?%D$i6(XqLsccKHI_ug}U=bZ8C94`^Wdc9u9$X{3=;SSC{&wDj~ zw}nC(IDu?1gR{-Pa2~@_P&;;y_{dZ`4GQs1!z>hF8!n*(vNmi6^^tpoNvJ_8Qe*;X zmf;#?->^3+=TC78AAqh)ltzl+$bhaTITDC@Ain~#77V{V2mPS3TFjauIW_$2st$rU zvv3B(u7ha24K;GmHKgNZJE|#u0kYjG$DFvbTa!>s&}Bx%tuRhcy*$XCJU&{8=f@=k zV~hnac((1SIfuUu@4BL{P4r(rHTW>a@_M>S=Gb`A=$dNqU=Aye$u6>0BWkF@y*X^T zWIAfrjjpbLkp|nRA?sKmQbwKdi-z+MWLJ@yjsa>|M@5>zn>t|oj*K{-m>_ai8Q=DAE$QG;s-^!m>k zw3kmYX=KW=Kl$&D2vbl6KbdW$Nh7Uw)Q_DjBx#;+KoJVC0J0`Dpbj^@{m-mc`vDLi V0VD!6d8hyY002ovPDHLkV1m8Px%H%UZ6R7ee-mcL6JK@`VbAx6Py6A?7%5iw$8h*m-{jfECA5{v|UZM97=q_IdE zZ44?WCIrN2ti?Yd*eEE3Ac}~=AC+(#-F&{AnPX1peoP*GZr{B3eebb*v%^MZ0T##c z74i%m!B6-OD^V03axL9x)C>!da;sST><__D(CKOm`iN&iw&)63S3os9gSYSnu0VDR zlde9mkFXKu;7MMQNuX(g1F*JKtmeP+bpK{1=uY0}G$BwAX-MTH0vFV~LprkMB&I=j z3GbjXn+$ZQJp$QYQi`MJeGNBY?HA7Lz}~jsOKSC)L`NAvV<6Lxcm-QvZ3E|d=pbqi zO@pr*ux1uRYJ^`7qi|y4y0aOmD|Has52p554fo8V)onnm7N)@N^9%fOc;M=MKAJv| zWi;S4apSlukGPHey3y#;>(jY0G>?t`Rs*ziWZnU`kbFOUwCh4wbh>-lxdv<}F0X%~ zqL3W@^mfbk%PHJ7#VhRPMIq?{Bi_^_ssrs5(G=3fr=>>gKX7T6cVGyO45lVKU+sYhuxV;Ok@**4)uCZWZL?TIpqX~K<9^SJSSPgf|`k> z^54&(w}hOI2y434lvn5k(KNxTDbler>ZI?x2nNBv-TJdW!w5Wu8mNMH7=~rAM~NPB za-QAqdMAQVNBAsgCloUc&?c$B(Px%S4l)cR7ee-md{HTK^VtfLC8Z|gb5k1BJq}80=sp|LzfO-Of*XH;Nd-V5IyIA z5OwGjgrGM;DvdmO2t5Z1K_Ehq;30WaNT@f|@$+40w%uX&Rri4p^ZfWeGrRBZ%)U{> zIn5aJo0c8;2FoxXMbQ@H*^Nos;4PH>E^RFfI1XlbnPU9$5i{c>&MmU~AAAY_dt}5ln%* zsKJb$!d2J-*(((pFTi(@RWc1Z0Xlb4d-$b9MlcJqcS#b5SkFWD6I#=?kdH=pqUR!; zjx{(};IF0AZzTU2WH0?%C`MO~)y5n!mUNZlHZh;maEH2nTfL4qLmEGaZUtoHTHr(M z8Z+z*T}=Ir)hxMOy%FtbqQ}{=K~G*2b!|L7IN8HIoy()S6)TPTsyp~&gJvfPjc6vU z?scf1&Y`<#wN)*k9Vlx_i+rDY6Fl(qp%~pIknL-MAF*ppcSu|$$9%nZ&UNk3FLsi9 zMJCJXac|qE7Fe;O{v2_Uv^(@x^%Gr#8fas56`3vF!yF~ho)K+v4QCo;3u$o?pE*9p zGjaSR{Ukb|Y6}xd;hYZrlXeb$Puer>By?Omo6Ho?5YXuMiH;Tb46>aRD&Wp^-Rl1% zH!DpdG+nR&&gviSsD{9OGWDR|!(-@yW@v$K7=by^%OTTGJdmth&q3G@`bKDh(@bxm zUXtKPKRRV0y=G6L4{pPCsDb{vXoG4G&GENon=xp|xySQ|VHCgt00000NkvXXu0mjf Dj6N+Q diff --git a/app/src/main/res/drawable-xhdpi/timer60.png b/app/src/main/res/drawable-xhdpi/timer60.png deleted file mode 100644 index 4066835b030da63a674a2e269d477aaa73c1de26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 577 zcmV-H0>1r;P)Px$`$v!sqrpOF_-?4>3A@h{`xTfC^rlBi)_Xn% zn!L|nC02h@=6}?@UXJjwM!xSfFB> z9i3BY<9w6SWFzVsc%zycG`WE9T5T0*8F8r~VbG9kwN&8Bvg7MLsASA{tt&m`(ibh( z$X}sX1AcM4?E*)sR*^3md(rY%ag;@HmTGocLXQbL2SBD~SB+>m!gk%Wqee-=f_#a4 zyI%`Jzf%a|2S-~iY<|iV2l%n5K>i2E%Rtah{M(?1bqaQXK9jnkg68rmCXP%w`Tx0# z`v_6bL=WDRnf5es#Es9~#0sxIN&d4%9d`lvF9+RzT~OVj3p&UHW3GJzG&_J9DYZkN P00000NkvXXu0mjf&?EYr diff --git a/app/src/main/res/drawable-xxhdpi/ic_audio_dark.png b/app/src/main/res/drawable-xxhdpi/ic_audio_dark.png deleted file mode 100644 index 85f999841721bf9c1d473ed2d6f3f10588f59e69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4643 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 DT|S)A diff --git a/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png deleted file mode 100644 index cb43d2814f0651f1b2ab79236c15f12b52ebf658..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 855 zcmV-d1E~CoP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00PfRL_t(o!|m8jOB+EL!0~D0%Rz+_f}g;Gm7>OojijHT z1Y1D}21RX8CCTxB(7RGk1tACT7IG3i*n@(1MZsEc6~!v0(6d%hn;z6>XS1)_-Fhf8 zCo}Wx&z*VZZ58VNih98AA8VtZJSAqBp+ug3TK@%>VxAqYsn(Qhw)sF`R9GYL*ta0H zWsgZ3!eS#FdqQ{Rh~eN^JFC8-yRt$e2$tbT&4UgoGfFRUnt03;o|9vNgPK;S4AzSc z@mqVq6-%TY|E)BixYAmmldT6EG6jF8!!wPZv8F{_+H>|DXuQ=KQ@-a>(e!p{KUljq zRzFGlrW#o`jopbgRjfg)8uc_Vb(5JM*ofAF$xu|4DzDwJMr{gD1*9t4*d3Zz6?52I zEdzs6F%;|a_k=UHFY~G+1f}SbCHspLmXe^QP?SPZkzaf4v3Y5b4oN98^81`UwsTwk zEi|Q29NyM!*rLRXhx6~hrfKrUPrGBeS z>W>jnibc0ct=M*0u7XgHIJ#}W>9r8Q2uUekO2n)^)@QTD1f%pVrrT-r{3`w28HzGC z&t2!8dQu*|7J^c2NYuD9)*!=Lr5KocuiXw}Zb!Lc+ap;$)BqO}m-RZNu7sjG`6Y47 z-Y4P&ry<_TppLCum0idn=NeY5W2`Q;^Ynn3Oj967 h(%6L`zBm4#*k9;84{+Mt}KWF>$?VRFs zsz=18@A-U;wU+fqo0cu9>6`S)@}`Q|o<*sXEuQUnYpb|a*?c8jdF`UQ z=F>Bm^h=zxjh8t!Sv0uzSV6AzgySe2sw;M}BrmXo$GZzww}aBQZ+T-Ac8 zB|Lt!O^jUDd@9NQ_Vo4S7omB6nvIj)E*;L=mNwH~_HEkb)qB$WQu-{P%}9n4Lgi=P zcizprJatB2SkvOE4>D9OqBv2QZI>DDE-L`K|1ZNgT}hst?0cJm!NTC_>gTe~DWM4f DUmK5Z diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png deleted file mode 100644 index 27d3b3e21b17dd6ef6d7696977055d86b5f32a0a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1063 zcmV+?1laqDP)Px&;Ymb6R9Fe!nZHgHQ542qv9N#y8VVE0*zf|{(QF}5*lbC`W7zQqH1HsjNMS(? zVnS$)jft8Nn-vop3lmASAc((jxChVPo!OncdlOww^6j~Q&i&5$c4qJV+18yzySloX zW64vn0v5r8fV=o_0Uz*h!0T44^%dV|19%jB7I2Dx1zy7Q9iQJQqyrAX1!!Vt1U-r? zVJwwLxs+46J2BtLeaM!T%BfnGJ$F)@#ZzFXg87oMBk&Bg!4jAb zP+Ma&mW=i|Jj$V5${E`>15=i#k!Qgy*pHZg(cc2|jYtS0HW$x)$gLW@sm9qx^(YX0 z^s8w<2R*0OFf!t^@c2Bc0qg>As&%k90(9E=BlVm9rYCRG&c#!gOEm%2W~Q3!PfI3u zEE2DU>viOV#&huu$*~@4#Fe&P8PAkB1v+#%)u12SyuYom7!0bdM=hnkdN5K-+4f8+odbq4K=5p~Qi&8=4*As@> zd5SPC(so-Zj`60#YI&F#NmLLvBjR8`B&^9PWJgWwB+hRX3`lDUN!BhbTievr|m?iPBXe?2x8IHCk#~*4})C&`{$JX#ZY_Dy`wB5?I zQ|&>ZmpW!6^FWZY`&qu&``S;XwDEcm0EAIBHoQ=}DA@Cl*lEC)XzM*8gfCh}&^dx8r3_ z5OzCXzXfr#9JTm`sm^2ncvE#|^o$Xog~yYs2H<~Fbjw-?zc4itf>qSMRyotoGr6z0 zoj9GA{x@eDFKOs1KY4#jm^gU^XqWSs0=h%(|NR@uiwgof$k83j#ZXz-0cW^>_Qzi< h7BH+&u@muE?H?4%%3C!%+H?Q_002ovPDHLkV1k~o1D5~* diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png deleted file mode 100644 index 69376c9a20c3502389f23c5bdfaa7774c81bfa96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1|*Ak?@s|zoCO|{#S9FJ79h;%I?XTvD9BhG znu$bx9-wQYaqWtAL1zqjLJmqfnD zf`dPJ?jP0K9vH9xD=K7)8~@&pxe+ruZ>1&OJF)cE^G=?P&3*bU#)~%Z-Ecwb^0nn3 z{bw|${f)9pIi#m>ZK1$y=_4mHBE`3>&HDYZcW$(**#QNSUvmU>&zxn|{@H27@%@?9 l{!~Zvv*u2P@*m|tGH3L%2fxVbJ`D_Q22WQ%mvv4FO#mCMwx$38 diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png deleted file mode 100644 index 3dce4a05ea972d6d59ce32b35cfed0ce4b759655..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 473 zcmV;~0Ve*5P)}+F#Z zF&P^*FW8BXHw2KB_G(U2(A{_d9NDxFOgc^G^${Yai%=oJW*phptX5o;#DiaCt}_@ksmu$4Z4`T!p#O P00000NkvXXu0mjfQvcR6OavXH(-OX0oMuW2CzZcAlC_6yOFUI5<3F^aL0G|E{7va zvSq~pFP@z@fo)->t(I04ji0Q(tiJIe$m(GAn^D6F-Nn0A8##4>E348<@pH$)yiJX6 zH^girCwug_af~WT?kEkaqF_caj}TT{3+7!W8OR}om8v-tG{Pa6%NkuhJqDrXS^SCA zn1>#5?&>#dl?6iW2zi`+Ghx5u@hTo%42)L%JhO7FInd{b{fVE;lmmtFy(e^aDF$Py zTI(@iiwR>I{B{Ro!}54XA4v0)dP$sBI~b2^{E=bW!xKVNt@5Pq0J= zgXtbp2&Y&ld?*NdnULVt9*wqc&lO P00000NkvXXu0mjfwiv}& diff --git a/app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index 30b762d70f1af4a4b843b6175b70264eb87cd714..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1059 zcmeAS@N?(olHy`uVBq!ia0vp^IUvlz1|<8_!p|}=Fu(G2aSW-L^JcDZp0KY#+v&ol zBTc5(>zuxI1aEl2q9Eqb;>gh{yy1affoPsXgu>JFC7H{f9AZ3bJnz-I@;`P#CpYcW zzkK&|$^L(@CHtYZ21tbS~h!>|~;bI?V5PQ(W9 z`kbNpxLjfGmwr?AtAY35os>I%LAU6-(3ViWpc!R9vikS$e#^&`vfS8R+b#<# z`h-uJ*WkW%p6B-Fu#N@`$sH3OuFpKZ@0#+zvhX)I zt{c^)uKj&!YH8T3%Ufc9&wgPp+r{Uw-Ep>wE4z7!!;^2B`*mxkPtB?Q{@5^*`}=<% zzb~`-T-i5FI=djpO;4rls>{Z^<+^iwv^guz-dgsJwKhL>f5n+YWjyP$Ok-Xf_no}h zIQOKQkZ#bWxOE5|J?;_fKbv&n@}u1@ODcQ>o9Bsz zCoDRWJWJwB%FegW*T6#8?ANFC94|3^ky#s9wXMQOvU#4)#21;e{V#zUt%PiK9~+he z&2nF~Jk(}czoY*#k1tocj$bi+aZ;pnp43MU;pTZh6JK=pxvv14+V67y3Rq~(#1}J- z7ub2NT^w$bDrTx4*6!e7LPAHy~iVuEMNz!DnF`R!&7x3 zY{u~;GZ-eP6}jZSmyYrLuU+5)=@^t7z}>z;V=PqkEy@J;u9Q~!A%;k=rh zl^#E>N_XS`O>t&9YvjFZr<~n6qi2QnLycP>e_y)0{bfMdtlq0?FP`jAbKlqYbN(~_ z)(@{4ek@~#Csw6aU9Bw>d8M;r8JTlH1?r29oP<{#Gk^?dUF=$=1EXWlPiIiSmw rAam*UX&v;m3pWMbsgM1E`ThU@)t-*Aw@VI50XYnwu6{1-oD!M~H990%~vHecC}{79;6epFjTXhxP_4V%ob!wE~wc`!dJ;VwUFY&jn2$N6#1 z=C^wY%eqpeaOV4V{-2PzsKYLc)#9%yk37lbG*GysA0aw007iz zt`y&`ANtR!C~xI^ei8`)$dN{I^h*wzEg19YzqdpyCAtEKMa2@TOnx9<4$m$q|roRxU;baCZu@ z4#oz+&%ZKZMZf_^P9cbcS|m*pf`pW+OChPw3bdlUqLPeCMg$s8$k6 zcsK<{pcC5EZ!7ENEl{1>Q>@Ee0ka7ehmW(WpWI!?f`-(FOeVSr3hhaoc+hk9y!<;k z{~Em=d6>IEcd4MC_S6Q%AXPhPDj(mvX|swY1xtb;)^g%80_d5NyC~W&MiN^-)T(8u zReAo=6}BJnWe)H>EYg)EYK|zeNULb6UR+cb%7Jw~MjvHJTMNH2%3?8eIYsA`?GUAe z{iD1R^kOuvU45Wf<`3V3t>AkHSoY}BL(j&Y$C|@Dkeq7q&A}zxcIXbbu}&DsOhm-( zs-MTE_VN~711hSg>X1hoe!cvb;~b==&?31@gtw4<*-Oue>3+$wuH1;$UcJ^E&DaZD zR8Dt)*+cS_w206pMaS*egWn{YIuz0_3hr)VH)2qMlRLuP_glNQTo}TMr*o8 z%j{yW#jgdRLYcNm2C77zCbw@FJ|oy!c9xD<>007?D?~N8iL7`Ew)VUyaOXq@r70E* zV)YI0$x_8rHu5GNwmZ(AXX0O1Q49)ENsS8mlY(I{&l<5Pr6XiyZX*N!}!S)oX4K({OBxr0dxbKAvJW^c|{-9;_CxT{UkpeZ6EapY&bkxm7;JhBA4pAy}AUZMLw)oAEJ*_Stfn(iGjmrvYE& z)DPUa@SIVB5~qpUlo^6{&_A7To1+C-3}&}O@6a*s-16m{sPApf5pWXTJfpSD&(zv& zE_i?*ga&Vx2f|NA6%uK(#qQ62JxFrLP7UDu_zK3=TgZu4b%ZFM@q2*pYva(p0Fzz( z-U8edoP>`8Z4Ij@5x~_)$TEJ5xnnQ9g)wn-`8vgES}>`MKyr zw&Wx%hL7UO9z?(xo;^SnmBsfmnIduDi#?I-?g}O_!3W#9DT-#qXYp}90qb`XUt!>^ za%zFX-nn*b7&|jK*sz`Y3M0qUx{`d;3iFHFRIt2(^JeSOP`#TZ^OjSw>_?7%fVdw? zPQQULaJ;QqLr|)YqRX!bdUS9R$t#c4rZK;eq2y1=8bVdLv&)yVOC{4dG%<<|pW1OX z-qdm&LFP?XW{VpgwrOiNyI!X)$h5~5QJ+t9n-(Q^iBWzXBlLkLLBMcM%%w{+F7L`51nVE<1o& p?BoNgI z`z{H_&&Bh^Y0l&j#rk%4L!LkghEJtRsb10;N zt&>(@1{#B>tDnm{r-UW|tn!6x diff --git a/app/src/main/res/drawable-xxhdpi/ic_timer.png b/app/src/main/res/drawable-xxhdpi/ic_timer.png deleted file mode 100644 index 48adc80b74c02913c23a4b94aaeb919fa6d18751..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2203 zcmV;M2xRw(P)Px-RY^oaRCod9n^}w$MHt7O<;;2@9uN`D0xAfJM~Dha!UBpxMFWD12MB5ugF?_K zMiXB!s1Kqh_~46)4@xv@Vhm{H2ognzpg<5o38)+btB42)vatUC!^}84TVG9g&vftX z@+JS>?(eRzd#b9dt5c^{7&SIF9ww!u;2_uswt{V7Ggt{Wq*AHl{695`0yO|1{f+Q5 z@F?hU%2kO*7b_4dIXc17QZTAj6pBQv!BkKcHDPSS6bSVkt>@@I&^~JH*zA-PP+@if z(SuQgVH>qT>A?K?1xyE(Q794_3^sv$+Re$CQHq3JsRhdBv6_SaQF17Y4Z%Bsm%wr0 zqL-rt!ck|?0}KE|fPOZwfv><}5QhFi`3aDUl1pS80`>v=&HLyG!rqaCI1KTppK>jD z6Z`>!(lQil94^bxB9N|XeZb7#Eq?|lJrchIxE*{MFqi%4T_0CIX}<)}?S3?1LQ-o0 zSHzkW&WSM+tN_JGbEw)_^1<=_K=Y2Uv^18498O8>E>P_5RzEhpfJGWc>#{*Oe$rBe|7=z-ssF^h6&&>Bv=iC+k@CS%^PF!y&i5i4obR0T zj^IbbMjhk@`T9v56Z0CNTrK((LHKzRM)NF>@YT@`bTtsZu9`(}yKLg-k<38gyOW*{ zl>^Rh0M>{l{&=55vfPPM7JyW9k^%*%+vGK6FVfX=1Epwn~}9MlR-EWSoFAQXb5nmxrjwC7-q4v zN$74XDfp9|2_VpuvKE_M^gYbb3sBgoKeL|tHHWHkCX)b@*k1z|ZGqXmtuXI(X=ivh zMa>!eVIIF9MD<&$9XXBm331qvd^&m1F$ z*pCG9QEnaLR)IZAz(Lb<@enR>D2O#br&QtrzBGl{C8rM3v(nimhKOWN+_xP0i1kU@ zAqV;`{w0osZiy7tFv9;NM~p0Mu3;7{o8V)z2^9Q4zWkY~+`? zdd0jAxJZlt(bkG1Bu%W|t|Z|7te=>*$)G+$tiUGRMMqG6$}p&gKh%)L!9HL(t=5&) zY}rMf4WqKM3B0Tz_(IMT9qx&zmklmDr~+AYfJ8b<;$I2$?6V29$WAfhS_NuQfwV5; zqQqAG2!10-aCh>6==0aF%7dW z(t1dXjVwu(n>Omr%OO_*;9a5uX|JF4Vt;s2iMCW6+CCh(=ueobs?6}Z*hug;gWs0e zcN&dZL2XX1TT!WpT_uF~8z}3lqLECCuxXoqUe?3bS0gWB& zYA`%)lR2+RyI?FbloQ*f+9aU_PH6A)z->lxoS`gsl1mQ>lqS6%HOSN63C)r~S)NFSz`P&2SpLhwC-!8Z39XNGp-bMF z>dY6%6G4!Vdi9)hozqDSOkxcM){{A+9L3*f5~>zh-*e1|D-QCZO;%d%3`&}=)L8BD@l!sbPewq^ z9p-?UAdDaF30woT#cv(>73=_d1%5n(j{MiFZ@t~^1@w+iuc7qOo3&jBQ8PLLXC9cv z`m(m$Bu^AdS5(h2l}37=R6$#wNJRa?G@#{zmZse#pcM(-g_T7l)dgtLO{;QAlM18Q z+MQO}M3TCr^z^(G_-3<+OCrt77J+dfmbbuRI@d^7g~YWP^A0cu3<0Hn9CHXCdeONM zETHq<$^Y0W_5$T+PO%iPAJ8OHBh$s8X9gObGzF*vdZVdPOM_q d26Grc{Rgh43ht%39c};s002ovPDHLkV1f)Z9_0W4 diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index df2042188c4767f0bb410503e1753410a2827d66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 754 zcmV004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00L@BL_t(o!|m9=PZU8E2k=F(B9cok5UmLbL4(F%Lqaqd zC?J#+YV_b*OZ_SkFr)q*HYA2XgY}UBC6JI9CH|$_l8|<=%oU7=f^e4%t1^xq{`CcOVU$H!#9?^vGtTk9!{;sqJ6E zeXMXif$$6cvgd5d!9C|L&G=i`Ft~2nLv82s_-w@jwBQ9!nfr-rvJ5hL|nv~m8<;%=8R5Tu+H35dDhptX=}PFpoKlt_w68T zEv3awt`lFw)&;GfrLgTNJw~XY7~^)eeqM28K0QV_Gq^nvPSK{lziV;*SzspDg*^~% z@ct-A4Q7MIxeM$e#|&kKMLF9r-p&H6Sh$OAmgBmzzzSY&H9U5h#ndO`;~~mjm86PM9!!6mN4ZDIN+#vOTwGrMS>WP= z2DlW(pz>h)$AgEH6m!ahwPDSg#8Q;G%7NX(t}|z*sHs(~uo%WC9>O}DyfRkal4;*} z?~)Ya%2^D4K)Y|MB&+*vW$rhO#AasMTzN>l+)%}~@z{8TtK1>(X1nMzi5ctCL=ojE k^)FX=a?ORZCiXvL=Uw#>dK_;-bpQYW07*qoM6N<$f-JOJT>t<8 diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png deleted file mode 100644 index ed4105b0fbe6053d100ea1750bffb70c8a63303e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 797 zcmV+&1LFLNP)7&l)yM)X62l_hM7wCA?Q9W2AHZ@U1c4P~yNX$4aj8XT5e2o1=nJ&5Hrc|J zBtb>e!f=}rky06n_{Zzhwi(}p_t>3t-l2JBJ@fK#&pCI_yuY(0@;?)aL?V$$guF*7 zbb((yXOQ-A1pr``5&9^HD*ym9RM@Rsz(@iXvs|L2RltBO;P8#CBZ* z21J2$-f}>XKm{@jz;{mS5HOG`(8n-~q_ISA@d5@M0e7@=ov+BO&|j>80Y^X{&D`KO z5>@(&6R3bFAdfblBC*V=VgwB9*l^CEct^}}D1QNi#K!Zw#QR}_ZTSiqL~5Gb$rS5( zn5RGmWNMthmr2%hA};|0*#v~qAzVn4H0B{-AiIDtx>>;Gs(%3knFJb0UuyuDS$6sr zFpyE;4c(-#_Y9XIp8^Il3qX~-l#y55&pb9WH2D%Ruqgq6aa!e78N#LCkAQ)H5P%6< z#C3Nti_O^P1nNcly00kL^c$?vu2TT+iFZ00Fx z6@W8B?G~1?Iigo!RIL6Qn~Qn{svH!mKfvamUI92SR6mE!ux5cLLiOX=OlTH(FTNev zOlubSEIx;9erOh05UQ84Sqe>{F(iR$q57_nE}?g!-^7nYe~b=z`y<; zoh)GUg|ZI;L7@)E>sxt=%QfE(j--wajtjVaquG~$px~j!bS0lAU!uRs=ey0?!89(b zbmiITPQq?<3kv$927-d>u!o1Z&0)rnnCDn=fv2QKffjBn3_J-6@`m{iKUGD}1O@$} zKM%3XPaM?~d`%PwUp+*Rmob#|g};;F!ry{I!0~%|{LK_+X^Nkq#E*aq`V1=)i9{li bNF?$HmtzW%BY!4d00000NkvXXu0mjfo5pE9 diff --git a/app/src/main/res/drawable-xxhdpi/icon_cached.png b/app/src/main/res/drawable-xxhdpi/icon_cached.png deleted file mode 100644 index d17e250790fa4d23ccae08e4f4c4998abe57e9bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1437 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 diff --git a/app/src/main/res/drawable-xxhdpi/timer55.png b/app/src/main/res/drawable-xxhdpi/timer55.png deleted file mode 100644 index c820ea2b0ae272a4a574fec2e6be5ec61d319f4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1045 zcmV+w1nT>VP)Px&&q+iEgl<7U+$h?L%0L^d#l8blLEK zn?z@ALRSuGTGp1__RMc z=}X~Y-Qzrz-@uxbfS`iC27Cohj{D-`*hsJn*wwdxhKJ*ZYk{(T#^F)$pE0o`=<$}X zKi7Ga>v=K+!wT>-aA+dec(^kr{MrBqfRigT&MXxYRDoYXBDGzm6?7~xsL+9!QHVQe zI7+xaBPk?6ZJJ!Inv|POY`4)vRZj3*ID3Ks?IWF`!I>R*Ovq0CLXS)jTZ}VOg`-1P zKeLm)k%SN@oW=L+j8%&N-b?A#Rl!WlSW_UV0 zKLvc9?v>ua2qT=bfUjag9U3-<=)=yF814}ed#KY>z|}R!$)q|o;A)h$>4b%@(`L14 ze@Lrr%rshW^ud^F-J#C%)o!4Z|QvH91<>kH79MXa5 zMAwf+n@6-&58T~IJAPF@yY2_R=t1yR1(YYV{KEN!w?cN$qb~X~l1xU=nt_vXlFU`OF8_2S6<1e62&#|yEwKyP`iTq_!d^3V?W91w~@C$Rr) zpqd>2$CRf)3#bDnpaQ4^S{!-^+ymNZt!TJ3Pqp98O&~V{Ez9|eww2r*O(fF8Za=sU z(!Uy0q}<2b9QWtQdh;WB70C|(Jq|X3jX;z0dw?-Y-5_*mKMRf~)Ohj_v;_J$r8ZfL P00000NkvXXu0mjfO|Idw diff --git a/app/src/main/res/drawable-xxhdpi/timer60.png b/app/src/main/res/drawable-xxhdpi/timer60.png deleted file mode 100644 index e4e8756966694662ead7cbf99736db9ab17683e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 912 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 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png deleted file mode 100644 index d0b16705c03b6da077d27d5123fce8e2404c91a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 539 zcmV+$0_6RPP)@~0drDELIAGL9O(c600d`2O+f$vv5yPGB;{e-v zwiQ_ku(7eR@yi{;Nmz#O-&p?+naF$9y}lfxcNs2bSHtwGCXBdLVI^}|$h-t{`fB>=;3|XaEfWc23tYwPEPTV}pWsJ63@KuWi zwbT$3*;tE6zidn-V^TT^f|k1sFjQbg4CV_rtBxmP;6ccL3AA!4YllnV(g903i;j;o dJPzEO<{QK~)TFvn4j2Fc002ovPDHLkV1oGV;dTH3 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png deleted file mode 100644 index 411dc9d502715780984077036bf307170b884d78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~OrP)@~0drDELIAGL9O(c600d`2O+f$vv5yP)p0 z`zSllTGFzYT1TzV=3F$--lOhP``L%)I2wVvwY>FvM}V$!2lTTxN9KS|0l~O@yfGgY zMvk+BIU~n2q;r7TxFlc7aw0Gu-pDNy;N2#W2g**B>Q})W)H&HO-@BNCJk0`m;n}KU z3h}{x4GydhNru~j0(>5MweGD^cc-%K%43&}odz|3+m4?4yeM?7STXw+=0v!1JWtwq z?1dGq@%&JxIA_Zd=dn31Eqi-yXF#&^lAYhh2E{pB*JX43@YqX_oe=E&3jxr!#O{jL zQM+6e=p46;wQVPM=w6t4KpkEL+~Xi8J6LaD2q$#&$e8tR$p0nA{|1rijX)x?Cj`u` zM$T;UEG-ZT@Ap0Ou!gzwh-L<}RfGHN0hTZ)Q-MUJ~9jwy9V!(e=s^OqLAU2=u>BLPY_N{)+hxiEdd52}XuE&Gj!~)>KFtxMOx& iVCi4N3;K)r|Kb}?2IlmRZ(y4M0000@~0drDELIAGL9O(c600d`2O+f$vv5yPLz0@#2N=mzNqv=f9*KsTT^#D|Nlkw34!*EjBr@v6NJUdIuIYn!(3U7R^86>La2hAMq4uSKVA@Ac*N zgLB#fmDd~2DWy(-=kT_+ZL9B$~?lHDlGc!qoo(v2}k z2848WB@RU_(x_KZ!q<^Q9u@inuXFE9NYj=BF?;S<_#e}#ik4+gS>(}Rd{R7G2106P zM9{-Gh#k0Y*!4Ap@bGFOX1^KfQ!%G@5WNUl8-%uSPq>j&^D~P=RCxkd$UmHA)+Ct0g zloi?}6}_J5=Q>UiWHcEzCwK~{c2bW?(kD8h970}X6L{{g6+?+bJZ%mI z>`?BQL3+U3q7Yt}_*x)=EunwD-y!30H|E$D{Spw`!X_}dyp+z{wtAqy_(Y(=q8~Mh z8f2cyrI5n85Umh3Rk}*QCBzrb@cY`f{o?YHy2q18;~z!8{^_v7xBvhE002ovPDHLk FV1fdF`da`1 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index e261f9a870240a3b8e9cf7ad7de7eff6c858ce13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q1xWh(YZ(J6g=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G&@^*J&_}|`tWO>_%)r1c48n{Iv*t)J zFfiWtba4!+xb^nVZLh-)BCHploL(EhuBlZiE5A5TGuwHdv{8(nFK4H1?#@7ohT@r0Bavzm+X|(yM{jzRRz`UNd zZu)@+{^#P>^lQvf&)$5?dCGyw+h?y=_j-6zru>SK>c=PNnqPHXs@TMyX6n*u^Hca} z&7zQbT@$nQ0*(C7`dIaA%w3+9G2c1lKydjgLI03L!If`>y+e)!SL=znha3y8Es$^y zIT3vS-zLA1QxxbU`!f`l?C%$YbYSS(ol;vFIqfT8@w$-!(j m6F5r1u>lWwNCe3$&$l)d7PvYuWD79<89ZJ6T-G@yGywn)eBG%4 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png deleted file mode 100644 index 22f75edfb9bcbc09adbbe9ebe128a9f9a20a08e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2067 zcmZvdYdn;B8^`}M!;EvOOy%4fIYbC+oyH*}!$V3UWJHa1dRRgv?sO2d4iPd~S|Q3% zG7e#GdE_)P*%d-_t4U}W(XOGvdfxAg>vz4mt~cN7_xoJw?rtY!r4*$A0J3B!htoS9 z`hQ7aceI;$VGsb^JFy0t`0r0 zLY@bu2FVe!>;ef}hYnXgD$;RI$0y_WP@G9O92IqShxR%moR*7S39h0-2#5kK=!IVd)v6`R&(tWa>@Aqjwn?jA9WUaP=K07nMgH=V7j4i2=U zHR5d&ArQcm`gLk^4)w1?ooFgkw!y4Hy1}|Zvq8SW|Knyc90vaZYrq2NyQVeqax8Wd zbAMtMz6&3Mm*6&)8|(OG5Ve0fh&&vpM zNlkZ#CesKkUEQ%#`AoYpHUGLSM4x4%W4f1depKh3oE1j!UM zm@az{j&IB6c8+&%zbCQziHP)G8Q~OsLNh_o)UX3KiQJ4a)Y-Jnt;gvVu=y5h|( z+V7x77m@3R}Pv%a?$TWC04zY&YM1dmf#o3qUnOWod~e%vdackn%6&f1DFe;1TD_*3X|t+P$ADeoUo^Rr&KQJd zdClXqciDOb=ljmZ(Vf{(opfa(0b@N>et4md=R@u-3{4IJTO-PUoE=~(eo1f`k*ien zR|H(`H_WEmdjguz@MLbAYRK4R;Rs+yz!lB*G1}TS-hLULJPZ0ckOR(u9QBc$Mlo%XayDBD!0<3oFZjCq@6r#O)OLDt*_81m*03-N$($AqVcf^#a05#N7Zx|hyS(_WAE z7(GlgaXARsa=_;J+}l|&p9WP*my3R{IccJmqb4q_A;h?#Y?J3VW$bJ?GcP}7Wl(CL znIGF~pGDU)8^LPxpV{{5^9a9fL%wl;GTQjh>E{RssG*7=9=#~O{V4<;(j zoiYS-hu&@Ld}zKYOrPF$GH8fRloL)v=a+WTs|Kz}mN<88EBw8bqD9^k=U5gMrAP{y z`m9q=oHA6^v)Qa1o>_`(DVrU?kVFkVo{mLB4i9{u?Xa?tOE`DM3hgLo+b))+4B~Bw z7+>`RD;n_m?Ca%sG00M{`?8qC?e`eHR@FSaL0dyS2(-Au1A483v1ZYubGuixkkMJS zr|N_m+Jyx@X{NPR!Ofap`wn6a)F#qh5^|$j|v~s1|r+ z64gpq0ld@CPFtdEE3XfZ@_~9`ZvHz-peyUgpyEL5y(fRV0rwCj+SC&CRbH%&LqpD( z!p9TGvFcg0$m)xn-k#oD@{2%b-}V^u1U9p&KOLPu9DY>;W;Ju(pIw!+iyIL!G% z3;S4*J>5{B8N>Scq3=?KjfkdE=$HLC$m>Rcl~->kF11dC97g)&DFpMO!3r+M&QHze z?U#?M?cKNYLlm?(6bvJRDw2}L(U6FK_L32Jr0ch?qy%_l&vj`iThEyR?l@3qRe7G1 z0ku+ITd%NS-L!5X5V(7`teE`>`szbG@ffJ~Wj^caj*DC$KRW=V666ZaSW-r_4cm6*I@@4)(7vt z=motJ+}jkfwnE}7kDiv?;n-LEU$SfxU4GfKo~Mrg-nQ=Vj*}EG=mdFj_pTOXUuld6u(a-tP&&lRw8B=M~%MUKL6Bb5Q-a*z=tt3q>BB zTgIM#nd`x!)aAd5wq!9DCJB3i q13wzENc~=5%)rXvzz7PoEs5WIdlOmMf9+}kIn2}5&t;ucLK6VvGn?`N diff --git a/app/src/main/res/drawable-xxxhdpi/ic_timer.png b/app/src/main/res/drawable-xxxhdpi/ic_timer.png deleted file mode 100644 index 4853eb2d9db256421bff8b401c96252f712f5d88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2973 zcmV;O3u5$%P)Px=S4l)cRCodHoqMbuRTaj2Tj&!4v;_o8DX-R60oy7VY}0~TJ(4i(q{N&$E59r>AEJ+uBJ_ z?HHvbLhvwA$=`a1y+DxQX#Lity~V43TQ3p-JEy~ECb7_j8|F}29KR3peq36 zk=MZqT1xpVvo{9|uKN zK*IlScE%+>V{W7U5))Do{Q&!SffJK+9k!b|H<{Ll+U~goed5I@~z- zV_?bmqY{s34B|7uCM$qO`f9d%LysdEyWQ#dujrlH$t&k|1zEQ*JMP>F~P{=$2{8eVLqK z^=#nj+5C1|2iX0+#hUc5q_tbNJ{)gy#37aQQgI`L+V1M{vt$VhsmBz^qf6N8#?U2x zTq@!fD8q3%u;fV;{KK0lf6B5*`ai+pSz{*eVuz0(Wwl>cK4N$q@H{84b8*6$e*G^1 zOLDz-WBeHKy5U5(z_VS%D^!Bx$Bj6YtS)DT*M=T%emkpV%%Sagt`8(^cWw;kXopWG9K5Lep0` z>3Nb*?VBs!5&pj$V%L>Wi-ct9ibegx%+$i7*BD44^5u3CuuQuV>9t!{E`Rf6dUTI@y}<^i~C^>p^d$R`c_W zGNXZyW~y17bX%NKCa*NU;AXXk)Ehif6<{f~3C%p#oH%HEh0J6)?e2T5qHmnS2k%tsj7%1D`LFL=M~QjJ&Q2P?M?>XxMD&Y@oexm(==q0g^y`@_MoRu_2tsrYW zv+`dS98Un-KuFZAJIe^T)MWT*kR~MSbp=>W%r{GA-N6}G!ONWfPKTuA)NXb(Q1tiY zL`}AD7(7Pn3b1B)jW&#wk_^w0;6>0X4{<^d;@SzkhPRS!IetSGU`?2fH_JC?5y7>0 zr{QxPUzpQmq1oYQRlsglfc45hqEMD8jl)+Hp}q*}+V*co1V=524axRH(AEo2sE{?e zbYs(M#XCF?F0YtE8uwO@?W=kSZ#FDy+5ezqD}na0-@bNS6%c(EL|+aA`Y1{T96z9o zsMz4Fm)tG71#L1o)ON{`!MhI0R!{S0Re;rH`mQ=e(xn_`8oZxYGgPbpLbBB`GML<< z1Sn9?NaHkBzzCq>y^vJ?L5&VE*7_8MsYNIuQ4hq>p|q=a5{Wf%P|H!LTGzmkA?F(W z^fljodIm^V9oY2ycd!EbEt-B4tQW>7nmM@?E)88Pq-jFwVa*JKJHZm5ti}RsvhkHo zBPfpsR-aJq7fCb&s(6R8ydH!^Grjx%pgHkyCkJEOl3KIrf<6XfB4u(KuzD2M zn6YDI0kFiHK5qIMXbKdVwZc?oYB%Lk(kc}Vcw18IZ~By3!4%XytFTDdw>5^lc%gW; z!{A1AtT9iQziw8d4Agpj*->7faL0);&23-nFnX`s=m1ruj2>{5lRp2D?t2$xmTJHN&l2+-pcbJe*}p7<%f4~X%2@B;8ioN*L9-Qn?uj))D| zt_os1sSzU^Qx>JTsKTna(j?QBrpp5gs)Q+Fi`|z>Os4}~g`KNs)cYvAw)_vN({FEYj`nP2O2KCk8HhO=6L_d4QBRJ26uvQn7h=nAkW@$A^@RefG5wKVF ziC0t>c1K$TZcP(%2vqB7L_LP5HI3A42X2$V(lh~~tGmG!m4t%h%Ye1S36(w~!ncF* zm5}b>6fSCDECQZ4ThyAO=V6?-eg|HzQxKR5(p;_ydy&`p={sDG3Q)rLG_VkO8y2WG zMJ>W?eLS&afyhMQ={;?-Z`t`3$Jc?vAJ+Id4CwZ`0%VnyWUSW;TF)lG`gLkyiPkmf!R@ww%eT4Zkopy5qD^j-qpBAS$NHHdzTj$FKca|AOSs3O*Y zx{BsREdWs-{{GS_S(5-~gC$@Ws9N+6pedP^469api4n9Wsj34@bBxqiRMv+3-Bm~zJqP$Qkx2B zEeR?_UqjIsux0>_HBV^ZM7C=4>pm@Lotw6LXV7;q5~$t&40sCsg#y>FzIO6Io+sbF TQO}Ms00000NkvXXu0mjfAz+o> diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 359e1aed804a5015a3b53c3b9b1efc1aa8a817df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 921 zcmV;K17`e*P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00R+8L_t(&-tE~tXjDNI2H7SXd~y+NhvlBZ8nH zs6{lpfnX4{ksuao@uQ>~4T{*Po%jGNB?QvinZiO4;{y>?g!qVx7J>*WyKBb6?C#9H z^Ei8FW(jh+)y^v)o4Iq&&S7A)>tQ)42j!p~G)qC97(s95WjBTfbz%$xmCPi8Pq>Ed zm=gx{k0U%tV&-{;Bbet88fh#@Cvh!EK;S(N_<~BPv`9JwVFU>XjANDG9!io*PL3TU z@E!erZyB9*c4my+_FF>|I8WK+=B^T|1k!M0gxuPV!+4}2-9*9XV04p)rT+B%U6@Fb zZuyEX9Mw8@I+U zxVlJF4iLk_1nHh{w6~Lrm~wy^mf(#-I_R5zxJl|}POq>5zoZr3qRTfQ>T{6fPfxH_ zv%*Q`J38v0pxH@k=&0dzsvHWQ<8t*_wV_Nwx1$zN4K~kRU%MQoa9Fs^_sZ z`+9hgiB#{crS{UGH1~^c^ZSDIq-O8aM1=2F{^^@zfOMn}Qg1T78@In{AwX$TQ6k+n ztbofx40k^X-H}7Qu3?F zAU$a)5kQ)w|HKFBNPCsfi=?qX+5t+F)MtIWHg$zaDof+;#{~5fNf+94FbXgt>7g{{ zSX>Z=v=YcfmoRdI|d{hRw$>$_cCQ{ZLcTNIjAZ4#ga1w~&VhfpoXYqwQLGy7* z2^9pAU+solKm`ooUF-RhZ#ju;LCbMGb+bq$`B%8O22|4^rfJ(dMA3~CN(*1H8$A?K zm^vVLfX1)_3=I3+q)??vb9w58@~dONLb}tgnrrIT|A2gn#5&nu~r90 v_2D%RsOY?8z8x~!=XLuWl!J26e+~Kz(C1UUQH)`-00000NkvXXu0mjfIt-p- diff --git a/app/src/main/res/drawable-xxxhdpi/icon_cached.png b/app/src/main/res/drawable-xxxhdpi/icon_cached.png deleted file mode 100644 index fc6a8335472dbb472dec1d83c406230e6d6a8ed6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2118 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 diff --git a/app/src/main/res/drawable-xxxhdpi/timer00.png b/app/src/main/res/drawable-xxxhdpi/timer00.png deleted file mode 100644 index a724af4d9cd3cd32c8b8f7a3e2b6d7b2ee09b396..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 773 zcmV+g1N!`lP)Px%zez+vRA>e5S<7z1Fc5@-sDyd}At62yi93QTQhTEx+8(HiIP{XvP&?iDft_Ht ziIkO=IL_?s+Qi=29t<)`Q4}ZWm(o)vH-lj=Vgz1D?jc0prz$_xB|=iAh!l@me{1!*v6l_{bHmyxhP|6c;%u zYajORdXSHxFVIDZ448^U;1@6;_b7yw&$CQzbin8M52~l!Il5N`8+prR%J? zu|PE{z*CVLrWO=L%sCXs#u)wIVR*>Y&g|$-QkDlt1?(&0fJ9e;h{=vIS)VR_9*FM7 zOu$MW$mAm{ZQp>b-5WvP{eJ*c6L$}!%p)59FAxMdmw}>u;y*AoqB$G)ZMrpMn|A*d zLib*bE`BoZ+jMKjcIh*5oBp0Ymc9r29zYKuyKzBtgxFPx&h)G02RA>e5nmugPKoEuFBqaPvC{a)p(Le)bIz)#kp{6C25Gf5+bfF_ax}>J2 zg_atLf(Et0knZfA~ZwueTq>%ek}nY*)aPw?mDR{%652VC7~_w{SIHCcp-m z3uo!}gd+pnz{*Q@yv3oZ-)Q{_^oorqB#Oy7b*btX?HG=afp@^l{b&wS6A`s0a8}aIcVZ_))Mvn+ zjh=S8`A&ia9+iTB#s0zAevf#IJ4N|hdHH2sY-pvA~QvE(tiA80U0`bgO% z5n`)2jmvJXBEvr4SS2@NEY zmXEA!^7psKRx8dDam$DMEsT$-A06Zxu{G^*FL6VJ{vYviW2kwUID21Q_&TDiU7N4z w?^G*#tK`d+B)-!9Rozc)^tp{hpWGyo-x{Px&u}MThRA>e5nmcF|Q51%EOiV-tNsNSGBO(NBv=YHW5G+zDXsebIh=m9y8_P%{ zh)JE=O3>C?EPNCiOTofM1Rur5#26#Ge*f+axw$iU_A$?55B$4#&OP&=|ITF3y>lmC zB_pfX>outNf=#dv*1!j_0^Wibp67kxyPU=VTG?L$o`W0UKyITT*(jI;gKcju!*d)gfKL`@aJWk2Vk@4G`XQY3*}w5U1qM1IhzN#b z5X=DGQQQ9DNN*63^#NVAkq9k`TyTzoN5HmC9On~}=Xo+>bvOQ5qxi}3nLKqQ%7xeq zV8a?jjpOlHVgghuRc(f119Rv^!zEzfiP!l0oayOQotLTF=m+Z#cCw_`LH^sowwcHb zL}`W%CaK`0`N-g$oQOCF?CPXhJ6uF?YI$IqN@X}O2l3=R3Z9r^toFT-3<6Y-s~{Nh zR46E82f6oyd7#o6WxsLW2kylmovO!6An_#C?s&=Nfvey%h%)_{bw|=&UbHJPE)G$r zfvStkUea+Yj-!x1L>&eXfn6$bHB4)yE5Iv6pereY_JBLUu6Vc__KVNEV4sVlFh8O$ zgO9*vkLfs4m_S!zM4bT7T&nTE17}_57v@LQA+QYe{2OIoab5?zx)#aFS|ph^+%=hX zBBZZOVxzr9TTf>0dTw#)kobcJhnh7xMP^!R^&ip+lh|)R{R#O47$@;fu)8glF+p%>bHFrhp7pGE1$tMYcLmCH1*lPa zpwTRu+Ju&LeA41;a_+@yLM!}{(lt=DX&Y0f$K5IjZ|&nO%|pF85Oj?IZ7`TNXT~(z z^p0Q}%mZEX3Pw4tm`Hf^@arX$7s!KK++v_&FZ?|ok`gXi#bq!P;AlOi**|1qxeAj+xRbn+yXd;ny^rAa1vA7zH zO>X7Lu2e#<8C%Pa@Os2hN&gNrxRu<&y-AZWp}G8 ll~o3yf47lsG{4gS{15WrqwWM0i*Nt{002ovPDHLkV1jL2+4=wg diff --git a/app/src/main/res/drawable-xxxhdpi/timer15.png b/app/src/main/res/drawable-xxxhdpi/timer15.png deleted file mode 100644 index e0fe28c894397e82ed4bb880cc87f1a4aff6e6bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 909 zcmV;819JR{P)Px&M@d9MRA>e5nmub3K@^7XnwSd`BT1ByDosGF#8&Vh*a?E5od|(g$HYd67;F;s zXV}^J2NXoaBA}&}HWCp(qnPORdE+jb%*;;MkJ-By?twQuJ9Fke=gi%mkF!ZSk)+q_ zeTMp2O25tZJ$M6Nr`M-Rl5A0Krx3tlvV+1Ca21R<76ry;z$)m5jM832exQ6GOoTWq zwLv}#mcVx)Sq>5YS1J7W13hklkq~#OHb_@2cfN5yn@2|56c_{JU>;lmm%#(@7KG## zjdOY2rRpJ^1RpJ{8>PtT1kV|8+ryce(64d6EWfuOgbOMA7S}~E=2a|E3C9Gu1HJ-V zeo=9)09{xWvJtQbPKGs$w~7-y20po7oUso*@H_!`JrV3ucd-vHl{1O6dfB(FKs5W! z2N_Y&RH@l!GN{-}!^?#lQZbr4=?? zq_R%;T-s)9aO6?uOnTL9b;|LAN^K8J(NtOcY~vU{qyisiffh|u9y;Au+?98hTvfcFuKY78*RMalcpT;LaiFUZ z^ZkN;D8msLjzIGxz?-fE%{pk)fkru2`IMWBRj#tlM@pqJB|1v#*z2bDsch?^UMWW< z7))7po=sZYnpRmeJ*TYH&x{j*Drjg`Vk`AY31ubOvef>P*ZEYrHK|40NdnOK38g*r zQ;T+|;eFdifS*s#qh6}oQ;y^Vw9n5aox%fbS#UdR1faYK1jL zvrYZqCm=2RnN=e^l@};&+PY*3Wr};UlBZv~9$HPYS|_q^2>uTZsf9f^j6Wg6sA+p3 z5w~o_n<^pKimh#jyB{&Ur2T*xO->O-?Zd?BUtygd`xVj7#PWXM32S$e1PX4BOvm@_byI500000NkvXXu0mjfERv$> diff --git a/app/src/main/res/drawable-xxxhdpi/timer20.png b/app/src/main/res/drawable-xxxhdpi/timer20.png deleted file mode 100644 index a81cc471a425f651bc7e98e66114bc4760592e15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1089 zcmV-H1it%;P)Px&`$e5noUR*Q545}X(|?krJ1;qh-_CuNV}q4(JEX;V2g?jHw6`1izra& zL)*4)8MKLN8}wm9MbJjtv?vNJ%&6?c%6@;H(d(TL-<>z}<_R76J9o}K_kYfrx!$jN zg=HDZ^z`%u)b-#SQ28?$10&!O7%mhF(p9FAz+&od1r~;xH)0aVM&Sw9g9X3->KiWYnzzH6B#n0uBN_ z>1QBL9#YvJ#hpkGVFS>sHf1Og8I2&>2+o4{CZSROM0rnUdnz@~OElW(A2!-Jk|n(g{GR}} z91Hb;m1bCflJXn%hZ>hrNg%iUV7%)X*VTa#P-=N#iuzpYqfNl^A_`*AB&a#Tlq-Q| z)YgJtpcz)u6EMn2K`f_dBAWlB7`4(33KcD22$VcY1=*8nh@}p^0j8uk!6ggJtrd}< zH>0O~&y68xYXaV6CGea3bvcLS=AM9`T1j*+ccaMN`hgWe$5Kn+rIo<8rJg{mHWm|B zzglBuS<)@s?LbR{6(teT7>>2TlyNET1yicyKm2z+kE2}ol#&GPwJS7bD$@k;wAb|J zmG5v(wBMzjoxo(KT?mIyyrAui-uhJ#T$IJUBJAxhleTH_xfW%c znI7+2S)K#-=E)E2I|c1cR@y>1<(cFfiTfqvb)^zc?L=t z+`~Y#HE#<-`4~mM})Dz1FV4p^ynv=^5p1y9Vs<>knV%iN}&4oQ%P*++X zNWe>?q7Jsam^AF_7~cOMI2jteXIwEyi*}GCpka3arrV>=Wx@*AT0D!XJ;FK6J-9hO-w)#4R7yarPzEL9Q8F%MPcX8pJsj zm&fV6JWl_Uz85J0O`AdU%alxfW%sIPe_rE%gqSpwNwfS91ZjGPx(Oi4sRRA>d&noUR*Q545}epXhdeLO@05kYd3s|YF#p+ylSNVJipO&}-~MrpJQ z`c~KN+e8$!Q3!&tqFSWYrl4gJWCfb$M^>-jdA+gw&YO3iGxO$69{4-=%a)m_Jf9_-Wekrf!H4KTV?T!`oV}Kc@K-& zW8kMs;uH0_l??}(DrL5*vN{%^6AkS^9v{<&t)cOhd0tFr8~KARwhjonllW(VwjS58 zc*TM9l5z%JbagIJBO;2x14m5iD%0!63<)&K=YeT5Rpx!R$q1aI0S&d_t0|0fU&b&R z%RxVo48^)LoFZyKOK(!qzaJkdy1#?yr~&eZba#j#3AC&Slfbm&5lHbL66IG+^^_m` zXNzDYg14yz7O1a@K#Kp62!5-Q*jMDAE|QU>D(c>qmcS#Gz@C+sz$=wNoxJJv^i-{! z{->(!ZkQIA-+*j=n5Lb~KF?)t&;jraZ1FrebHJ=>s*t6f{^HBa3k=>yFbo{Kp@Ms^ zLbSmlOI_zBflwYHcu%TevM6cxtlAjB_^pa_%IMB-kwCK6)${hlAW`6J0j9kPZ5CqN za1pVml;9SkA5m>ys@s+G`_K}QKqN83MX#c+lEi8yWVzKQ<@j3F^It`*{4wi^;=G?o z@Vo$~1^I0*Ujh*a%&3%QI2v zMzv3}jz=g98hB5b(J9N`&n{OHsu4d~Eq(x|r7!O35vP6-%90XZ`D}R!OnXixru1f1 z@#!-MQT_%h(;R6CeNj)07ND&&O^B-CEKh`UQ=PE`$}RavQjl#G6x?=Hq^`0+UzZ^$ z)j;1ZW6{e zN_tdnr(qaLz1@x z4!C0ADKJgXHqs}LS8(x?Jw#o?h~?#o37(xm-)-S{OpcxQ2fp(u@otBFgF(p=c*jwz zuCmbD9h84*JY;h4VOsY2m{B;U9kVVGsRDitV8JA&EN|#WCgVtUxYrY?;SB{i{Ka1mtV=GVpvJk@G{!7TDH@n1tES>MWF4VZPO5 k$H3Wh()&4PZh^{w0MZh5Q8y2f*Z=?k07*qoM6N<$f~~zTjsO4v diff --git a/app/src/main/res/drawable-xxxhdpi/timer30.png b/app/src/main/res/drawable-xxxhdpi/timer30.png deleted file mode 100644 index 0815499c68fbf1acaae3e3c9c5df257f7b37913d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1029 zcmV+g1p51lP)Px&zez+vRA>d&n#*exK@i5BWX+07OoET>A$Spd+yqaGCr^qeMK2ogAOR(wO%zd4 z6fX(*X9yk?@uVlwfFdF&c=4bHb|pT7i81(!zlt-YsqLA}^z`oR7JNJXsQS8U9zESP z8?0!MEG#TcL%lkrpW`?QUV~TR@lg;2QKy}pC- zuL3Qn>osXXOWv^2ODZzDW>s!dBTmROaVK&nJXSeIYpf+tXq4rFDKb@hpKX>#Oji;g zz&Fz|$|H5wG?u|Ai00$rI;Dad@aYmTpGB~FZeO_7eU*;+aaQf;?cNC!}Bw*dC2Oa5Nf{Z-{5HpK@s ze0nLd-v_Ui!*pKCR;3J$P;6J6i;KB-x`hs@cpowW&8xP>y|HX)#z3wOjXGPot^i!i zZ)i~Rx~23SfF!$2sozr4>vdCajwa>0OP#U-)6|=KuW1z@0l<+yts*A`18<2fPXVM) zY=r@MOD*ON0DD_%@d@qb$~onY=%AJ-0BjF~?8#jRKVwZ?1Hev_%Z*8|L!9~pN7ew) zm-o*tZ5P)4eBgjA^(Q9nNZh4DPu@QxAWCVProXA2jI+(u4w3C6-&h=p>4lWF9!*<; z%rYN8^4d`13vWVROH^Bzm$!mHWj`XDP{tr46;0$p7k3^#` zl{~axfn<;6%_)Dq`3&BHm*53>zPx(kx4{BRA>d&np=ofRT#%RsWUburR7W#1CvDclHyAu6w16Q^ALpuF(g6CC?jNf zPeBxBAgAWpo_iAniN=SBBD|#76d0mD)PR~cm6udfPdfhox{s~-_GR{3d(RyG;5TPo zzV%<<+Iye1*IH*PkBpOzjEr1Dx(Tj<(wAWv4#NRBRH;-{_Fx(V7|U+qa1fTk)Cn5} zowdLYXw2%AdLR1>=TBf}mS&+k#3#cBxC*jOS%UXmy#HRH#rsgn(hjOabh8QP3i(-S zV${umh6ttSLMP}-{|H&OpURigw1dhKoDRCxuJs3r(FlXy zWz@l+e0D=aqN7J48cnbcq-pI21@CysWxFCa33}m~Y)}7MG-tw>VC^;q%l#GW)Pi7j zXRBruH_0!oQ%L^Z66~9xpk31=)|RP;gQiN&Hq$d5C!mrTI=~KMkg`ds-BYP~UZ&Yb zU)Xf#RF(27=zk4Z>q_haFTG)FFR5Cio`C*Y%I5KABRlc>A*K@-9J zhiw4I%>d>+>;q`k#^REBu4b4~J}_D0Y&Nl8)*Y}fgsVj(rW390e}KhBwM_f#Cf}$# zU{`#FUtnnNf|?DGC}n?(&a*Yq=(Ae-BZROv?>h5w?E`b=g0cwCc120+z^q(D^gGkZ z3G!3Q=mniQM*C!`vk>A-3Dj==)^LPaEn@&^%r!bgCLf+9crCbo1J}L^SB$qr_;XjB z^wzjnUqyeI{YieBR<)t|4062>@LK>nd~$6?qIh5-!qdR67~UHthlBT) zk~S4M46gl5rtI16Bew(6zr*MYOo8{No;J(CS~*{==<*`2@7Ld^lMSP;zA`v?9joB~ z1n5e*wmT+Vpa5ar;cwgyNdE&*CCXBf`(O*-9&qhnGHr>%fraSV(+KsqI#rI!Tt|!e zX-#q+T-%W<98iJqTVNNO0|8R;CJ5WpvNM!D9`_6@LF--6rrota$h3qhI;{|CcU7Zw zr{hv6iQa0k)3R2#O9~0AF(R63Pl0QP$Q1rj4vjY0=L$z@Jw|UA4n7)ye$#SISL)Rw z;002ovPDHLkV1jZJ BRT2OI diff --git a/app/src/main/res/drawable-xxxhdpi/timer40.png b/app/src/main/res/drawable-xxxhdpi/timer40.png deleted file mode 100644 index 2ad5ff358b1d982b26a244b0a12257a8be4135ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1267 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 Eg8M&+i~s-t diff --git a/app/src/main/res/drawable-xxxhdpi/timer55.png b/app/src/main/res/drawable-xxxhdpi/timer55.png deleted file mode 100644 index eacdf73a070c816306b6bb71e96fa0fbc5392159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1414 zcmV;11$p|3P)Px)KuJVFRA>dwnq7!hRTRg&nogOM1J>k6#w3CUkqhB8HIgd z4MRn}gcNDM)^kr020=Iw2x3Nwls!ZW^)R3XGfiQZAC)!z{*(9Kb^rI=d-px(PIngk zoxRpx>%Z5&XP^CbEL}6pGCe(go+hTi1egThfy3ZvsZ_ea-?=Sx;BSy6e_%WUo(7Bl zw_ecLP2df%B&AXO8g+oGiu^?BHLx^=GnWtK^T8-M4j^yzCV|VJ7#^#SO0W<(2rfG#`fq0Y z9KGU&2Pv}uMIw}(k>&Je0VJf{(`=r>5LKI#o$K} z<%354dF_zWO>Vc(jjMo8_C=2H!W<#x}id8;tNX(^J*` zIF1MB1(Xj2y%WWcjbx)bu-=T{nx3R`zP*g&i{`wAUu=unim0b7c%6pODSIsUn^RjV zTt|%WG`gNfVS(pCl{)aL84Ilg#?@(c zlM$ZKOME@h#wf}vbL_fGa#~-aX!h)%#}?oc*`2Tqcbh&J)F&*7L*%aO`kC$U&e%1| znp%4;pld3*EC7C>8LT8kG^gR`aE*HqnZ%cGws@-2<7zI&&Mp@bA-1UwQ0KcEz zSCP8a zi`&mz)1*#Wdw`~G7mb499JJLC2=qlp<1b6JIvxPN&8-qNC=s&?yb1h3g=t3g9=7}{ zlQhReq?hL%u(?4#GM@z6n(JCdI1O~wqOb6R4oW&}XuGZ*Z?kjq81*~Z>-b>{a{-|v z+kqC6*%B?JvpP{|%q|7`Sl!8vXdkAnO!dmVzz>}iN@Lm~4uX$>-)vxNDLR831KJ5M zOlma4Dd`=^h^sM?fG)%IWo84=IKLI>i((JZM>+ks8ja_1p!Yzj;W6 UFZy1P=>Px#07*qoM6N<$f}k|0rvLx| diff --git a/app/src/main/res/drawable-xxxhdpi/timer60.png b/app/src/main/res/drawable-xxxhdpi/timer60.png deleted file mode 100644 index d66cf127be7d66ffd2e63bf2fd4ac7a06e1f46af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1260 zcmVPx(rb$FWRA>dwna^(xQ545LQQC?h2?7jDs=o61)I|(e**4QW>E=GeXjfz)8>#{(`*ni-4Qp zAW)&xBMi0-Tmj!eQ8EhAWw5ZcDj;tIr-245ONJpn0xDSx$>a#(Y6!0a&GC>tp`#x> z1JA)z;K~M+jX>AUQ9CsL0R4w~MqY7kPDp+a2u+uF^j!pN>?qh0;^|+F!zJ)3;W(+t zzSYL%(jhno{!DVlEfu8cG%rLLdq#~Ezqz=a0=^&V2loA;*i<`#hk#G6Vbl_tDmF0H znNIvyf?GBYT`Sztsp1p;1Z)DT-j#nSXA_PoiW{hQiff_^S1rvN%M~rT+Y*i;1;^#! zy&Fw?4CK0%CO&fk%8OXt{ojV;1A6+M$3aWwV-qO#K&ar5-ExHdLU!VNfqniSVrvV< zsPYrQ%7tJ+N{1y$aSe9CamMnXpY#UM(Ful3zzy`!TJ7bj84XXaDdcce;JItKKBCXB z`NUU@S{7Z=8+U2!kh9?ushu-Cb|-WR7!ijfXJQ96=vWNCI1Xx_OE~3J3|GSiXjr)H zRQif-ahwv$aWmj&^rkjehOgNI7Qm;RTsf`+W=B=@#??$T`q+nz8rzJino61`ThorqC`k z{%xbTl6uD^vqJ@}Fh=d^TUBgs-6}vIXi82$)%WZQT^4$UJly{Ia z{cUd5JMhkonC;us1Wl7_b9<|;M2~qr7qXQoqagBpZF22V0lv-Lx-t@IhLb$kndG$r zZJ*6*_Y>lKhJ2sVTS2us-HURsZdD8apeeFqe(8n4`$1D3ht9%3DGM&(JUP0e&)>;U zN)3jg(2x3}3Lvkgz#i$JuH(n4&|{Ul0U9unla5t%wBw47emcE$bQE^52BgI7r40aya10L%D^I7R8N2=x}%1iinceN4qOJaJHK{|IdM= z?|5Bgx}~LUlf@m4!6hL&>QDQ7tG*BdLVs}Rp=$WZv3{fpi#UOOU@=wYP$(|dPN<5o ze{_gZ%i1}xAeA6PCmec7iOVjPn5ttpmV9k&NHm=^|9qmi<5{q_=AZSdm?+l1VKG-l zV@^43GR~(7KA;0W) ziqI0P_k{fq0aIZjZjF(?RAx`R-V<`Yv9;~6$MKjN7IBHw|Awtkqj?L1X#=QT>`h<~ zhcQhliczdMk{h!e>1BEbrs5-kc6@dMowMqwvNq>+m1r*4o(X-z>igq - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_appearance.xml b/app/src/main/res/drawable/ic_appearance.xml deleted file mode 100644 index bda7bf3550..0000000000 --- a/app/src/main/res/drawable/ic_appearance.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml deleted file mode 100644 index b4d562a13e..0000000000 --- a/app/src/main/res/drawable/ic_arrow_left.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_ban.xml b/app/src/main/res/drawable/ic_ban.xml new file mode 100644 index 0000000000..482ce3370b --- /dev/null +++ b/app/src/main/res/drawable/ic_ban.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_block_24.xml b/app/src/main/res/drawable/ic_baseline_block_24.xml deleted file mode 100644 index 9fefeec67e..0000000000 --- a/app/src/main/res/drawable/ic_baseline_block_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml deleted file mode 100644 index 2844bafebe..0000000000 --- a/app/src/main/res/drawable/ic_baseline_edit_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_mic_off_24.xml b/app/src/main/res/drawable/ic_baseline_mic_off_24.xml deleted file mode 100644 index 8e199f115a..0000000000 --- a/app/src/main/res/drawable/ic_baseline_mic_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml deleted file mode 100644 index 07b76d6275..0000000000 --- a/app/src/main/res/drawable/ic_baseline_search_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_volume_up_24.xml b/app/src/main/res/drawable/ic_baseline_volume_up_24.xml deleted file mode 100644 index 0db34695f1..0000000000 --- a/app/src/main/res/drawable/ic_baseline_volume_up_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000000..48ce8b20cf --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000000..c35e8ce679 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml b/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml deleted file mode 100644 index 0d1a076b41..0000000000 --- a/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_circle_dots_custom.xml b/app/src/main/res/drawable/ic_circle_dots_custom.xml new file mode 100644 index 0000000000..f976ae3ed0 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_dots_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_help.xml b/app/src/main/res/drawable/ic_circle_help.xml new file mode 100644 index 0000000000..1fba16ac74 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_help.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_question_mark.xml b/app/src/main/res/drawable/ic_circle_question_mark.xml deleted file mode 100644 index 9bc2b817f1..0000000000 --- a/app/src/main/res/drawable/ic_circle_question_mark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_clock_0.xml b/app/src/main/res/drawable/ic_clock_0.xml new file mode 100644 index 0000000000..3609c815e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_0.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_1.xml b/app/src/main/res/drawable/ic_clock_1.xml new file mode 100644 index 0000000000..f5f7585026 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_1.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_10.xml b/app/src/main/res/drawable/ic_clock_10.xml new file mode 100644 index 0000000000..69fa66fb0b --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_10.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_11.xml b/app/src/main/res/drawable/ic_clock_11.xml new file mode 100644 index 0000000000..d3fc86b216 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_11.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_12.xml b/app/src/main/res/drawable/ic_clock_12.xml new file mode 100644 index 0000000000..c5748881fb --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_12.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_2.xml b/app/src/main/res/drawable/ic_clock_2.xml new file mode 100644 index 0000000000..719d765d55 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_2.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_3.xml b/app/src/main/res/drawable/ic_clock_3.xml new file mode 100644 index 0000000000..0090d8d711 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_3.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_4.xml b/app/src/main/res/drawable/ic_clock_4.xml new file mode 100644 index 0000000000..78348d25bd --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_4.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_5.xml b/app/src/main/res/drawable/ic_clock_5.xml new file mode 100644 index 0000000000..887b6656b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_5.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_6.xml b/app/src/main/res/drawable/ic_clock_6.xml new file mode 100644 index 0000000000..78dd1f66f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_6.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_7.xml b/app/src/main/res/drawable/ic_clock_7.xml new file mode 100644 index 0000000000..6b39cf430d --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_7.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_8.xml b/app/src/main/res/drawable/ic_clock_8.xml new file mode 100644 index 0000000000..a407dffef1 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_8.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_9.xml b/app/src/main/res/drawable/ic_clock_9.xml new file mode 100644 index 0000000000..104f981838 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_9.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_conversations.xml b/app/src/main/res/drawable/ic_conversations.xml deleted file mode 100644 index a32d5ec910..0000000000 --- a/app/src/main/res/drawable/ic_conversations.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000000..7ce7719132 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_globe.xml b/app/src/main/res/drawable/ic_globe.xml index d16011ad09..3e017532dd 100644 --- a/app/src/main/res/drawable/ic_globe.xml +++ b/app/src/main/res/drawable/ic_globe.xml @@ -1,10 +1,9 @@ - - + + + + + + + + diff --git a/app/src/main/res/drawable/ic_group.xml b/app/src/main/res/drawable/ic_group.xml deleted file mode 100644 index 1a4a3feddd..0000000000 --- a/app/src/main/res/drawable/ic_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml deleted file mode 100644 index 670a82c658..0000000000 --- a/app/src/main/res/drawable/ic_help.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_invite_friend.xml b/app/src/main/res/drawable/ic_invite_friend.xml deleted file mode 100644 index e46d22704e..0000000000 --- a/app/src/main/res/drawable/ic_invite_friend.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml deleted file mode 100644 index b642ae75e5..0000000000 --- a/app/src/main/res/drawable/ic_lock.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_lock_keyhole.xml b/app/src/main/res/drawable/ic_lock_keyhole.xml new file mode 100644 index 0000000000..434b107795 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_keyhole.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lock_keyhole_open.xml b/app/src/main/res/drawable/ic_lock_keyhole_open.xml new file mode 100644 index 0000000000..749a90554a --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_keyhole_open.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml deleted file mode 100644 index fb194f55d6..0000000000 --- a/app/src/main/res/drawable/ic_message.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_message_details__refresh.xml b/app/src/main/res/drawable/ic_message_details__refresh.xml deleted file mode 100644 index 2aabe6fbe3..0000000000 --- a/app/src/main/res/drawable/ic_message_details__refresh.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_message_requests.xml b/app/src/main/res/drawable/ic_message_requests.xml deleted file mode 100644 index de8e1a6908..0000000000 --- a/app/src/main/res/drawable/ic_message_requests.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_message_square.xml b/app/src/main/res/drawable/ic_message_square.xml new file mode 100644 index 0000000000..0d064b6dea --- /dev/null +++ b/app/src/main/res/drawable/ic_message_square.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_message_square_warning.xml b/app/src/main/res/drawable/ic_message_square_warning.xml new file mode 100644 index 0000000000..e4f818c4c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_square_warning.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mic_off.xml b/app/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 0000000000..7e54ce597d --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_more_horiz_white.xml b/app/src/main/res/drawable/ic_more_horiz_white.xml deleted file mode 100644 index efb34a24a3..0000000000 --- a/app/src/main/res/drawable/ic_more_horiz_white.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml deleted file mode 100644 index 1e72d86cb6..0000000000 --- a/app/src/main/res/drawable/ic_next.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_outline_notifications_active_24.xml b/app/src/main/res/drawable/ic_outline_notifications_active_24.xml deleted file mode 100644 index 0a327617e4..0000000000 --- a/app/src/main/res/drawable/ic_outline_notifications_active_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_notifications_off_24.xml b/app/src/main/res/drawable/ic_outline_notifications_off_24.xml deleted file mode 100644 index cdb23eefa6..0000000000 --- a/app/src/main/res/drawable/ic_outline_notifications_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_paintbrush_vertical.xml b/app/src/main/res/drawable/ic_paintbrush_vertical.xml new file mode 100644 index 0000000000..77e78f4b46 --- /dev/null +++ b/app/src/main/res/drawable/ic_paintbrush_vertical.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pencil.xml b/app/src/main/res/drawable/ic_pencil.xml new file mode 100644 index 0000000000..7f0aa671d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_pencil.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml deleted file mode 100644 index f720261670..0000000000 --- a/app/src/main/res/drawable/ic_prev.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_privacy_icon.xml b/app/src/main/res/drawable/ic_privacy_icon.xml deleted file mode 100644 index 4162beb8d1..0000000000 --- a/app/src/main/res/drawable/ic_privacy_icon.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000000..dbdc758bf2 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code_24.xml b/app/src/main/res/drawable/ic_qr_code_24.xml deleted file mode 100644 index acd7573fdd..0000000000 --- a/app/src/main/res/drawable/ic_qr_code_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_question_custom.xml b/app/src/main/res/drawable/ic_question_custom.xml new file mode 100644 index 0000000000..1f9cfc20e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_question_custom.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_recovery_password_custom.xml b/app/src/main/res/drawable/ic_recovery_password_custom.xml new file mode 100644 index 0000000000..0eca9ea884 --- /dev/null +++ b/app/src/main/res/drawable/ic_recovery_password_custom.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh_cw.xml b/app/src/main/res/drawable/ic_refresh_cw.xml new file mode 100644 index 0000000000..f4c769dc00 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_cw.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..3bccaadd10 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml deleted file mode 100644 index 1d79a060b1..0000000000 --- a/app/src/main/res/drawable/ic_search_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 9fd7185331..6fd41c822d 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,12 +1,12 @@ diff --git a/app/src/main/res/drawable/ic_shield_outline.xml b/app/src/main/res/drawable/ic_shield_outline.xml deleted file mode 100644 index 3db98f53d0..0000000000 --- a/app/src/main/res/drawable/ic_shield_outline.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml deleted file mode 100644 index 505bf988b4..0000000000 --- a/app/src/main/res/drawable/ic_speaker.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_user_round_plus.xml b/app/src/main/res/drawable/ic_user_round_plus.xml new file mode 100644 index 0000000000..a8cd030338 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_plus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_users_group_custom.xml b/app/src/main/res/drawable/ic_users_group_custom.xml new file mode 100644 index 0000000000..32d935a1b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_users_group_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_volume_2.xml b/app/src/main/res/drawable/ic_volume_2.xml new file mode 100644 index 0000000000..7908c9e019 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_2.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_volume_off.xml b/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 0000000000..c0bccba881 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/padded_circle_accent.xml b/app/src/main/res/drawable/padded_circle_accent.xml deleted file mode 100644 index 797c6bf007..0000000000 --- a/app/src/main/res/drawable/padded_circle_accent.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/session_shield.xml b/app/src/main/res/drawable/session_shield.xml deleted file mode 100644 index a7c6d1a24a..0000000000 --- a/app/src/main/res/drawable/session_shield.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml index 2445fde11c..f0bae35659 100644 --- a/app/src/main/res/layout/activity_edit_closed_group.xml +++ b/app/src/main/res/layout/activity_edit_closed_group.xml @@ -85,8 +85,9 @@ android:textAlignment="center" android:paddingStart="24dp" android:paddingEnd="0dp" - android:drawableEnd="@drawable/ic_baseline_edit_24" android:drawablePadding="@dimen/small_spacing" + app:drawableEndCompat="@drawable/ic_pencil" + app:drawableTint="?android:textColorPrimary" tools:text="SomeGroupName"/> diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 817b8f8574..b86a1ed897 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -67,7 +67,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" - android:src="@drawable/ic_baseline_search_24" + android:src="@drawable/ic_search" app:tint="?sessionLogoTint" /> diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml index f84750480a..4bb5852a78 100644 --- a/app/src/main/res/layout/activity_webrtc.xml +++ b/app/src/main/res/layout/activity_webrtc.xml @@ -51,9 +51,9 @@ app:layout_constraintLeft_toLeftOf="parent" android:background="@drawable/call_controls_background" android:elevation="8dp" - android:layout_marginLeft="@dimen/small_spacing" + android:layout_marginStart="@dimen/small_spacing" android:layout_marginTop="@dimen/small_spacing" - android:src="@drawable/ic_arrow_left" + android:src="@drawable/ic_chevron_left" android:scaleType="centerInside" android:layout_width="@dimen/medium_profile_picture_size" android:layout_height="@dimen/medium_profile_picture_size" @@ -219,7 +219,7 @@ android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" android:padding="@dimen/medium_spacing" - android:src="@drawable/ic_baseline_mic_off_24" + android:src="@drawable/ic_mic_off" android:layout_marginBottom="@dimen/large_spacing" app:layout_constraintBottom_toTopOf="@+id/endCallButton" android:background="@drawable/circle_tintable" @@ -233,7 +233,7 @@ android:background="@drawable/circle_tintable" android:backgroundTint="@color/state_list_call_action_background" app:tint="@color/state_list_call_action_foreground" - android:src="@drawable/ic_baseline_volume_up_24" + android:src="@drawable/ic_volume_2" android:padding="@dimen/medium_spacing" android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" diff --git a/app/src/main/res/layout/context_menu_item.xml b/app/src/main/res/layout/context_menu_item.xml index 83d43d82d7..f3e3c66586 100644 --- a/app/src/main/res/layout/context_menu_item.xml +++ b/app/src/main/res/layout/context_menu_item.xml @@ -16,7 +16,7 @@ android:id="@+id/context_menu_item_icon" android:layout_width="24dp" android:layout_height="24dp" - tools:src="@drawable/ic_message"/> + tools:src="@drawable/ic_message_square"/> - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index d2ddeb3fdd..dcab77766b 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -58,7 +58,7 @@ android:contentDescription="@string/AccessibilityId_block" android:text="@string/block" android:visibility="gone" - app:drawableStartCompat="@drawable/ic_baseline_block_24" + app:drawableStartCompat="@drawable/ic_ban" app:drawableTint="?attr/colorControlNormal" tools:visibility="visible" /> @@ -76,7 +76,7 @@ style="@style/BottomSheetActionItem" android:contentDescription="@string/AccessibilityId_notificationsMute" android:text="@string/notificationsMute" - app:drawableStartCompat="@drawable/ic_outline_notifications_off_24" + app:drawableStartCompat="@drawable/ic_volume_off" app:drawableTint="?attr/colorControlNormal" tools:visibility="visible" android:visibility="gone" @@ -86,7 +86,7 @@ android:id="@+id/unMuteNotificationsTextView" style="@style/BottomSheetActionItem" android:text="@string/notificationsMuteUnmute" - app:drawableStartCompat="@drawable/ic_outline_notifications_active_24" + app:drawableStartCompat="@drawable/ic_volume_2" app:drawableTint="?attr/colorControlNormal" tools:visibility="visible" android:visibility="gone" diff --git a/app/src/main/res/layout/fragment_create_group.xml b/app/src/main/res/layout/fragment_create_group.xml index 54f6dc1949..6427d9d4e7 100644 --- a/app/src/main/res/layout/fragment_create_group.xml +++ b/app/src/main/res/layout/fragment_create_group.xml @@ -30,7 +30,7 @@ android:clickable="true" android:contentDescription="@string/AccessibilityId_navigateBack" android:focusable="true" - android:src="@drawable/ic_arrow_left" + android:src="@drawable/ic_chevron_left" app:layout_constraintBottom_toBottomOf="@id/titleText" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/titleText" diff --git a/app/src/main/res/layout/fragment_join_community.xml b/app/src/main/res/layout/fragment_join_community.xml index 4c5c1488de..6506ec210d 100644 --- a/app/src/main/res/layout/fragment_join_community.xml +++ b/app/src/main/res/layout/fragment_join_community.xml @@ -27,7 +27,7 @@ android:layout_marginStart="@dimen/medium_spacing" android:clickable="true" android:focusable="true" - android:src="@drawable/ic_arrow_left" + android:src="@drawable/ic_chevron_left" app:layout_constraintBottom_toBottomOf="@id/titleText" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/titleText" diff --git a/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml b/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml index a7ef7ba5be..818d2155dd 100644 --- a/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml @@ -61,7 +61,8 @@ android:contentDescription="@string/AccessibilityId_displayNameNew" android:paddingTop="2dp" android:layout_marginEnd="20dp" - android:src="@drawable/ic_baseline_edit_24" /> + android:src="@drawable/ic_pencil" + app:tint="?android:textColorPrimary" /> diff --git a/app/src/main/res/layout/giphy_activity_toolbar.xml b/app/src/main/res/layout/giphy_activity_toolbar.xml index b023848580..3c8766108e 100644 --- a/app/src/main/res/layout/giphy_activity_toolbar.xml +++ b/app/src/main/res/layout/giphy_activity_toolbar.xml @@ -1,6 +1,7 @@ + android:src="@drawable/ic_search" + app:tint="?attr/colorControlNormal"/> diff --git a/app/src/main/res/layout/layout_conversation_block_icon.xml b/app/src/main/res/layout/layout_conversation_block_icon.xml index 1cdc9924fe..4e193a96c6 100644 --- a/app/src/main/res/layout/layout_conversation_block_icon.xml +++ b/app/src/main/res/layout/layout_conversation_block_icon.xml @@ -12,7 +12,7 @@ android:layout_margin="@dimen/very_small_spacing" android:layout_width="13dp" android:layout_height="13dp" - android:src="@drawable/ic_baseline_block_24" + android:src="@drawable/ic_ban" app:tint="?danger" /> \ No newline at end of file diff --git a/app/src/main/res/layout/media_view_edit_button.xml b/app/src/main/res/layout/media_view_edit_button.xml deleted file mode 100644 index a850254421..0000000000 --- a/app/src/main/res/layout/media_view_edit_button.xml +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/src/main/res/layout/mediasend_activity.xml b/app/src/main/res/layout/mediasend_activity.xml index fcbbb435a1..8316c1f0e0 100644 --- a/app/src/main/res/layout/mediasend_activity.xml +++ b/app/src/main/res/layout/mediasend_activity.xml @@ -49,7 +49,7 @@ android:layout_width="16dp" android:layout_height="16dp" android:layout_marginStart="@dimen/medium_spacing" - android:src="@drawable/ic_arrow_right" + android:src="@drawable/ic_chevron_right" app:tint="@color/core_white"/> diff --git a/app/src/main/res/layout/session_logo_action_bar_content.xml b/app/src/main/res/layout/session_logo_action_bar_content.xml index 22f6baf564..22398083ce 100644 --- a/app/src/main/res/layout/session_logo_action_bar_content.xml +++ b/app/src/main/res/layout/session_logo_action_bar_content.xml @@ -1,6 +1,7 @@ @@ -8,7 +9,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" - android:src="@drawable/session_logo"/> + android:src="@drawable/session_logo" /> + android:src="@drawable/ic_chevron_left" + android:alpha="0.5" + app:tint="?colorControlNormal" /> diff --git a/app/src/main/res/layout/share_activity.xml b/app/src/main/res/layout/share_activity.xml index 1d7ad6aed9..6b276674a3 100644 --- a/app/src/main/res/layout/share_activity.xml +++ b/app/src/main/res/layout/share_activity.xml @@ -1,11 +1,11 @@ + xmlns:wheel="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:contentDescription="@string/AccessibilityId_search" + android:src="@drawable/ic_search" + android:visibility="gone" + android:layout_alignParentEnd="true" + android:layout_centerVertical="true" + wheel:tint="?attr/colorControlNormal" /> diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 0ca39ca387..7c414482fe 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -26,7 +26,7 @@ android:layout_marginBottom="@dimen/small_spacing" android:visibility="gone" app:tint="?android:textColorTertiary" - tools:src="@drawable/ic_timer" + tools:src="@drawable/ic_clock_11" tools:visibility="visible" /> + android:layout_marginEnd="6dp" + app:tint="?colorControlNormal" /> + android:layout_marginStart="@dimen/medium_spacing" + app:tint="?colorControlNormal" /> diff --git a/app/src/main/res/layout/view_conversation_setting.xml b/app/src/main/res/layout/view_conversation_setting.xml index ab9739402a..bd671f2479 100644 --- a/app/src/main/res/layout/view_conversation_setting.xml +++ b/app/src/main/res/layout/view_conversation_setting.xml @@ -13,7 +13,7 @@ android:layout_gravity="center" android:alpha="0.6" android:visibility="gone" - android:src="@drawable/ic_arrow_left" + android:src="@drawable/ic_chevron_left" app:tint="?android:textColorPrimary" tools:visibility="visible" /> @@ -26,7 +26,7 @@ android:alpha="0.6" android:visibility="gone" app:tint="?android:textColorPrimary" - tools:src="@drawable/ic_outline_notifications_off_24" + tools:src="@drawable/ic_volume_off" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/view_global_search_input.xml b/app/src/main/res/layout/view_global_search_input.xml index cd164772bd..21ae7c92b3 100644 --- a/app/src/main/res/layout/view_global_search_input.xml +++ b/app/src/main/res/layout/view_global_search_input.xml @@ -22,7 +22,7 @@ android:layout_gravity="center_vertical" android:layout_width="20dp" android:layout_height="20dp" - android:src="@drawable/ic_baseline_search_24" + android:src="@drawable/ic_search" android:scaleType="centerInside" app:tint="?searchIconColor" android:contentDescription="@string/search" /> diff --git a/app/src/main/res/layout/view_input_bar_recording.xml b/app/src/main/res/layout/view_input_bar_recording.xml index 42d93ba6a1..cf642286d2 100644 --- a/app/src/main/res/layout/view_input_bar_recording.xml +++ b/app/src/main/res/layout/view_input_bar_recording.xml @@ -63,7 +63,7 @@ android:id="@+id/inputBarChevronImageView" android:layout_width="12dp" android:layout_height="12dp" - android:src="@drawable/ic_arrow_left" + android:src="@drawable/ic_chevron_left" android:layout_marginTop="1dp" app:tint="?android:textColorPrimary" android:alpha="0.6" /> @@ -165,7 +165,7 @@ @@ -42,8 +43,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginStart="@dimen/medium_spacing" - android:foreground="@drawable/radial_multi_select" - android:src="@drawable/padded_circle_accent_select"/> + android:src="@drawable/ic_radio_unselected" + app:tint="?colorControlNormal"/> diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index 05dd63e7de..7dd2ea5d4b 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -113,6 +113,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:baselineAligned="true" + android:gravity="center_vertical" + android:layout_marginTop="3dp" app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView" app:layout_constraintStart_toStartOf="@id/messageInnerContainer" app:layout_constraintHorizontal_bias="1" @@ -126,6 +128,7 @@ android:layout_marginHorizontal="2dp" android:layout_gravity="center" android:textSize="@dimen/very_small_font_size" + android:includeFontPadding="false" tools:text="Sent" /> + android:src="@drawable/ic_circle_check" /> diff --git a/app/src/main/res/menu/settings_general.xml b/app/src/main/res/menu/settings_general.xml index ac51aba08b..c9738ca83f 100644 --- a/app/src/main/res/menu/settings_general.xml +++ b/app/src/main/res/menu/settings_general.xml @@ -7,7 +7,8 @@ android:id="@+id/action_qr_code" android:title="" android:contentDescription="@string/AccessibilityId_qrView" - android:icon="@drawable/ic_qr_code_24" + android:icon="@drawable/ic_qr_code" + app:iconTint="?colorControlNormal" app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 53646d5acc..e52e432a74 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -45,7 +45,6 @@ - @@ -107,7 +106,6 @@ - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 213609145f..c59b90b3b5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -7,15 +7,15 @@ 1dp 1dp @style/TextAppearance.Session.DarkActionBar.TitleTextStyle - @drawable/ic_arrow_left - @drawable/ic_arrow_left + @drawable/ic_chevron_left + @drawable/ic_chevron_left @@ -82,6 +71,7 @@ diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index c07b527aa8..31c9b79980 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -14,9 +14,9 @@ import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.DownloadUtilities import org.session.libsession.utilities.InputStreamMediaDataSource -import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.streams.AttachmentCipherInputStream import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ByteArraySlice.Companion.write import java.io.File @@ -76,8 +76,15 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) val threadID = storage.getThreadIdForMms(databaseMessageID) val handleFailure: (java.lang.Exception, attachmentId: AttachmentId?) -> Unit = { exception, attachment -> - if (exception is NonRetryableException || - exception == Error.NoAttachment + if(exception is HTTP.HTTPRequestFailedException && exception.statusCode == 404){ + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, id, databaseMessageID) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, AttachmentId(attachmentID,0), databaseMessageID) + } + } else if (exception == Error.NoAttachment || exception == Error.NoThread || exception == Error.NoSender || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400)) { @@ -123,14 +130,16 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } var tempFile: File? = null + var attachment: DatabaseAttachment? = null + try { - val attachment = messageDataProvider.getDatabaseAttachment(attachmentID) + attachment = messageDataProvider.getDatabaseAttachment(attachmentID) ?: return handleFailure(Error.NoAttachment, null) if (attachment.hasData()) { handleFailure(Error.DuplicateData, attachment.attachmentId) return } - messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachment.attachmentId, this.databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.databaseMessageID) tempFile = createTempFile() val openGroup = storage.getOpenGroup(threadID) if (openGroup == null) { @@ -171,7 +180,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } catch (e: Exception) { Log.e("AttachmentDownloadJob", "Error processing attachment download", e) tempFile?.delete() - return handleFailure(e,null) + return handleFailure(e,attachment?.attachmentId) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt index 063d5c5242..963201c978 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.streams.ProfileCipherInputStream +import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.io.File @@ -99,11 +100,17 @@ class RetrieveProfileAvatarJob( return delegate.handleJobFailedPermanently(this, dispatcherName, e) } catch (e: Exception) { - Log.e("Loki", "Failed to download profile avatar", e) - if (failureCount + 1 >= maxFailureCount) { + if(e is HTTP.HTTPRequestFailedException && e.statusCode == 404){ + Log.e("Loki", "Failed to download profile avatar from non-retryable error", e) errorUrls += profileAvatar + return delegate.handleJobFailedPermanently(this, dispatcherName, e) + } else { + Log.e("Loki", "Failed to download profile avatar", e) + if (failureCount + 1 >= maxFailureCount) { + errorUrls += profileAvatar + } + return delegate.handleJobFailed(this, dispatcherName, e) } - return delegate.handleJobFailed(this, dispatcherName, e) } finally { downloadDestination.delete() } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java index 5d7e5025c2..c9438bb7cf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java @@ -54,9 +54,15 @@ public Attachment(@NonNull String contentType, int transferState, long size, Str public int getTransferState() { return transferState; } public boolean isInProgress() { - return transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_DONE && - transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED && - transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING; + return transferState == AttachmentState.DOWNLOADING.getValue(); + } + + public boolean isDone() { + return transferState == AttachmentState.DONE.getValue(); + } + + public boolean isFailed() { + return transferState == AttachmentState.FAILED.getValue(); } public long getSize() { return size; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentTransferProgress.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentTransferProgress.kt deleted file mode 100644 index 76b1e57bbb..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentTransferProgress.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.session.libsession.messaging.sending_receiving.attachments - -object AttachmentTransferProgress { - const val TRANSFER_PROGRESS_DONE = 0 - const val TRANSFER_PROGRESS_STARTED = 1 - const val TRANSFER_PROGRESS_PENDING = 2 - const val TRANSFER_PROGRESS_FAILED = 3 -} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java index 69e08277f4..662795ddf0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java @@ -100,24 +100,24 @@ public static Optional forPointer(Optional } return Optional.of(new PointerAttachment(pointer.get().getContentType(), - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, - pointer.get().asPointer().getSize().or(0), - pointer.get().asPointer().getFilename(), - String.valueOf(pointer.get().asPointer().getId()), - encodedKey, null, - pointer.get().asPointer().getDigest().orNull(), - fastPreflightId, - pointer.get().asPointer().getVoiceNote(), - pointer.get().asPointer().getWidth(), - pointer.get().asPointer().getHeight(), - pointer.get().asPointer().getCaption().orNull(), - pointer.get().asPointer().getUrl())); + AttachmentState.PENDING.getValue(), + pointer.get().asPointer().getSize().or(0), + pointer.get().asPointer().getFilename(), + String.valueOf(pointer.get().asPointer().getId()), + encodedKey, null, + pointer.get().asPointer().getDigest().orNull(), + fastPreflightId, + pointer.get().asPointer().getVoiceNote(), + pointer.get().asPointer().getWidth(), + pointer.get().asPointer().getHeight(), + pointer.get().asPointer().getCaption().orNull(), + pointer.get().asPointer().getUrl())); } public static Optional forPointer(SignalServiceProtos.AttachmentPointer pointer) { return Optional.of(new PointerAttachment(pointer.getContentType(), - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, + AttachmentState.PENDING.getValue(), (long)pointer.getSize(), pointer.getFileName(), String.valueOf(pointer != null ? pointer.getId() : 0), @@ -136,26 +136,26 @@ public static Optional forPointer(SignalServiceProtos.DataMessage.Qu SignalServiceProtos.AttachmentPointer thumbnail = pointer.getThumbnail(); return Optional.of(new PointerAttachment(pointer.getContentType(), - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, - thumbnail != null ? (long)thumbnail.getSize() : 0, - thumbnail.getFileName(), - String.valueOf(thumbnail != null ? thumbnail.getId() : 0), - thumbnail != null && thumbnail.getKey() != null ? Base64.encodeBytes(thumbnail.getKey().toByteArray()) : null, - null, - thumbnail != null ? thumbnail.getDigest().toByteArray() : null, - null, - false, - thumbnail != null ? thumbnail.getWidth() : 0, - thumbnail != null ? thumbnail.getHeight() : 0, - thumbnail != null ? thumbnail.getCaption() : null, - thumbnail != null ? thumbnail.getUrl() : "")); + AttachmentState.PENDING.getValue(), + thumbnail != null ? (long)thumbnail.getSize() : 0, + thumbnail.getFileName(), + String.valueOf(thumbnail != null ? thumbnail.getId() : 0), + thumbnail != null && thumbnail.getKey() != null ? Base64.encodeBytes(thumbnail.getKey().toByteArray()) : null, + null, + thumbnail != null ? thumbnail.getDigest().toByteArray() : null, + null, + false, + thumbnail != null ? thumbnail.getWidth() : 0, + thumbnail != null ? thumbnail.getHeight() : 0, + thumbnail != null ? thumbnail.getCaption() : null, + thumbnail != null ? thumbnail.getUrl() : "")); } public static Optional forPointer(SignalServiceDataMessage.Quote.QuotedAttachment pointer) { SignalServiceAttachment thumbnail = pointer.getThumbnail(); return Optional.of(new PointerAttachment(pointer.getContentType(), - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, + AttachmentState.PENDING.getValue(), thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0, pointer.getFileName(), String.valueOf(thumbnail != null ? thumbnail.asPointer().getId() : 0), @@ -178,7 +178,7 @@ public static Optional forPointer(SignalServiceDataMessage.Quote.Quo public static Attachment forAttachment(org.session.libsession.messaging.messages.visible.Attachment attachment) { return new PointerAttachment( attachment.getContentType(), - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, + AttachmentState.PENDING.getValue(), attachment.getSizeInBytes(), attachment.getFilename(), null, Base64.encodeBytes(attachment.getKey()), diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt index c30e628cde..1f493a0a86 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt @@ -100,10 +100,10 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S } } -// matches values in AttachmentDatabase.java enum class AttachmentState(val value: Int) { DONE(0), - STARTED(1), + DOWNLOADING(1), PENDING(2), - FAILED(3) + FAILED(3), + EXPIRED(4) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Contact.java b/libsession/src/main/java/org/session/libsession/utilities/Contact.java index a0d181ad62..4bd0c1e01a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Contact.java +++ b/libsession/src/main/java/org/session/libsession/utilities/Contact.java @@ -13,7 +13,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState; import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment; import org.session.libsignal.utilities.JsonUtil; @@ -642,7 +642,7 @@ public int describeContents() { private static Attachment attachmentFromUri(@Nullable Uri uri) { if (uri == null) return null; - return new UriAttachment(uri, MediaTypes.IMAGE_JPEG, AttachmentTransferProgress.TRANSFER_PROGRESS_DONE, 0, null, false, false, null); + return new UriAttachment(uri, MediaTypes.IMAGE_JPEG, AttachmentState.DONE.getValue(), 0, null, false, false, null); } @Override diff --git a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt index cf581d0f01..585abe3353 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -29,9 +29,6 @@ object DownloadUtilities { downloadFile(outputStream, url) return // return on success } catch (e: HTTP.HTTPRequestFailedException) { - if (e.statusCode == 404) { - throw NonRetryableException("404 response trying to download file: $url", e) - } exception = e } catch (e: Exception) { exception = e From 1106987c0c859e9761d2973df185a0a1f1d61d59 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 28 Mar 2025 10:33:01 +1030 Subject: [PATCH 085/867] Android target sdk 35 (#1063) * Android target sdk 15 Removed unused libraries Fixed broken icon colors * Bumping libsession-util --- app/build.gradle | 22 +++++-------------- app/src/main/AndroidManifest.xml | 2 +- .../securesms/ApplicationContext.kt | 11 +++++----- .../menus/ConversationActionModeCallback.kt | 18 ++++++++++++++- .../conversation/v2/messages/DocumentView.kt | 1 + app/src/main/res/layout/share_activity.xml | 10 ++++----- app/src/main/res/layout/view_document.xml | 2 +- .../menu/menu_conversation_item_action.xml | 1 + build.gradle | 10 ++------- gradle.properties | 6 ++--- libsession/build.gradle | 2 +- 11 files changed, 43 insertions(+), 42 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f4d9c11a7a..2c407f426e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -270,7 +270,7 @@ dependencies { implementation("com.google.dagger:hilt-android:$daggerHiltVersion") implementation "androidx.appcompat:appcompat:$appcompatVersion" - implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.recyclerview:recyclerview:1.4.0' implementation "com.google.android.material:material:$materialVersion" implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.legacy:legacy-support-v13:1.0.0' @@ -279,7 +279,7 @@ dependencies { implementation 'androidx.legacy:legacy-preference-v14:1.0.0' implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.exifinterface:exifinterface:1.3.4' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" @@ -287,11 +287,11 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.paging:paging-runtime-ktx:$pagingVersion" - implementation 'androidx.activity:activity-ktx:1.9.2' - implementation 'androidx.activity:activity-compose:1.9.2' - implementation 'androidx.fragment:fragment-ktx:1.8.4' + implementation 'androidx.activity:activity-ktx:1.10.1' + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.fragment:fragment-ktx:1.8.6' implementation "androidx.core:core-ktx:$coreVersion" - implementation "androidx.work:work-runtime-ktx:2.7.1" + implementation "androidx.work:work-runtime-ktx:2.10.0" playImplementation ("com.google.firebase:firebase-messaging:24.0.0") { exclude group: 'com.google.firebase', module: 'firebase-core' @@ -307,19 +307,13 @@ dependencies { implementation 'org.signal:aesgcmprovider:0.0.3' implementation 'io.github.webrtc-sdk:android:125.6422.06.1' implementation "me.leolin:ShortcutBadger:1.1.16" - implementation 'se.emilsjolander:stickylistheaders:2.7.0' - implementation 'com.jpardogo.materialtabstrip:library:1.0.9' implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' - implementation 'commons-net:commons-net:3.7.2' implementation 'com.github.chrisbanes:PhotoView:2.1.3' implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:compose:1.0.0-beta01" implementation 'com.makeramen:roundedimageview:2.1.0' - implementation 'com.pnikosis:materialish-progress:1.5' implementation 'org.greenrobot:eventbus:3.0.0' - implementation 'pl.tajchert:waitingdots:0.1.0' implementation 'com.vanniktech:android-image-cropper:4.5.0' - implementation 'com.melnykov:floatingactionbutton:1.3.0' implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { exclude group: 'com.android.support', module: 'support-annotations' } @@ -331,7 +325,6 @@ dependencies { exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } implementation 'com.annimon:stream:1.1.8' - implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' implementation 'androidx.sqlite:sqlite-ktx:2.3.1' implementation 'net.zetetic:sqlcipher-android:4.6.1@aar' implementation project(":libsignal") @@ -363,9 +356,6 @@ dependencies { // Core library androidTestImplementation "androidx.test:core:$testCoreVersion" - androidTestImplementation('com.adevinta.android:barista:4.2.0') { - exclude group: 'org.jetbrains.kotlin' - } // AndroidJUnitRunner and JUnit Rules androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6c9072e18..12a36085de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - + - + diff --git a/app/src/main/res/layout/view_document.xml b/app/src/main/res/layout/view_document.xml index c9abfd0f55..95b9921740 100644 --- a/app/src/main/res/layout/view_document.xml +++ b/app/src/main/res/layout/view_document.xml @@ -15,7 +15,7 @@ android:layout_height="match_parent" android:paddingHorizontal="@dimen/message_spacing" android:background="@drawable/view_quote_attachment_preview_background"> - Date: Fri, 28 Mar 2025 15:30:39 +1100 Subject: [PATCH 086/867] [SES-3368] - Convert MediaSendFragment to Kotlin (#1064) --- .../mediasend/MediaSendFragment.java | 464 ---------------- .../securesms/mediasend/MediaSendFragment.kt | 498 ++++++++++++++++++ 2 files changed, 498 insertions(+), 464 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java deleted file mode 100644 index 235a4e9dfa..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ /dev/null @@ -1,464 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.WindowManager; -import android.view.inputmethod.EditorInfo; -import android.widget.ImageButton; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.ViewPager; -import com.bumptech.glide.Glide; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ExecutionException; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.SettableFuture; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.components.ComposeText; -import org.thoughtcrime.securesms.components.ControllableViewPager; -import org.thoughtcrime.securesms.components.InputAwareLayout; -import org.thoughtcrime.securesms.imageeditor.model.EditorModel; -import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; -import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; -import org.thoughtcrime.securesms.util.PushCharacterCalculator; -import org.thoughtcrime.securesms.util.Stopwatch; - -/** - * Allows the user to edit and caption a set of media items before choosing to send them. - */ -@AndroidEntryPoint -public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGlobalLayoutListener, - MediaRailAdapter.RailItemListener, - InputAwareLayout.OnKeyboardShownListener, - InputAwareLayout.OnKeyboardHiddenListener -{ - private static final String TAG = MediaSendFragment.class.getSimpleName(); - - private static final String KEY_ADDRESS = "address"; - - private InputAwareLayout hud; - private View captionAndRail; - private ImageButton sendButton; - private ComposeText composeText; - private ViewGroup composeContainer; - private ViewGroup playbackControlsContainer; - private TextView charactersLeft; - private View closeButton; - private View loader; - - private ControllableViewPager fragmentPager; - private MediaSendFragmentPagerAdapter fragmentPagerAdapter; - private RecyclerView mediaRail; - private MediaRailAdapter mediaRailAdapter; - - private int visibleHeight; - private MediaSendViewModel viewModel; - private Controller controller; - - private final Rect visibleBounds = new Rect(); - - private final PushCharacterCalculator characterCalculator = new PushCharacterCalculator(); - - public static MediaSendFragment newInstance(@NonNull Recipient recipient) { - Bundle args = new Bundle(); - args.putParcelable(KEY_ADDRESS, recipient.getAddress()); - - MediaSendFragment fragment = new MediaSendFragment(); - fragment.setArguments(args); - return fragment; - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(requireActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller interface."); - } - - controller = (Controller) requireActivity(); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediasend_fragment, container, false); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - initViewModel(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - hud = view.findViewById(R.id.mediasend_hud); - captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail); - sendButton = view.findViewById(R.id.mediasend_send_button); - composeText = view.findViewById(R.id.mediasend_compose_text); - composeContainer = view.findViewById(R.id.mediasend_compose_container); - fragmentPager = view.findViewById(R.id.mediasend_pager); - mediaRail = view.findViewById(R.id.mediasend_media_rail); - playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); - charactersLeft = view.findViewById(R.id.mediasend_characters_left); - closeButton = view.findViewById(R.id.mediasend_close_button); - loader = view.findViewById(R.id.loader); - - View sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg); - - sendButton.setOnClickListener(v -> { - if (hud.isKeyboardOpen()) { - hud.hideSoftkey(composeText, null); - } - - processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState()); - }); - - ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); - - composeText.setOnKeyListener(composeKeyPressedListener); - composeText.addTextChangedListener(composeKeyPressedListener); - composeText.setOnClickListener(composeKeyPressedListener); - composeText.setOnFocusChangeListener(composeKeyPressedListener); - - composeText.requestFocus(); - - fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager()); - fragmentPager.setAdapter(fragmentPagerAdapter); - - FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener(); - fragmentPager.addOnPageChangeListener(pageChangeListener); - fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem())); - - mediaRailAdapter = new MediaRailAdapter(Glide.with(this), this, true); - mediaRail.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); - mediaRail.setAdapter(mediaRailAdapter); - - hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this); - hud.addOnKeyboardShownListener(this); - hud.addOnKeyboardHiddenListener(this); - - composeText.append(viewModel.getBody()); - - Recipient recipient = Recipient.from(requireContext(), getArguments().getParcelable(KEY_ADDRESS), false); - String displayName = Optional.fromNullable(recipient.getName()) - .or(Optional.fromNullable(recipient.getProfileName()) - .or(recipient.getAddress().toString())); - composeText.setHint(getString(R.string.message), null); - composeText.setOnEditorActionListener((v, actionId, event) -> { - boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; - if (isSend) sendButton.performClick(); - return isSend; - }); - - closeButton.setOnClickListener(v -> requireActivity().onBackPressed()); - } - - @Override - public void onStart() { - super.onStart(); - - fragmentPagerAdapter.restoreState(viewModel.getDrawState()); - viewModel.onImageEditorStarted(); - - requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - } - - @Override - public void onHiddenChanged(boolean hidden) { - super.onHiddenChanged(hidden); - } - - @Override - public void onStop() { - super.onStop(); - fragmentPagerAdapter.saveAllState(); - viewModel.saveDrawState(fragmentPagerAdapter.getSavedState()); - } - - @Override - public void onGlobalLayout() { - hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds); - - int currentVisibleHeight = visibleBounds.height(); - - if (currentVisibleHeight != visibleHeight) { - hud.getLayoutParams().height = currentVisibleHeight; - hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom); - hud.requestLayout(); - - visibleHeight = currentVisibleHeight; - } - } - - @Override - public void onRailItemClicked(int distanceFromActive) { - viewModel.onPageChanged(fragmentPager.getCurrentItem() + distanceFromActive); - } - - @Override - public void onRailItemDeleteClicked(int distanceFromActive) { - viewModel.onMediaItemRemoved(requireContext(), fragmentPager.getCurrentItem() + distanceFromActive); - } - - @Override - public void onKeyboardShown() { - if (composeText.hasFocus()) { - mediaRail.setVisibility(View.VISIBLE); - composeContainer.setVisibility(View.VISIBLE); - } else { - mediaRail.setVisibility(View.GONE); - composeContainer.setVisibility(View.VISIBLE); - } - } - - @Override - public void onKeyboardHidden() { - composeContainer.setVisibility(View.VISIBLE); - mediaRail.setVisibility(View.VISIBLE); - } - - public void onTouchEventsNeeded(boolean needed) { - if (fragmentPager != null) { - fragmentPager.setEnabled(!needed); - } - } - - public boolean handleBackPress() { - if (hud.isInputOpen()) { - hud.hideCurrentInput(composeText); - return true; - } - return false; - } - - private void initViewModel() { - viewModel.getSelectedMedia().observe(this, media -> { - if (Util.isEmpty(media)) { - controller.onNoMediaAvailable(); - return; - } - - fragmentPagerAdapter.setMedia(media); - - mediaRail.setVisibility(View.VISIBLE); - mediaRailAdapter.setMedia(media); - }); - - viewModel.getPosition().observe(this, position -> { - if (position == null || position < 0) return; - - fragmentPager.setCurrentItem(position, true); - mediaRailAdapter.setActivePosition(position); - mediaRail.smoothScrollToPosition(position); - - View playbackControls = fragmentPagerAdapter.getPlaybackControls(position); - - if (playbackControls != null) { - ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - playbackControls.setLayoutParams(params); - playbackControlsContainer.removeAllViews(); - playbackControlsContainer.addView(playbackControls); - } else { - playbackControlsContainer.removeAllViews(); - } - }); - - viewModel.getBucketId().observe(this, bucketId -> { - if (bucketId == null) return; - - mediaRailAdapter.setAddButtonListener(() -> controller.onAddMediaClicked(bucketId)); - }); - } - - - private void presentCharactersRemaining() { - String messageBody = composeText.getTextTrimmed(); - CharacterState characterState = characterCalculator.calculateCharacters(messageBody); - - if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { - charactersLeft.setText(String.format(Locale.getDefault(), - "%d/%d (%d)", - characterState.charactersRemaining, - characterState.maxTotalMessageSize, - characterState.messagesSpent)); - charactersLeft.setVisibility(View.VISIBLE); - } else { - charactersLeft.setVisibility(View.GONE); - } - } - - @SuppressLint("StaticFieldLeak") - private void processMedia(@NonNull List mediaList, @NonNull Map savedState) { - Map> futures = new HashMap<>(); - - for (Media media : mediaList) { - Object state = savedState.get(media.getUri()); - - if (state instanceof ImageEditorFragment.Data) { - EditorModel model = ((ImageEditorFragment.Data) state).readModel(); - if (model != null && model.isChanged()) { - futures.put(media, render(requireContext(), model)); - } - } - } - - new AsyncTask>() { - - private Stopwatch renderTimer; - private Runnable progressTimer; - - @Override - protected void onPreExecute() { - renderTimer = new Stopwatch("ProcessMedia"); - progressTimer = () -> { - loader.setVisibility(View.VISIBLE); - }; - Util.runOnMainDelayed(progressTimer, 250); - } - - @Override - protected List doInBackground(Void... voids) { - Context context = requireContext(); - List updatedMedia = new ArrayList<>(mediaList.size()); - - for (Media media : mediaList) { - if (futures.containsKey(media)) { - try { - Bitmap bitmap = futures.get(media).get(); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); - - Uri uri = BlobProvider.getInstance() - .forData(baos.toByteArray()) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e)); - - Media updated = new Media(uri, media.getFilename(), MediaTypes.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), baos.size(), media.getBucketId(), media.getCaption()); - - updatedMedia.add(updated); - renderTimer.split("item"); - } catch (InterruptedException | ExecutionException | IOException e) { - Log.w(TAG, "Failed to render image. Using base image."); - updatedMedia.add(media); - } - } else { - updatedMedia.add(media); - } - } - return updatedMedia; - } - - @Override - protected void onPostExecute(List media) { - controller.onSendClicked(media, composeText.getTextTrimmed()); - Util.cancelRunnableOnMain(progressTimer); - loader.setVisibility(View.GONE); - renderTimer.stop(TAG); - } - }.execute(); - } - - private static ListenableFuture render(@NonNull Context context, @NonNull EditorModel model) { - SettableFuture future = new SettableFuture<>(); - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> future.set(model.render(context))); - - return future; - } - - public void onRequestFullScreen(boolean fullScreen) { - captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE); - } - - private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener { - @Override - public void onPageSelected(int position) { - viewModel.onPageChanged(position); - } - } - - private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener { - - int beforeLength; - - @Override - public boolean onKey(View v, int keyCode, KeyEvent event) { - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (keyCode == KeyEvent.KEYCODE_ENTER) { - if (TextSecurePreferences.isEnterSendsEnabled(requireContext())) { - sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); - sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); - return true; - } - } - } - return false; - } - - @Override - public void onClick(View v) { - hud.showSoftkey(composeText); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count,int after) { - beforeLength = composeText.getTextTrimmed().length(); - } - - @Override - public void afterTextChanged(Editable s) { - presentCharactersRemaining(); - viewModel.onBodyChanged(s); - } - - @Override - public void onTextChanged(CharSequence s, int start, int before,int count) {} - - @Override - public void onFocusChange(View v, boolean hasFocus) {} - } - - public interface Controller { - void onAddMediaClicked(@NonNull String bucketId); - void onSendClicked(@NonNull List media, @NonNull String body); - void onNoMediaAvailable(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt new file mode 100644 index 0000000000..58dca20cd2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -0,0 +1,498 @@ +package org.thoughtcrime.securesms.mediasend + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Rect +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.ImageButton +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener +import com.bumptech.glide.Glide +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.TextSecurePreferences.Companion.isEnterSendsEnabled +import org.session.libsession.utilities.Util.cancelRunnableOnMain +import org.session.libsession.utilities.Util.isEmpty +import org.session.libsession.utilities.Util.runOnMainDelayed +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.ListenableFuture +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.SettableFuture +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.components.ComposeText +import org.thoughtcrime.securesms.components.ControllableViewPager +import org.thoughtcrime.securesms.components.InputAwareLayout +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardHiddenListener +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener +import org.thoughtcrime.securesms.imageeditor.model.EditorModel +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.util.PushCharacterCalculator +import org.thoughtcrime.securesms.util.Stopwatch +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.Locale +import java.util.concurrent.ExecutionException + +/** + * Allows the user to edit and caption a set of media items before choosing to send them. + */ +@AndroidEntryPoint +class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, + OnKeyboardShownListener, OnKeyboardHiddenListener { + private var hud: InputAwareLayout? = null + private var captionAndRail: View? = null + private var sendButton: ImageButton? = null + private var composeText: ComposeText? = null + private var composeContainer: ViewGroup? = null + private var playbackControlsContainer: ViewGroup? = null + private var charactersLeft: TextView? = null + private var closeButton: View? = null + private var loader: View? = null + + private var fragmentPager: ControllableViewPager? = null + private var fragmentPagerAdapter: MediaSendFragmentPagerAdapter? = null + private var mediaRail: RecyclerView? = null + private var mediaRailAdapter: MediaRailAdapter? = null + + private var visibleHeight = 0 + private var viewModel: MediaSendViewModel? = null + private var controller: Controller? = null + + private val visibleBounds = Rect() + + private val characterCalculator = PushCharacterCalculator() + + override fun onAttach(context: Context) { + super.onAttach(context) + + check(requireActivity() is Controller) { "Parent activity must implement controller interface." } + + controller = requireActivity() as Controller + viewModel = ViewModelProvider(requireActivity()).get( + MediaSendViewModel::class.java + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.mediasend_fragment, container, false) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initViewModel() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + hud = view.findViewById(R.id.mediasend_hud) + captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail) + sendButton = view.findViewById(R.id.mediasend_send_button) + composeText = view.findViewById(R.id.mediasend_compose_text) + composeContainer = view.findViewById(R.id.mediasend_compose_container) + fragmentPager = view.findViewById(R.id.mediasend_pager) + mediaRail = view.findViewById(R.id.mediasend_media_rail) + playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container) + charactersLeft = view.findViewById(R.id.mediasend_characters_left) + closeButton = view.findViewById(R.id.mediasend_close_button) + loader = view.findViewById(R.id.loader) + + val sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg) + + sendButton!!.setOnClickListener(View.OnClickListener { v: View? -> + if (hud!!.isKeyboardOpen()) { + hud!!.hideSoftkey(composeText, null) + } + processMedia(fragmentPagerAdapter!!.allMedia, fragmentPagerAdapter!!.savedState) + }) + + val composeKeyPressedListener = ComposeKeyPressedListener() + + composeText!!.setOnKeyListener(composeKeyPressedListener) + composeText!!.addTextChangedListener(composeKeyPressedListener) + composeText!!.setOnClickListener(composeKeyPressedListener) + composeText!!.setOnFocusChangeListener(composeKeyPressedListener) + + composeText!!.requestFocus() + + fragmentPagerAdapter = MediaSendFragmentPagerAdapter(childFragmentManager) + fragmentPager!!.setAdapter(fragmentPagerAdapter) + + val pageChangeListener = FragmentPageChangeListener() + fragmentPager!!.addOnPageChangeListener(pageChangeListener) + fragmentPager!!.post(Runnable { pageChangeListener.onPageSelected(fragmentPager!!.currentItem) }) + + mediaRailAdapter = MediaRailAdapter(Glide.with(this), this, true) + mediaRail!!.setLayoutManager( + LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + ) + mediaRail!!.setAdapter(mediaRailAdapter) + + hud!!.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this) + hud!!.addOnKeyboardShownListener(this) + hud!!.addOnKeyboardHiddenListener(this) + + composeText!!.append(viewModel!!.body) + + val recipient = Recipient.from( + requireContext(), + arguments!!.getParcelable(KEY_ADDRESS)!!, false + ) + val displayName = Optional.fromNullable(recipient.name) + .or( + Optional.fromNullable(recipient.profileName) + .or(recipient.address.toString()) + ) + composeText!!.setHint(getString(R.string.message), null) + composeText!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + val isSend = actionId == EditorInfo.IME_ACTION_SEND + if (isSend) sendButton!!.performClick() + isSend + }) + + closeButton!!.setOnClickListener(View.OnClickListener { v: View? -> requireActivity().onBackPressed() }) + } + + override fun onStart() { + super.onStart() + + fragmentPagerAdapter!!.restoreState(viewModel!!.drawState) + viewModel!!.onImageEditorStarted() + + requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) + } + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + } + + override fun onStop() { + super.onStop() + fragmentPagerAdapter!!.saveAllState() + viewModel!!.saveDrawState(fragmentPagerAdapter!!.savedState) + } + + override fun onGlobalLayout() { + hud!!.rootView.getWindowVisibleDisplayFrame(visibleBounds) + + val currentVisibleHeight = visibleBounds.height() + + if (currentVisibleHeight != visibleHeight) { + hud!!.layoutParams.height = currentVisibleHeight + hud!!.layout( + visibleBounds.left, + visibleBounds.top, + visibleBounds.right, + visibleBounds.bottom + ) + hud!!.requestLayout() + + visibleHeight = currentVisibleHeight + } + } + + override fun onRailItemClicked(distanceFromActive: Int) { + viewModel!!.onPageChanged(fragmentPager!!.currentItem + distanceFromActive) + } + + override fun onRailItemDeleteClicked(distanceFromActive: Int) { + viewModel!!.onMediaItemRemoved( + requireContext(), + fragmentPager!!.currentItem + distanceFromActive + ) + } + + override fun onKeyboardShown() { + if (composeText!!.hasFocus()) { + mediaRail!!.visibility = View.VISIBLE + composeContainer!!.visibility = View.VISIBLE + } else { + mediaRail!!.visibility = View.GONE + composeContainer!!.visibility = View.VISIBLE + } + } + + override fun onKeyboardHidden() { + composeContainer!!.visibility = View.VISIBLE + mediaRail!!.visibility = View.VISIBLE + } + + fun onTouchEventsNeeded(needed: Boolean) { + if (fragmentPager != null) { + fragmentPager!!.isEnabled = !needed + } + } + + fun handleBackPress(): Boolean { + if (hud!!.isInputOpen) { + hud!!.hideCurrentInput(composeText) + return true + } + return false + } + + private fun initViewModel() { + viewModel!!.getSelectedMedia().observe( + this + ) { media: List? -> + if (isEmpty(media)) { + controller!!.onNoMediaAvailable() + return@observe + } + fragmentPagerAdapter!!.setMedia(media!!) + + mediaRail!!.visibility = View.VISIBLE + mediaRailAdapter!!.setMedia(media) + } + + viewModel!!.getPosition().observe(this) { position: Int? -> + if (position == null || position < 0) return@observe + fragmentPager!!.setCurrentItem(position, true) + mediaRailAdapter!!.setActivePosition(position) + mediaRail!!.smoothScrollToPosition(position) + + val playbackControls = fragmentPagerAdapter!!.getPlaybackControls(position) + if (playbackControls != null) { + val params = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + playbackControls.layoutParams = params + playbackControlsContainer!!.removeAllViews() + playbackControlsContainer!!.addView(playbackControls) + } else { + playbackControlsContainer!!.removeAllViews() + } + } + + viewModel!!.getBucketId().observe(this) { bucketId: String? -> + if (bucketId == null) return@observe + mediaRailAdapter!!.setAddButtonListener { controller!!.onAddMediaClicked(bucketId) } + } + } + + + private fun presentCharactersRemaining() { + val messageBody = composeText!!.textTrimmed + val characterState = characterCalculator.calculateCharacters(messageBody) + + if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { + charactersLeft!!.text = String.format( + Locale.getDefault(), + "%d/%d (%d)", + characterState.charactersRemaining, + characterState.maxTotalMessageSize, + characterState.messagesSpent + ) + charactersLeft!!.visibility = View.VISIBLE + } else { + charactersLeft!!.visibility = View.GONE + } + } + + @SuppressLint("StaticFieldLeak") + private fun processMedia(mediaList: List, savedState: Map) { + val futures: MutableMap> = HashMap() + + for (media in mediaList) { + val state = savedState[media.uri] + + if (state is ImageEditorFragment.Data) { + val model = state.readModel() + if (model != null && model.isChanged) { + futures[media] = render(requireContext(), model) + } + } + } + + object : AsyncTask>() { + private var renderTimer: Stopwatch? = null + private var progressTimer: Runnable? = null + + override fun onPreExecute() { + renderTimer = Stopwatch("ProcessMedia") + progressTimer = Runnable { + loader!!.visibility = View.VISIBLE + } + runOnMainDelayed(progressTimer!!, 250) + } + + override fun doInBackground(vararg params: Void?): List { + val context = requireContext() + val updatedMedia: MutableList = ArrayList(mediaList.size) + + for (media in mediaList) { + if (futures.containsKey(media)) { + try { + val bitmap = futures[media]!!.get() + val baos = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos) + + val uri = BlobProvider.getInstance() + .forData(baos.toByteArray()) + .withMimeType(MediaTypes.IMAGE_JPEG) + .createForSingleSessionOnDisk( + context + ) { e: IOException? -> + Log.w( + TAG, + "Failed to write to disk.", + e + ) + } + + val updated = Media( + uri, + media.filename, + MediaTypes.IMAGE_JPEG, + media.date, + bitmap.width, + bitmap.height, + baos.size().toLong(), + media.bucketId, + media.caption + ) + + updatedMedia.add(updated) + renderTimer!!.split("item") + } catch (e: InterruptedException) { + Log.w(TAG, "Failed to render image. Using base image.") + updatedMedia.add(media) + } catch (e: ExecutionException) { + Log.w(TAG, "Failed to render image. Using base image.") + updatedMedia.add(media) + } catch (e: IOException) { + Log.w(TAG, "Failed to render image. Using base image.") + updatedMedia.add(media) + } + } else { + updatedMedia.add(media) + } + } + return updatedMedia + } + + override fun onPostExecute(media: List) { + controller!!.onSendClicked(media, composeText!!.textTrimmed) + cancelRunnableOnMain(progressTimer!!) + loader!!.visibility = View.GONE + renderTimer!!.stop(TAG) + } + }.execute() + } + + fun onRequestFullScreen(fullScreen: Boolean) { + captionAndRail!!.visibility = + if (fullScreen) View.GONE else View.VISIBLE + } + + private inner class FragmentPageChangeListener : SimpleOnPageChangeListener() { + override fun onPageSelected(position: Int) { + viewModel!!.onPageChanged(position) + } + } + + private inner class ComposeKeyPressedListener : View.OnKeyListener, View.OnClickListener, + TextWatcher, OnFocusChangeListener { + var beforeLength: Int = 0 + + override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (isEnterSendsEnabled(requireContext())) { + sendButton!!.dispatchKeyEvent( + KeyEvent( + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_ENTER + ) + ) + sendButton!!.dispatchKeyEvent( + KeyEvent( + KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_ENTER + ) + ) + return true + } + } + } + return false + } + + override fun onClick(v: View) { + hud!!.showSoftkey(composeText) + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + beforeLength = composeText!!.textTrimmed.length + } + + override fun afterTextChanged(s: Editable) { + presentCharactersRemaining() + viewModel!!.onBodyChanged(s) + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + + override fun onFocusChange(v: View, hasFocus: Boolean) {} + } + + interface Controller { + fun onAddMediaClicked(bucketId: String) + fun onSendClicked(media: List, body: String) + fun onNoMediaAvailable() + } + + companion object { + private val TAG: String = MediaSendFragment::class.java.simpleName + + private const val KEY_ADDRESS = "address" + + fun newInstance(recipient: Recipient): MediaSendFragment { + val args = Bundle() + args.putParcelable(KEY_ADDRESS, recipient.address) + + val fragment = MediaSendFragment() + fragment.arguments = args + return fragment + } + + private fun render(context: Context, model: EditorModel): ListenableFuture { + val future = SettableFuture() + + AsyncTask.THREAD_POOL_EXECUTOR.execute { future.set(model.render(context)) } + + return future + } + } +} From 7b8e669eb9ac7247a218e607e36d54342e9d472e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 31 Mar 2025 13:42:36 +1030 Subject: [PATCH 087/867] Android 15 fixes (#1066) * SES-3589 - qa tags * Catering for insets in android 15 * Fixing scrim colour * Adding back loader on images as it is useful for outgoing messages * Catering for keyboard insets due to new target sdk 35 * Using latest webrtc lib * Catering for insets when calculating recyclerview scroll * Enabling predictive back gesture for android 15 devices * Reworking new message fragment for ime handling on all versions including small screens * Removing insets from base app bar as theyare handled by the base activity --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../securesms/BaseActionBarActivity.java | 133 ------------ .../securesms/BaseActionBarActivity.kt | 159 +++++++++++++++ .../securesms/components/ShapeScrim.java | 107 ---------- .../start/newmessage/NewMessage.kt | 193 +++++++++--------- .../conversation/v2/ConversationActivityV2.kt | 8 +- .../v2/ConversationReactionOverlay.kt | 2 +- .../v2/utilities/ThumbnailView.kt | 8 +- .../groups/EnterCommunityUrlFragment.kt | 3 +- .../loadaccount/LoadAccountActivity.kt | 5 + .../securesms/preferences/SettingsActivity.kt | 4 +- .../securesms/ui/components/AppBar.kt | 2 + .../securesms/util/GeneralUtilities.kt | 42 +++- .../res/layout/activity_conversation_v2.xml | 9 - .../layout/conversation_reaction_scrubber.xml | 2 +- app/src/main/res/layout/thumbnail_view.xml | 8 + app/src/main/res/values/attrs.xml | 9 - app/src/main/res/values/colors.xml | 5 +- .../src/main/res/values/strings.xml | 2 + libsession/src/main/res/values/attrs.xml | 9 - 21 files changed, 336 insertions(+), 377 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java diff --git a/app/build.gradle b/app/build.gradle index f277becb2c..dba4704e16 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -305,7 +305,7 @@ dependencies { implementation 'androidx.media3:media3-ui:1.4.0' implementation 'org.conscrypt:conscrypt-android:2.5.2' implementation 'org.signal:aesgcmprovider:0.0.3' - implementation 'io.github.webrtc-sdk:android:125.6422.06.1' + implementation 'io.github.webrtc-sdk:android:125.6422.07' implementation "me.leolin:ShortcutBadger:1.1.16" implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' implementation 'com.github.chrisbanes:PhotoView:2.1.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12a36085de..525ffe2c6d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,6 +81,7 @@ android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_configuration" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" android:theme="@style/Theme.Session.DayNight" tools:replace="android:allowBackup,android:label" > diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java deleted file mode 100644 index 4e385cfe2b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms; - -import static android.os.Build.VERSION.SDK_INT; -import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR; - -import android.app.ActivityManager; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.os.Bundle; -import android.view.WindowManager; - -import androidx.annotation.Nullable; -import androidx.annotation.StyleRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.thoughtcrime.securesms.conversation.v2.WindowUtil; -import org.thoughtcrime.securesms.util.ActivityUtilitiesKt; -import org.thoughtcrime.securesms.util.ThemeState; -import org.thoughtcrime.securesms.util.UiModeUtilities; - -import network.loki.messenger.R; - -public abstract class BaseActionBarActivity extends AppCompatActivity { - private static final String TAG = BaseActionBarActivity.class.getSimpleName(); - public ThemeState currentThemeState; - - private Resources.Theme modifiedTheme; - - private TextSecurePreferences getPreferences() { - ApplicationContext appContext = (ApplicationContext) getApplicationContext(); - return appContext.textSecurePreferences; - } - - @StyleRes - private int getDesiredTheme() { - ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); - int userSelectedTheme = themeState.getTheme(); - - // If the user has configured Session to follow the system light/dark theme mode then do so.. - if (themeState.getFollowSystem()) { - - // Use light or dark versions of the user's theme based on light-mode / dark-mode settings - boolean isDayUi = UiModeUtilities.isDayUiMode(this); - if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) { - return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark; - } else { - return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark; - } - } - else // ..otherwise just return their selected theme. - { - return userSelectedTheme; - } - } - - @StyleRes @Nullable - private Integer getAccentTheme() { - if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null; - ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); - return themeState.getAccentStyle(); - } - - @Override - public Resources.Theme getTheme() { - if (modifiedTheme != null) { - return modifiedTheme; - } - - // New themes - modifiedTheme = super.getTheme(); - modifiedTheme.applyStyle(getDesiredTheme(), true); - Integer accentTheme = getAccentTheme(); - if (accentTheme != null) { - modifiedTheme.applyStyle(accentTheme, true); - } - currentThemeState = ActivityUtilitiesKt.themeState(getPreferences()); - return modifiedTheme; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - } - } - - @Override - protected void onResume() { - super.onResume(); - initializeScreenshotSecurity(true); - String name = getResources().getString(R.string.app_name); - Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground); - int color = getResources().getColor(R.color.app_icon_background); - setTaskDescription(new ActivityManager.TaskDescription(name, icon, color)); - if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) { - recreate(); - } - - // apply lightStatusBar manually as API 26 does not update properly via applyTheme - // https://issuetracker.google.com/issues/65883460?pli=1 - if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this); - if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this); - } - - @Override - protected void onPause() { - super.onPause(); - initializeScreenshotSecurity(false); - } - - @Override - public boolean onSupportNavigateUp() { - if (super.onSupportNavigateUp()) return true; - - onBackPressed(); - return true; - } - - private void initializeScreenshotSecurity(boolean isResume) { - if (!isResume) { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } else { - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt new file mode 100644 index 0000000000..717374eaf8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms + +import android.app.ActivityManager.TaskDescription +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.os.Build.VERSION +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.v2.WindowUtil +import org.thoughtcrime.securesms.util.ThemeState +import org.thoughtcrime.securesms.util.UiModeUtilities.isDayUiMode +import org.thoughtcrime.securesms.util.themeState +import kotlin.math.max + +abstract class BaseActionBarActivity : AppCompatActivity() { + var currentThemeState: ThemeState? = null + + private var modifiedTheme: Resources.Theme? = null + + private val preferences: TextSecurePreferences + get() { + val appContext = + applicationContext as ApplicationContext + return appContext.textSecurePreferences + } + + @get:StyleRes + private val desiredTheme: Int + get() { + val themeState = preferences.themeState() + val userSelectedTheme = themeState.theme + + // If the user has configured Session to follow the system light/dark theme mode then do so.. + if (themeState.followSystem) { + // Use light or dark versions of the user's theme based on light-mode / dark-mode settings + + val isDayUi = isDayUiMode(this) + return if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) { + if (isDayUi) R.style.Ocean_Light else R.style.Ocean_Dark + } else { + if (isDayUi) R.style.Classic_Light else R.style.Classic_Dark + } + } else // ..otherwise just return their selected theme. + { + return userSelectedTheme + } + } + + @get:StyleRes + private val accentTheme: Int? + get() { + if (!preferences.hasPreference(TextSecurePreferences.SELECTED_ACCENT_COLOR)) return null + val themeState = preferences.themeState() + return themeState.accentStyle + } + + override fun getTheme(): Resources.Theme { + if (modifiedTheme != null) { + return modifiedTheme!! + } + + // New themes + modifiedTheme = super.getTheme() + modifiedTheme!!.applyStyle(desiredTheme, true) + val accentTheme = accentTheme + if (accentTheme != null) { + modifiedTheme!!.applyStyle(accentTheme, true) + } + currentThemeState = preferences.themeState() + return modifiedTheme!! + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Enable edge-to-edge - needed for sdk35 and above + WindowCompat.setDecorFitsSystemWindows(window, false) + + + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setHomeButtonEnabled(true) + } + + // Apply insets to your views - Needed for sdk35 and above + val rootView = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, windowInsets -> + // Get system bars insets + val systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Get IME (keyboard) insets + val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + + // Update view padding to account for system bars + view.updatePadding( + left = systemBarsInsets.left, + top = systemBarsInsets.top, + right = systemBarsInsets.right, + bottom = max(systemBarsInsets.bottom, imeInsets.bottom) // set either the padding for the inset or for the keyboard + ) + + // Consume the insets + windowInsets + } + } + + override fun onResume() { + super.onResume() + initializeScreenshotSecurity(true) + val name = resources.getString(R.string.app_name) + val icon = BitmapFactory.decodeResource(resources, R.drawable.ic_launcher_foreground) + val color = resources.getColor(R.color.app_icon_background) + setTaskDescription(TaskDescription(name, icon, color)) + if (currentThemeState != preferences.themeState()) { + recreate() + } + + // apply lightStatusBar manually as API 26 does not update properly via applyTheme + // https://issuetracker.google.com/issues/65883460?pli=1 + if (VERSION.SDK_INT >= 26 && VERSION.SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme( + this + ) + if (VERSION.SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this) + } + + override fun onPause() { + super.onPause() + initializeScreenshotSecurity(false) + } + + override fun onSupportNavigateUp(): Boolean { + if (super.onSupportNavigateUp()) return true + + onBackPressed() + return true + } + + private fun initializeScreenshotSecurity(isResume: Boolean) { + if (!isResume) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + companion object { + private val TAG: String = BaseActionBarActivity::class.java.simpleName + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java deleted file mode 100644 index b4239ecdd9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.util.AttributeSet; -import android.view.View; - -import network.loki.messenger.R; - -public class ShapeScrim extends View { - - private enum ShapeType { - CIRCLE, SQUARE - } - - private final Paint eraser; - private final ShapeType shape; - private final float radius; - - private Bitmap scrim; - private Canvas scrimCanvas; - - public ShapeScrim(Context context) { - this(context, null); - } - - public ShapeScrim(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ShapeScrim, 0, 0); - String shapeName = typedArray.getString(R.styleable.ShapeScrim_shape); - - if ("square".equalsIgnoreCase(shapeName)) this.shape = ShapeType.SQUARE; - else if ("circle".equalsIgnoreCase(shapeName)) this.shape = ShapeType.CIRCLE; - else this.shape = ShapeType.SQUARE; - - this.radius = typedArray.getFloat(R.styleable.ShapeScrim_radius, 0.4f); - - typedArray.recycle(); - } else { - this.shape = ShapeType.SQUARE; - this.radius = 0.4f; - } - - this.eraser = new Paint(); - this.eraser.setColor(0xFFFFFFFF); - this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - } - - @Override - public void onDraw(Canvas canvas) { - super.onDraw(canvas); - - int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight(); - float drawRadius = shortDimension * radius; - - if (scrimCanvas == null) { - scrim = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - scrimCanvas = new Canvas(scrim); - } - - scrim.eraseColor(Color.TRANSPARENT); - scrimCanvas.drawColor(Color.parseColor("#55BDBDBD")); - - if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser); - else drawSquare(scrimCanvas, drawRadius, eraser); - - canvas.drawBitmap(scrim, 0, 0, null); - } - - @Override - public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { - super.onSizeChanged(width, height, oldHeight, oldHeight); - - if (width != oldWidth || height != oldHeight) { - scrim = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - scrimCanvas = new Canvas(scrim); - } - } - - private void drawCircle(Canvas canvas, float radius, Paint eraser) { - canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, eraser); - } - - private void drawSquare(Canvas canvas, float radius, Paint eraser) { - float left = (getWidth() / 2 ) - radius; - float top = (getHeight() / 2) - radius; - float right = left + (radius * 2); - float bottom = top + (radius * 2); - - RectF square = new RectF(left, top, right, bottom); - - canvas.drawRoundRect(square, 25, 25, eraser); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index 46e45045e1..22480a0965 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -1,16 +1,15 @@ package org.thoughtcrime.securesms.conversation.start.newmessage import android.graphics.Rect -import android.os.Build import android.view.ViewTreeObserver import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -18,32 +17,28 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar @@ -60,7 +55,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import kotlin.math.max private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) @@ -106,82 +100,83 @@ private fun EnterAccountId( callbacks: Callbacks, onHelp: () -> Unit = {} ) { - // the scaffold is required to provide the contentPadding. That contentPadding is needed - // to properly handle the ime padding. - Scaffold() { contentPadding -> - // we need this extra surface to handle nested scrolling properly, - // because this scrollable component is inside a bottomSheet dialog which is itself scrollable - Surface( - modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), - color = LocalColors.current.backgroundSecondary - ) { - - var accountModifier = Modifier - .fillMaxSize() + // Get accurate IME height + val keyboardHeight by keyboardHeightState() + val isKeyboardVisible = keyboardHeight > 0.dp + + // Use a Column as the main container + Column( + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.backgroundSecondary) + ) { + // Scrollable content area + Column( + modifier = Modifier + .weight(1f) .verticalScroll(rememberScrollState()) + .padding(vertical = LocalDimensions.current.spacing) + ) { + // Input field + SessionOutlinedTextField( + text = state.newMessageIdOrOns, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .qaTag(stringResource(R.string.AccessibilityId_sessionIdInput)), + placeholder = stringResource(R.string.accountIdOrOnsEnter), + onChange = callbacks::onChange, + onContinue = callbacks::onContinue, + error = state.error?.string(), + isTextErrorColor = state.isTextErrorColor + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // Help button + BorderlessButtonWithIcon( + text = stringResource(R.string.messageNewDescriptionMobile), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_messageNewDescriptionMobile) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth(), + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + iconRes = R.drawable.ic_circle_help, + onClick = onHelp + ) + } - // There is a known issue with the ime padding on android versions below 30 - // So on these older versions we need to resort to some manual padding based on the visible height - // when the keyboard is up - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - val keyboardHeight by keyboardHeight() - accountModifier = accountModifier.padding(bottom = keyboardHeight) - } else { - accountModifier = accountModifier - .consumeWindowInsets(contentPadding) - .imePadding() - } - - Column( - modifier = accountModifier - ) { - Column( - modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SessionOutlinedTextField( - text = state.newMessageIdOrOns, - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .qaTag(stringResource(R.string.AccessibilityId_sessionIdInput)), - placeholder = stringResource(R.string.accountIdOrOnsEnter), - onChange = callbacks::onChange, - onContinue = callbacks::onContinue, - error = state.error?.string(), - isTextErrorColor = state.isTextErrorColor - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) - - BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_messageNewDescriptionMobile) - .padding(horizontal = LocalDimensions.current.mediumSpacing) - .fillMaxWidth(), - style = LocalType.current.small, - color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_help, - onClick = onHelp - ) - } - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Spacer(Modifier.weight(2f)) - - PrimaryOutlineButton( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = LocalDimensions.current.xlargeSpacing) - .padding(bottom = LocalDimensions.current.smallSpacing) - .fillMaxWidth() - .contentDescription(R.string.next), - enabled = state.isNextButtonEnabled, - onClick = callbacks::onContinue - ) { - LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) + // Add extra space at the bottom to prevent content from being hidden by the button + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Button container that responds to keyboard visibility + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + start = LocalDimensions.current.xlargeSpacing, + end = LocalDimensions.current.xlargeSpacing, + bottom = LocalDimensions.current.smallSpacing + ) + // Apply keyboard padding + .then( + if (isKeyboardVisible) { + Modifier.padding(bottom = keyboardHeight) + } else { + Modifier.navigationBarsPadding() } + ) + ) { + // Next button + PrimaryOutlineButton( + modifier = Modifier + .fillMaxWidth() + .contentDescription(R.string.next), + enabled = state.isNextButtonEnabled, + onClick = callbacks::onContinue + ) { + LoadingArcOr(state.loading) { + Text(stringResource(R.string.next)) } } } @@ -189,24 +184,38 @@ private fun EnterAccountId( } @Composable -fun keyboardHeight(): MutableState { +fun keyboardHeightState(): androidx.compose.runtime.State { val view = LocalView.current - var keyboardHeight = remember { mutableStateOf(0.dp) } + val keyboardHeight = remember { mutableStateOf(0.dp) } val density = LocalDensity.current + val context = LocalContext.current DisposableEffect(view) { + val rootView = view.rootView + val rect = Rect() + val listener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height * PEEK_RATIO - val keypadHeightPx = max( screenHeight - rect.bottom, 0f) + rootView.getWindowVisibleDisplayFrame(rect) + val screenHeight = rootView.height - keyboardHeight.value = with(density) { keypadHeightPx.toDp() } + // Get the system window insets to account for status bar, navigation bar, etc. + val windowInsets = ViewCompat.getRootWindowInsets(rootView) + val systemBarsBottom = windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + + // Calculate keyboard height taking into account the system bars + val keyboardHeightPx = screenHeight - rect.bottom - systemBarsBottom + + // Only consider as keyboard if height is significant + if (keyboardHeightPx > screenHeight * 0.15) { + keyboardHeight.value = with(density) { keyboardHeightPx.toDp() } + } else { + keyboardHeight.value = 0.dp + } } - view.viewTreeObserver.addOnGlobalLayoutListener(listener) + rootView.viewTreeObserver.addOnGlobalLayoutListener(listener) onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(listener) + rootView.viewTreeObserver.removeOnGlobalLayoutListener(listener) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 4b51ae7dc2..c2f7bd1b3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -193,9 +193,11 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.drawToBitmap import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut +import org.thoughtcrime.securesms.util.isFullyScrolled import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.scrollAmount import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity @@ -1540,16 +1542,12 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } emojiPickerVisible = true ViewUtil.hideKeyboard(this, messageView) - binding.reactionsShade.isVisible = true binding.scrollToBottomButton.isVisible = false binding.conversationRecyclerView.suppressLayout(true) reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message)) reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener { override fun startHide() { emojiPickerVisible = false - binding.reactionsShade.let { - ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) - } showScrollToBottomButtonIfApplicable() } @@ -2611,7 +2609,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() { override fun onChanged() { super.onChanged() - if (recyclerView.isScrolledToWithin30dpOfBottom) { + if (recyclerView.isScrolledToWithin30dpOfBottom && !recyclerView.isFullyScrolled) { // Note: The adapter itemCount is zero based - so calling this with the itemCount in // a non-zero based manner scrolls us to the bottom of the last message (including // to the bottom of long messages as required by Jira SES-789 / GitHub 1364). diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index e6694d002e..8b0aa81a8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -333,7 +333,7 @@ class ConversationReactionOverlay : FrameLayout { private fun updateSystemUiOnShow(activity: Activity) { val window = activity.window - val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color) + val barColor = ContextCompat.getColor(context, R.color.conversation_overlay_scrim) originalStatusBarColor = window.statusBarColor WindowUtil.setStatusBarColor(window, barColor) originalNavigationBarColor = window.navigationBarColor diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 8a5b894a01..4d4e21d5f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -47,6 +47,8 @@ open class ThumbnailView @JvmOverloads constructor( private val dimensDelegate = ThumbnailDimensDelegate() + val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } + private var slide: Slide? = null private val errorDrawable by lazy { @@ -144,6 +146,8 @@ open class ThumbnailView @JvmOverloads constructor( this.slide = slide + binding.thumbnailLoadIndicator.isVisible = slide.isInProgress + dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() @@ -151,7 +155,7 @@ open class ThumbnailView @JvmOverloads constructor( when { slide.thumbnailUri != null -> { buildThumbnailGlideRequest(glide, slide).into( - GlideDrawableListeningTarget(binding.thumbnailImage, null, it) + GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it) ) } slide.hasPlaceholder() -> { @@ -201,7 +205,7 @@ open class ThumbnailView @JvmOverloads constructor( private fun RequestBuilder.intoDrawableTargetAsFuture() = SettableFuture().also { binding.run { - GlideDrawableListeningTarget(thumbnailImage, null, it) + GlideDrawableListeningTarget(thumbnailImage, binding.thumbnailLoadIndicator, it) }.let { into(it) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt index 26c9fd1fdd..4e2852b242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups +import android.content.Context.INPUT_METHOD_SERVICE import android.graphics.BitmapFactory import android.os.Bundle import android.view.LayoutInflater @@ -82,7 +83,7 @@ class EnterCommunityUrlFragment : Fragment() { // region Convenience private fun joinCommunityIfPossible() { - val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager + val inputMethodManager = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.hideSoftInputFromWindow(binding.communityUrlEditText.windowToken, 0) val communityUrl = binding.communityUrlEditText.text.trim().toString().lowercase(Locale.US) delegate?.handleCommunityUrlEntered(communityUrl) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt index 39b119e5b6..2e4da626fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -1,9 +1,14 @@ package org.thoughtcrime.securesms.onboarding.loadaccount import android.os.Bundle +import android.view.View import androidx.activity.viewModels import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index fe6a28c16c..8a169bfb51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -601,7 +601,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable - fun AvatarBottomSheet( + fun AvatarBottomSheet( showCamera: Boolean, onDismissRequest: () -> Unit, onGalleryPicked: () -> Unit, @@ -618,6 +618,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing) ) { AvatarOption( + modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_imageButton)), title = stringResource(R.string.image), iconRes = R.drawable.ic_image, onClick = onGalleryPicked @@ -625,6 +626,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { if(showCamera) { AvatarOption( + modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_cameraButton)), title = stringResource(R.string.contentDescriptionCamera), iconRes = R.drawable.ic_camera, onClick = onCameraPicked diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index a9a1d55d96..4183c1fbc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -81,6 +82,7 @@ fun BasicAppBar( ) { CenterAlignedTopAppBar( modifier = modifier, + windowInsets = WindowInsets(0, 0, 0, 0), // insets handled in BaseActionBarActivity for now title = { AppBarText(title = title, singleLine = singleLine) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index cc40e0cc92..9fbac1ab90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.util import android.content.res.Resources +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.RecyclerView import kotlin.math.roundToInt @@ -28,6 +30,40 @@ val RecyclerView.isScrolledToBottom: Boolean toPx(50, resources) >= computeVerticalScrollRange() val RecyclerView.isScrolledToWithin30dpOfBottom: Boolean - get() = computeVerticalScrollOffset().coerceAtLeast(0) + - computeVerticalScrollExtent() + - toPx(30, resources) >= computeVerticalScrollRange() \ No newline at end of file + get() { + // Retrieve the bottom inset from the window insets, if available. + val bottomInset = ViewCompat.getRootWindowInsets(this) + ?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + + return computeVerticalScrollOffset().coerceAtLeast(0) + + computeVerticalScrollExtent() + + toPx(30, resources) + + bottomInset >= computeVerticalScrollRange() + } + + +val RecyclerView.isFullyScrolled: Boolean + get() { + val scrollOffset = computeVerticalScrollOffset().coerceAtLeast(0) + val scrollExtent = computeVerticalScrollExtent() + val scrollRange = computeVerticalScrollRange() + + /// Retrieve the bottom inset from the window insets, if available. + val bottomInset = ViewCompat.getRootWindowInsets(this) + ?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + + // We're at the bottom if the offset + extent equals the range (accounting for insets) + return scrollOffset + scrollExtent >= scrollRange - bottomInset + } + +val RecyclerView.scrollAmount: Int + get() { + val scrollOffset = computeVerticalScrollOffset().coerceAtLeast(0) + val scrollExtent = computeVerticalScrollExtent() + val scrollRange = computeVerticalScrollRange() + + + // We're at the bottom if the offset + extent equals the range + return scrollOffset + scrollExtent - scrollRange + } + diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 0fc8401524..f935a44b0f 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -73,15 +73,6 @@ app:layout_constraintBottom_toBottomOf="parent" android:visibility="gone"/> - - diff --git a/app/src/main/res/layout/thumbnail_view.xml b/app/src/main/res/layout/thumbnail_view.xml index ce6575f7a3..f6c371e663 100644 --- a/app/src/main/res/layout/thumbnail_view.xml +++ b/app/src/main/res/layout/thumbnail_view.xml @@ -17,6 +17,14 @@ android:scaleType="center" android:contentDescription="@string/AccessibilityId_mediaMessage" /> + + - - - - @@ -132,11 +128,6 @@ - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d2b67f279b..b3227b2491 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -39,6 +39,8 @@ #dfffffff #90000000 + #df5e5e5e + #26ffffff #30ffffff #40ffffff @@ -62,9 +64,6 @@ @color/transparent_black_15 - @color/transparent_black_70 - #df5e5e5e - @color/core_grey_95 @color/core_grey_60 diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index f1c40f0aa9..45f28ca40c 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -49,6 +49,8 @@ Blocked contacts Account ID + Image button + Camera button Clear all No pending message requests diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 0cdcb5a5ac..c378cf12be 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -10,10 +10,6 @@ - - - - @@ -77,11 +73,6 @@ - - - - - From 87851447315f7d3238170f190800b408036773dd Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:12:09 +1100 Subject: [PATCH 088/867] [SES-3368] - Fix image partial loading issue (#1046) --- .../thoughtcrime/securesms/ShareActivity.kt | 3 ++- .../attachments/DatabaseAttachmentProvider.kt | 9 ++------ .../securesms/audio/AudioRecorder.java | 19 +++++++++------- .../securesms/events/PartProgressEvent.java | 19 ---------------- .../securesms/giph/ui/GiphyActivity.java | 3 ++- .../securesms/mediasend/MediaSendActivity.kt | 4 ++-- .../securesms/mediasend/MediaSendFragment.kt | 13 +++++------ .../securesms/providers/BlobProvider.java | 20 ++++++++++++----- .../messaging/jobs/AttachmentUploadJob.kt | 4 ++-- .../attachments/SessionServiceAttachment.kt | 8 +------ .../SessionServiceAttachmentStream.kt | 4 ++-- .../utilities/ProfilePictureUtilities.kt | 1 - .../org/session/libsession/utilities/Util.kt | 15 +++++-------- .../messages/SignalServiceAttachment.java | 22 +------------------ .../SignalServiceAttachmentStream.java | 5 +---- .../streams/DigestingRequestBody.java | 11 +--------- .../utilities/PushAttachmentData.java | 9 +------- 17 files changed, 52 insertions(+), 117 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt index 3ba12796bb..4a2a6c1b4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -297,7 +297,8 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { .withMimeType(mimeType!!) .withFileName(fileName!!) .createForMultipleSessionsOnDisk(context, BlobProvider.ErrorListener { e: IOException? -> Log.w(TAG, "Failed to write to disk.", e) }) - } catch (ioe: IOException) { + .get() + } catch (ioe: Exception) { Log.w(TAG, ioe) return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 05b3b176ea..814116adfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.attachments import android.content.Context import android.text.TextUtils import com.google.protobuf.ByteString -import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.MarkAsDeletedMessage @@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.events.PartProgressEvent import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil @@ -345,7 +343,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) .withWidth(attachment.width) .withHeight(attachment.height) .withCaption(attachment.caption) - .withListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(attachment, total, progress)) } .build() } catch (ioe: IOException) { Log.w("Loki", "Couldn't open attachment", ioe) @@ -365,9 +362,8 @@ fun SessionServiceAttachmentPointer.toSignalPointer(): SignalServiceAttachmentPo fun DatabaseAttachment.toAttachmentStream(context: Context): SessionServiceAttachmentStream { val stream = PartAuthority.getAttachmentStream(context, this.dataUri!!) - val listener = SignalServiceAttachment.ProgressListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(this, total, progress))} - var attachmentStream = SessionServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption), listener) + var attachmentStream = SessionServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption)) attachmentStream.attachmentId = this.attachmentId.rowId attachmentStream.isAudio = MediaUtil.isAudio(this) attachmentStream.isGif = MediaUtil.isGif(this) @@ -409,9 +405,8 @@ fun DatabaseAttachment.toSignalAttachmentPointer(): SignalServiceAttachmentPoint fun DatabaseAttachment.toSignalAttachmentStream(context: Context): SignalServiceAttachmentStream { val stream = PartAuthority.getAttachmentStream(context, this.dataUri!!) - val listener = SignalServiceAttachment.ProgressListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(this, total, progress))} - return SignalServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption), listener) + return SignalServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption)) } fun DatabaseAttachment.shouldHaveImageSize(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 373c76857b..1eb663c3d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -6,7 +6,10 @@ import android.util.Pair; import androidx.annotation.NonNull; import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + import org.session.libsession.utilities.MediaTypes; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.ListenableFuture; @@ -25,7 +28,7 @@ public class AudioRecorder { private final Context context; private AudioCodec audioCodec; - private Uri captureUri; + private Future blobWritingTask; // Simple interface that allows us to provide a callback method to our `startRecording` method public interface AudioMessageRecordingFinishedCallback { @@ -49,7 +52,7 @@ public void startRecording(AudioMessageRecordingFinishedCallback callback) { ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); - captureUri = BlobProvider.getInstance() + blobWritingTask = BlobProvider.getInstance() .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .withMimeType(MediaTypes.AUDIO_AAC) .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); @@ -70,14 +73,14 @@ public void startRecording(AudioMessageRecordingFinishedCallback callback) { final SettableFuture> future = new SettableFuture<>(); executor.execute(() -> { - if (audioCodec == null) { + if (audioCodec == null || blobWritingTask == null) { sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!")); return; } audioCodec.stop(); - try { + final Uri captureUri = blobWritingTask.get(); long size = 0L; // Only obtain the media size if the voice message was at least our minimum allowed // duration (bypassing this work prevents the audio recording mechanism from getting into @@ -86,13 +89,13 @@ public void startRecording(AudioMessageRecordingFinishedCallback callback) { size = MediaUtil.getMediaSize(context, captureUri); } sendToFuture(future, new Pair<>(captureUri, size)); - } catch (IOException ioe) { - Log.w(TAG, ioe); - sendToFuture(future, ioe); + } catch (IOException | ExecutionException | InterruptedException e) { + Log.w(TAG, e); + sendToFuture(future, e); } audioCodec = null; - captureUri = null; + blobWritingTask = null; }); return future; diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java deleted file mode 100644 index 1be748f54b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.thoughtcrime.securesms.events; - - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; - -public class PartProgressEvent { - - public final Attachment attachment; - public final long total; - public final long progress; - - public PartProgressEvent(@NonNull Attachment attachment, long total, long progress) { - this.attachment = attachment; - this.total = total; - this.progress = progress; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index 14ed428371..68257fb0a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -112,7 +112,8 @@ protected Uri doInBackground(Void... params) { return BlobProvider.getInstance() .forData(data) .withMimeType(MediaTypes.IMAGE_GIF) - .createForSingleSessionOnDisk(GiphyActivity.this, e -> Log.w(TAG, "Failed to write to disk.", e)); + .createForSingleSessionOnDisk(GiphyActivity.this, e -> Log.w(TAG, "Failed to write to disk.", e)) + .get(); } catch (InterruptedException | ExecutionException | IOException e) { Log.w(TAG, e); return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 9dbceca531..c5ab98cb2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -244,7 +244,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme "Failed to write to disk.", e ) - } + }.get() return@run Media( uri, @@ -257,7 +257,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme Optional.of(Media.ALL_MEDIA_BUCKET_ID), Optional.absent() ) - } catch (e: IOException) { + } catch (e: Exception) { return@run null } }, { media: Media? -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 58dca20cd2..56f64a8ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -20,6 +20,7 @@ import android.view.inputmethod.EditorInfo import android.widget.ImageButton import android.widget.TextView import android.widget.TextView.OnEditorActionListener +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager @@ -28,6 +29,7 @@ import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R +import org.session.libsession.utilities.Address import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.TextSecurePreferences.Companion.isEnterSendsEnabled import org.session.libsession.utilities.Util.cancelRunnableOnMain @@ -370,6 +372,7 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, e ) } + .get() val updated = Media( uri, @@ -385,14 +388,8 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, updatedMedia.add(updated) renderTimer!!.split("item") - } catch (e: InterruptedException) { - Log.w(TAG, "Failed to render image. Using base image.") - updatedMedia.add(media) - } catch (e: ExecutionException) { - Log.w(TAG, "Failed to render image. Using base image.") - updatedMedia.add(media) - } catch (e: IOException) { - Log.w(TAG, "Failed to render image. Using base image.") + } catch (e: Exception) { + Log.w(TAG, "Failed to render image. Using base image.", e) updatedMedia.add(media) } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 6d900a5b37..15240e4d3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -25,6 +25,10 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Future; + +import kotlin.Pair; +import kotlin.Result; /** * Allows for the creation and retrieval of blobs. @@ -173,23 +177,27 @@ public static boolean isAuthority(@NonNull Uri uri) { } @WorkerThread - private synchronized @NonNull Uri writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { + @NonNull + private static Future writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); String directory = getDirectory(blobSpec.getStorageType()); File outputFile = new File(getOrCreateCacheDirectory(context, directory), buildFileName(blobSpec.id)); OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; - SignalExecutors.UNBOUNDED.execute(() -> { + final Uri uri = buildUri(blobSpec); + + return SignalExecutors.UNBOUNDED.submit(() -> { try { Util.copy(blobSpec.getData(), outputStream); + return uri; } catch (IOException e) { if (errorListener != null) { errorListener.onError(e); } + + throw e; } }); - - return buildUri(blobSpec); } private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { @@ -258,7 +266,7 @@ protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { * period from one {@link Application#onCreate()} to the next. */ @WorkerThread - public Uri createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public Future createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), errorListener); } @@ -267,7 +275,7 @@ public Uri createForSingleSessionOnDisk(@NonNull Context context, @Nullable Erro * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. */ @WorkerThread - public Uri createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public Future createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), errorListener); } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index d1edfb240b..561b166739 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -96,9 +96,9 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val outputStreamFactory = if (encrypt) AttachmentCipherOutputStreamFactory(key) else PlaintextOutputStreamFactory() // Create a digesting request body but immediately read it out to a buffer. Doing this makes // it easier to deal with inputStream and outputStreamFactory. - val pad = PushAttachmentData(attachment.contentType, inputStream, length, outputStreamFactory, attachment.listener) + val pad = PushAttachmentData(attachment.contentType, inputStream, length, outputStreamFactory) val contentType = "application/octet-stream" - val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, contentType, pad.dataSize, pad.listener) + val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, contentType, pad.dataSize) Log.d("Loki", "File size: ${length.toDouble() / 1000} kb.") val b = Buffer() drb.writeTo(b) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt index 1f493a0a86..698bf2dc8c 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt @@ -34,7 +34,6 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S private var contentType: String? = null private var filename: String = "PlaceholderFilename" private var length: Long = 0 - private var listener: SignalServiceAttachment.ProgressListener? = null private var voiceNote = false private var width = 0 private var height = 0 @@ -59,11 +58,6 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S return this } - fun withListener(listener: SignalServiceAttachment.ProgressListener?): Builder { - this.listener = listener - return this - } - fun withVoiceNote(voiceNote: Boolean): Builder { this.voiceNote = voiceNote return this @@ -88,7 +82,7 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S requireNotNull(inputStream) { "Must specify stream!" } requireNotNull(contentType) { "No content type specified!" } require(length != 0L) { "No length specified!" } - return SessionServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption), listener) + return SessionServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption)) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt index 4e881e4b01..4af1d16acc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt @@ -16,9 +16,9 @@ import kotlin.math.round /** * Represents a local SignalServiceAttachment to be sent. */ -class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: String?, val length: Long, val filename: String, val voiceNote: Boolean, val preview: Optional, val width: Int, val height: Int, val caption: Optional, val listener: SAttachment.ProgressListener?) : SessionServiceAttachment(contentType) { +class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: String?, val length: Long, val filename: String, val voiceNote: Boolean, val preview: Optional, val width: Int, val height: Int, val caption: Optional) : SessionServiceAttachment(contentType) { - constructor(inputStream: InputStream?, contentType: String?, length: Long, filename: String, voiceNote: Boolean, listener: SAttachment.ProgressListener?) : this(inputStream, contentType, length, filename, voiceNote, Optional.absent(), 0, 0, Optional.absent(), listener) {} + constructor(inputStream: InputStream?, contentType: String?, length: Long, filename: String, voiceNote: Boolean) : this(inputStream, contentType, length, filename, voiceNote, Optional.absent(), 0, 0, Optional.absent()) {} // Though now required, `digest` may be null for pre-existing records or from // messages received from other clients diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt index c5e4e82d53..73c45d787e 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt @@ -99,7 +99,6 @@ object ProfilePictureUtilities { pad.outputStreamFactory, pad.contentType, pad.dataLength, - null ) val b = Buffer() drb.writeTo(b) diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index e0c47c34c7..58d6d4e0b6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -44,17 +44,12 @@ object Util { @JvmStatic @Throws(IOException::class) - fun copy(`in`: InputStream, out: OutputStream?): Long { - val buffer = ByteArray(8192) - var read: Int - var total: Long = 0 - while (`in`.read(buffer).also { read = it } != -1) { - out?.write(buffer, 0, read) - total += read.toLong() + fun copy(src: InputStream, dst: OutputStream): Long { + return src.use { + dst.use { + src.copyTo(dst) + } } - `in`.close() - out?.close() - return total } @JvmStatic diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java index 07584a1c48..493135ccf0 100644 --- a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java +++ b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java @@ -43,7 +43,6 @@ public static class Builder { private String contentType; private String filename; private long length; - private ProgressListener listener; private boolean voiceNote; private int width; private int height; @@ -71,11 +70,6 @@ public Builder withFileName(String filename) { return this; } - public Builder withListener(ProgressListener listener) { - this.listener = listener; - return this; - } - public Builder withVoiceNote(boolean voiceNote) { this.voiceNote = voiceNote; return this; @@ -101,21 +95,7 @@ public SignalServiceAttachmentStream build() { if (contentType == null) throw new IllegalArgumentException("No content type specified!"); if (length == 0) throw new IllegalArgumentException("No length specified!"); - return new SignalServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption), listener); + return new SignalServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption)); } } - - /** - * An interface to receive progress information on upload/download of - * an attachment. - */ - public interface ProgressListener { - /** - * Called on a progress change event. - * - * @param total The total amount to transmit/receive in bytes. - * @param progress The amount that has been transmitted/received in bytes thus far - */ - public void onAttachmentProgress(long total, long progress); - } } diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java index 63be579788..062df319d3 100644 --- a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java +++ b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java @@ -14,19 +14,17 @@ public class SignalServiceAttachmentStream extends SignalServiceAttachment { private final InputStream inputStream; private final long length; private final String filename; - private final ProgressListener listener; private final Optional preview; private final boolean voiceNote; private final int width; private final int height; private final Optional caption; - public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, String filename, boolean voiceNote, Optional preview, int width, int height, Optional caption, ProgressListener listener) { + public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, String filename, boolean voiceNote, Optional preview, int width, int height, Optional caption) { super(contentType); this.inputStream = inputStream; this.length = length; this.filename = filename; - this.listener = listener; this.voiceNote = voiceNote; this.preview = preview; this.width = width; @@ -43,7 +41,6 @@ public SignalServiceAttachmentStream(InputStream inputStream, String contentType public InputStream getInputStream() { return inputStream; } public long getLength() { return length; } public String getFilename() { return filename; } - public ProgressListener getListener() { return listener; } public Optional getPreview() { return preview; } public boolean getVoiceNote() { return voiceNote; } public int getWidth() { return width; } diff --git a/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java b/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java index 4eb739368a..6e7d1f5514 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java @@ -1,7 +1,5 @@ package org.session.libsignal.streams; -import org.session.libsignal.messages.SignalServiceAttachment.ProgressListener; - import java.io.IOException; import java.io.InputStream; @@ -15,20 +13,17 @@ public class DigestingRequestBody extends RequestBody { private final OutputStreamFactory outputStreamFactory; private final String contentType; private final long contentLength; - private final ProgressListener progressListener; private byte[] digest; public DigestingRequestBody(InputStream inputStream, OutputStreamFactory outputStreamFactory, - String contentType, long contentLength, - ProgressListener progressListener) + String contentType, long contentLength) { this.inputStream = inputStream; this.outputStreamFactory = outputStreamFactory; this.contentType = contentType; this.contentLength = contentLength; - this.progressListener = progressListener; } @Override @@ -47,10 +42,6 @@ public void writeTo(BufferedSink sink) throws IOException { while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { outputStream.write(buffer, 0, read); total += read; - - if (progressListener != null) { - progressListener.onAttachmentProgress(contentLength, total); - } } outputStream.flush(); diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java b/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java index b0a6cd54d3..19d83f08fa 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java +++ b/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java @@ -6,7 +6,6 @@ package org.session.libsignal.utilities; -import org.session.libsignal.messages.SignalServiceAttachment.ProgressListener; import org.session.libsignal.streams.OutputStreamFactory; import java.io.InputStream; @@ -17,16 +16,14 @@ public class PushAttachmentData { private final InputStream data; private final long dataSize; private final OutputStreamFactory outputStreamFactory; - private final ProgressListener listener; public PushAttachmentData(String contentType, InputStream data, long dataSize, - OutputStreamFactory outputStreamFactory, ProgressListener listener) + OutputStreamFactory outputStreamFactory) { this.contentType = contentType; this.data = data; this.dataSize = dataSize; this.outputStreamFactory = outputStreamFactory; - this.listener = listener; } public String getContentType() { @@ -44,8 +41,4 @@ public long getDataSize() { public OutputStreamFactory getOutputStreamFactory() { return outputStreamFactory; } - - public ProgressListener getListener() { - return listener; - } } From b4a9b22529b731e39a7add718c9e5e703cd86333 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 1 Apr 2025 11:30:50 +1030 Subject: [PATCH 089/867] Fixing gradient issue on older android versions (#1069) paprently older versions of android can't use gradients with a mix of hex colors and dynamic theme attributes --- app/src/main/res/drawable/fade_gradient.xml | 2 +- app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/themes.xml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/drawable/fade_gradient.xml b/app/src/main/res/drawable/fade_gradient.xml index 0a4c6bc9de..c2a6a33eac 100644 --- a/app/src/main/res/drawable/fade_gradient.xml +++ b/app/src/main/res/drawable/fade_gradient.xml @@ -5,7 +5,7 @@ diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 28811cb15d..52cc05d918 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -23,6 +23,7 @@ + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f813274526..bd4b0b416d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -43,6 +43,7 @@ ?colorPrimary ?colorAccent ?danger + @color/transparent @style/MenuTextAppearance From 6aa0024aa98f1295d13a9c8b397b2f38c09ad386 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 1 Apr 2025 13:35:26 +1030 Subject: [PATCH 090/867] Searching for "Note to self" should show note to self in search results (#1070) --- .../org/thoughtcrime/securesms/home/HomeActivity.kt | 7 ++++++- .../securesms/home/search/GlobalSearchResult.kt | 3 ++- .../securesms/home/search/GlobalSearchViewModel.kt | 12 +++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index d8d32cf5c8..a5a6713912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -269,7 +269,12 @@ class HomeActivity : ScreenLockActionBarActivity(), addAll(result.groupedContacts) } else -> buildList { - result.contactAndGroupList.takeUnless { it.isEmpty() }?.let { + val conversations = result.contactAndGroupList.toMutableList() + if(result.showNoteToSelf){ + conversations.add(GlobalSearchAdapter.Model.SavedMessages(publicKey)) + } + + conversations.takeUnless { it.isEmpty() }?.let { add(GlobalSearchAdapter.Model.Header(R.string.sessionConversations)) addAll(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt index 29e11067a0..c2c5f01a20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -9,7 +9,8 @@ data class GlobalSearchResult( val query: String, val contacts: List = emptyList(), val threads: List = emptyList(), - val messages: List = emptyList() + val messages: List = emptyList(), + val showNoteToSelf: Boolean = false ) { val isEmpty: Boolean get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index dd94bf04d7..1afd54f92e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -56,6 +57,8 @@ class GlobalSearchViewModel @Inject constructor( configFactory.configUpdateNotifications ) + val noteToSelfString by lazy { application.getString(R.string.noteToSelf).lowercase() } + val result = combine( _queryText, observeChangesAffectingSearch().onStart { emit(Unit) } @@ -73,7 +76,14 @@ class GlobalSearchViewModel @Inject constructor( ) } } else { - searchRepository.suspendQuery(query).toGlobalSearchResult() + val results = searchRepository.suspendQuery(query).toGlobalSearchResult() + + // show "Note to Self" is the user searches for parts of"Note to Self" + if(noteToSelfString.contains(query.lowercase())){ + results.copy(showNoteToSelf = true) + } else { + results + } } } catch (e: Exception) { Log.e("GlobalSearchViewModel", "Error searching len = ${query.length}", e) From f2cf7565e510479c5ede63e93e8e9df7d1839dd8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 1 Apr 2025 15:47:00 +1030 Subject: [PATCH 091/867] Konvert searchRepository (#1071) --- .../securesms/search/SearchRepository.java | 285 ------------------ .../securesms/search/SearchRepository.kt | 262 ++++++++++++++++ 2 files changed, 262 insertions(+), 285 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java deleted file mode 100644 index 3da8f99c21..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ /dev/null @@ -1,285 +0,0 @@ -package org.thoughtcrime.securesms.search; - -import android.content.Context; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.MergeCursor; -import androidx.annotation.NonNull; -import com.annimon.stream.Stream; -import org.session.libsession.messaging.contacts.Contact; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.GroupRecord; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.contacts.ContactAccessor; -import org.thoughtcrime.securesms.database.CursorList; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.SearchDatabase; -import org.thoughtcrime.securesms.database.SessionContactDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.thoughtcrime.securesms.search.model.MessageResult; -import org.thoughtcrime.securesms.search.model.SearchResult; -import org.thoughtcrime.securesms.util.Stopwatch; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Executor; -import kotlin.Pair; - -// Class to manage data retrieval for search -public class SearchRepository { - private static final String TAG = SearchRepository.class.getSimpleName(); - - private static final Set BANNED_CHARACTERS = new HashSet<>(); - static { - // Construct a list containing several ranges of invalid ASCII characters - // See: https://www.ascii-code.com/ - for (int i = 33; i <= 47; i++) { BANNED_CHARACTERS.add((char) i); } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., / - for (int i = 58; i <= 64; i++) { BANNED_CHARACTERS.add((char) i); } // :, ;, <, =, >, ?, @ - for (int i = 91; i <= 96; i++) { BANNED_CHARACTERS.add((char) i); } // [, \, ], ^, _, ` - for (int i = 123; i <= 126; i++) { BANNED_CHARACTERS.add((char) i); } // {, |, }, ~ - } - - private final Context context; - private final SearchDatabase searchDatabase; - private final ThreadDatabase threadDatabase; - private final GroupDatabase groupDatabase; - private final SessionContactDatabase contactDatabase; - private final ContactAccessor contactAccessor; - private final Executor executor; - - public SearchRepository(@NonNull Context context, - @NonNull SearchDatabase searchDatabase, - @NonNull ThreadDatabase threadDatabase, - @NonNull GroupDatabase groupDatabase, - @NonNull SessionContactDatabase contactDatabase, - @NonNull ContactAccessor contactAccessor, - @NonNull Executor executor) - { - this.context = context.getApplicationContext(); - this.searchDatabase = searchDatabase; - this.threadDatabase = threadDatabase; - this.groupDatabase = groupDatabase; - this.contactDatabase = contactDatabase; - this.contactAccessor = contactAccessor; - this.executor = executor; - } - - public void query(@NonNull String query, @NonNull Callback callback) { - // If the sanitized search is empty then abort without search - String cleanQuery = sanitizeQuery(query).trim(); - - executor.execute(() -> { - Stopwatch timer = new Stopwatch("FtsQuery"); - timer.split("clean"); - - Pair, List> contacts = queryContacts(cleanQuery); - timer.split("Contacts"); - - CursorList conversations = queryConversations(cleanQuery, contacts.getSecond()); - timer.split("Conversations"); - - CursorList messages = queryMessages(cleanQuery); - timer.split("Messages"); - - timer.stop(TAG); - - callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages)); - }); - } - - public void query(@NonNull String query, long threadId, @NonNull Callback> callback) { - // If the sanitized search query is empty then abort the search - String cleanQuery = sanitizeQuery(query).trim(); - if (cleanQuery.isEmpty()) { - callback.onResult(CursorList.emptyList()); - return; - } - - executor.execute(() -> { - CursorList messages = queryMessages(cleanQuery, threadId); - callback.onResult(messages); - }); - } - - public Pair, List> queryContacts(String query) { - Cursor contacts = contactDatabase.queryContactsByName(query); - List
contactList = new ArrayList<>(); - List contactStrings = new ArrayList<>(); - - while (contacts.moveToNext()) { - try { - Contact contact = contactDatabase.contactFromCursor(contacts); - String contactAccountId = contact.getAccountID(); - Address address = Address.fromSerialized(contactAccountId); - contactList.add(address); - contactStrings.add(contactAccountId); - } catch (Exception e) { - Log.e("Loki", "Error building Contact from cursor in query", e); - } - } - - contacts.close(); - - Cursor addressThreads = threadDatabase.searchConversationAddresses(query); - Cursor individualRecipients = threadDatabase.getFilteredConversationList(contactList); - if (individualRecipients == null && addressThreads == null) { - return new Pair<>(CursorList.emptyList(),contactStrings); - } - MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients}); - - return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings); - } - - private CursorList queryConversations(@NonNull String query, List matchingAddresses) { - List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); - String localUserNumber = TextSecurePreferences.getLocalNumber(context); - if (localUserNumber != null) { - matchingAddresses.remove(localUserNumber); - } - Set
addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList()); - - Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses); - if (membersGroupList != null) { - GroupDatabase.Reader reader = new GroupDatabase.Reader(membersGroupList); - while (membersGroupList.moveToNext()) { - GroupRecord record = reader.getCurrent(); - if (record == null) continue; - - addresses.add(Address.fromSerialized(record.getEncodedId())); - } - membersGroupList.close(); - } - - Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses)); - return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase)) - : CursorList.emptyList(); - } - - private CursorList queryMessages(@NonNull String query) { - Cursor messages = searchDatabase.queryMessages(query); - return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) - : CursorList.emptyList(); - } - - private CursorList queryMessages(@NonNull String query, long threadId) { - Cursor messages = searchDatabase.queryMessages(query, threadId); - return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) - : CursorList.emptyList(); - } - - /** - * Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes. - * MATCH queries have a separate format of their own that disallow most "special" characters. - * - * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". - * However, if we replace the apostrophe with a space, then the query will find the match. - */ - private String sanitizeQuery(@NonNull String query) { - StringBuilder out = new StringBuilder(); - - for (int i = 0; i < query.length(); i++) { - char c = query.charAt(i); - if (!BANNED_CHARACTERS.contains(c)) { - out.append(c); - } else if (c == '\'') { - out.append(' '); - } - } - - return out.toString(); - } - - private static class ContactModelBuilder implements CursorList.ModelBuilder { - - private final SessionContactDatabase contactDb; - private final ThreadDatabase threadDb; - - public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) { - this.contactDb = contactDb; - this.threadDb = threadDb; - } - - @Override - public Contact build(@NonNull Cursor cursor) { - ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent(); - Contact contact = contactDb.getContactWithAccountID(threadRecord.getRecipient().getAddress().toString()); - if (contact == null) { - contact = new Contact(threadRecord.getRecipient().getAddress().toString()); - contact.setThreadID(threadRecord.getThreadId()); - } - return contact; - } - } - - private static class RecipientModelBuilder implements CursorList.ModelBuilder { - - private final Context context; - - RecipientModelBuilder(@NonNull Context context) { this.context = context; } - - @Override - public Recipient build(@NonNull Cursor cursor) { - Address address = Address.fromExternal(context, cursor.getString(1)); - return Recipient.from(context, address, false); - } - } - - private static class GroupModelBuilder implements CursorList.ModelBuilder { - private final ThreadDatabase threadDatabase; - private final GroupDatabase groupDatabase; - - public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) { - this.threadDatabase = threadDatabase; - this.groupDatabase = groupDatabase; - } - - @Override - public GroupRecord build(@NonNull Cursor cursor) { - ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent(); - return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get(); - } - } - - private static class ThreadModelBuilder implements CursorList.ModelBuilder { - - private final ThreadDatabase threadDatabase; - - ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) { - this.threadDatabase = threadDatabase; - } - - @Override - public ThreadRecord build(@NonNull Cursor cursor) { - return threadDatabase.readerFor(cursor).getCurrent(); - } - } - - private static class MessageModelBuilder implements CursorList.ModelBuilder { - - private final Context context; - - MessageModelBuilder(@NonNull Context context) { this.context = context; } - - @Override - public MessageResult build(@NonNull Cursor cursor) { - Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS))); - Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))); - Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); - Recipient messageRecipient = Recipient.from(context, messageAddress, false); - String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)); - long sentMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID)); - - return new MessageResult(conversationRecipient, messageRecipient, body, threadId, sentMs); - } - } - - public interface Callback { - void onResult(@NonNull E result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt new file mode 100644 index 0000000000..529bbec247 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt @@ -0,0 +1,262 @@ +package org.thoughtcrime.securesms.search + +import android.content.Context +import android.database.Cursor +import android.database.MergeCursor +import com.annimon.stream.Stream +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromExternal +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.contacts.ContactAccessor +import org.thoughtcrime.securesms.database.CursorList +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.search.model.SearchResult +import org.thoughtcrime.securesms.util.Stopwatch +import java.util.concurrent.Executor + +// Class to manage data retrieval for search +class SearchRepository( + context: Context, + private val searchDatabase: SearchDatabase, + private val threadDatabase: ThreadDatabase, + private val groupDatabase: GroupDatabase, + private val contactDatabase: SessionContactDatabase, + private val contactAccessor: ContactAccessor, + private val executor: Executor +) { + private val context: Context = context.applicationContext + + fun query(query: String, callback: (SearchResult) -> Unit) { + // If the sanitized search is empty then abort without search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + + executor.execute { + val timer = + Stopwatch("FtsQuery") + timer.split("clean") + + val contacts = + queryContacts(cleanQuery) + timer.split("Contacts") + + val conversations = + queryConversations(cleanQuery, contacts.second) + timer.split("Conversations") + + val messages = queryMessages(cleanQuery) + timer.split("Messages") + + timer.stop(TAG) + callback( + SearchResult( + cleanQuery, + contacts.first, + conversations, + messages + ) + ) + } + } + + fun query(query: String, threadId: Long, callback: (CursorList) -> Unit) { + // If the sanitized search query is empty then abort the search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + if (cleanQuery.isEmpty()) { + callback(CursorList.emptyList()) + return + } + + executor.execute { + val messages = queryMessages(cleanQuery, threadId) + callback(messages) + } + } + + fun queryContacts(query: String): Pair, MutableList> { + val contacts = contactDatabase.queryContactsByName(query) + val contactList: MutableList
= ArrayList() + val contactStrings: MutableList = ArrayList() + + while (contacts.moveToNext()) { + try { + val contact = contactDatabase.contactFromCursor(contacts) + val contactAccountId = contact.accountID + val address = fromSerialized(contactAccountId) + contactList.add(address) + contactStrings.add(contactAccountId) + } catch (e: Exception) { + Log.e("Loki", "Error building Contact from cursor in query", e) + } + } + + contacts.close() + + val addressThreads = threadDatabase.searchConversationAddresses(query) + val individualRecipients = threadDatabase.getFilteredConversationList(contactList) + if (individualRecipients == null && addressThreads == null) { + return Pair(CursorList.emptyList(), contactStrings) + } + val merged = MergeCursor(arrayOf(addressThreads, individualRecipients)) + + return Pair( + CursorList(merged, ContactModelBuilder(contactDatabase, threadDatabase)), + contactStrings + ) + } + + private fun queryConversations( + query: String, + matchingAddresses: MutableList + ): CursorList { + val numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query) + val localUserNumber = getLocalNumber(context) + if (localUserNumber != null) { + matchingAddresses.remove(localUserNumber) + } + val addresses: MutableSet
= HashSet(Stream.of(numbers).map { number: String? -> + fromExternal( + context, + number + ) + }.toList()) + + val membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses) + if (membersGroupList != null) { + val reader = GroupDatabase.Reader(membersGroupList) + while (membersGroupList.moveToNext()) { + val record = reader.current ?: continue + + addresses.add(fromSerialized(record.encodedId)) + } + membersGroupList.close() + } + + val conversations = threadDatabase.getFilteredConversationList(ArrayList(addresses)) + return if (conversations != null) + CursorList(conversations, GroupModelBuilder(threadDatabase, groupDatabase)) + else + CursorList.emptyList() + } + + private fun queryMessages(query: String): CursorList { + val messages = searchDatabase.queryMessages(query) + return if (messages != null) + CursorList(messages, MessageModelBuilder(context)) + else + CursorList.emptyList() + } + + private fun queryMessages(query: String, threadId: Long): CursorList { + val messages = searchDatabase.queryMessages(query, threadId) + return if (messages != null) + CursorList(messages, MessageModelBuilder(context)) + else + CursorList.emptyList() + } + + /** + * Unfortunately [DatabaseUtils.sqlEscapeString] is not sufficient for our purposes. + * MATCH queries have a separate format of their own that disallow most "special" characters. + * + * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". + * However, if we replace the apostrophe with a space, then the query will find the match. + */ + private fun sanitizeQuery(query: String): String { + val out = StringBuilder() + + for (i in 0.. { + override fun build(cursor: Cursor): Contact { + val threadRecord = threadDb.readerFor(cursor).current + var contact = + contactDb.getContactWithAccountID(threadRecord.recipient.address.toString()) + if (contact == null) { + contact = Contact(threadRecord.recipient.address.toString()) + contact.threadID = threadRecord.threadId + } + return contact + } + } + + private class GroupModelBuilder( + private val threadDatabase: ThreadDatabase, + private val groupDatabase: GroupDatabase + ) : CursorList.ModelBuilder { + override fun build(cursor: Cursor): GroupRecord { + val threadRecord = threadDatabase.readerFor(cursor).current + return groupDatabase.getGroup(threadRecord.recipient.address.toGroupString()).get() + } + } + + private class MessageModelBuilder(private val context: Context) : CursorList.ModelBuilder { + override fun build(cursor: Cursor): MessageResult { + val conversationAddress = + fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS))) + val messageAddress = + fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))) + val conversationRecipient = Recipient.from(context, conversationAddress, false) + val messageRecipient = Recipient.from(context, messageAddress, false) + val body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)) + val sentMs = + cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID)) + + return MessageResult(conversationRecipient, messageRecipient, body, threadId, sentMs) + } + } + + interface Callback { + fun onResult(result: E) + } + + companion object { + private val TAG: String = SearchRepository::class.java.simpleName + + private val BANNED_CHARACTERS: MutableSet = HashSet() + + init { + // Construct a list containing several ranges of invalid ASCII characters + // See: https://www.ascii-code.com/ + for (i in 33..47) { + BANNED_CHARACTERS.add(i.toChar()) + } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., / + + for (i in 58..64) { + BANNED_CHARACTERS.add(i.toChar()) + } // :, ;, <, =, >, ?, @ + + for (i in 91..96) { + BANNED_CHARACTERS.add(i.toChar()) + } // [, \, ], ^, _, ` + + for (i in 123..126) { + BANNED_CHARACTERS.add(i.toChar()) + } // {, |, }, ~ + } + } +} From 33c6c4692f09e140deb1f1b5dda9cf96953d437a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 3 Apr 2025 10:00:18 +1100 Subject: [PATCH 092/867] Fixing store crashes --- .../java/org/thoughtcrime/securesms/ApplicationContext.kt | 2 +- .../org/thoughtcrime/securesms/MediaPreviewActivity.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index a0eb7b6cd0..b976394d60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -205,7 +205,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, @Volatile var isAppVisible: Boolean = false - override fun getSystemService(name: String): Any { + override fun getSystemService(name: String): Any? { if (MessagingModuleConfiguration.MESSAGING_MODULE_SERVICE == name) { return messagingModuleConfiguration!! } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 293e2b9ee0..1af080236d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -429,9 +429,12 @@ private void saveToDisk() { String mediaFilename = ""; if (mediaItem.attachment != null) { mediaFilename = mediaItem.attachment.getFilename(); - } else { + } + + if(mediaFilename == null || mediaFilename.isEmpty()){ mediaFilename = FilenameUtils.getFilenameFromUri(MediaPreviewActivity.this, mediaItem.uri, mediaItem.mimeType); } + final String outputFilename = mediaFilename; // We need a `final` value for the saveTask, below Log.i(TAG, "About to save media as: " + outputFilename); From 27507b5af897498efbffaf04a013b6701f2743df Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 3 Apr 2025 10:02:00 +1100 Subject: [PATCH 093/867] Version bump --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 02760bf920..f668639a13 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ configurations.configureEach { exclude module: "commons-logging" } -def canonicalVersionCode = 402 -def canonicalVersionName = "1.22.0" +def canonicalVersionCode = 403 +def canonicalVersionName = "1.22.1" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, From dbcffbd2b38fde15df8fb67561a043c1b11201f4 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:16:16 +1100 Subject: [PATCH 094/867] Tidy up MediaSendFragment (#1068) --- .../conversation/v2/ConversationActivityV2.kt | 12 +- .../database/AttachmentDatabase.java | 27 +- .../mediapreview/MediaPreviewViewModel.java | 4 +- .../securesms/mediasend/Media.java | 95 ---- .../thoughtcrime/securesms/mediasend/Media.kt | 49 ++ .../securesms/mediasend/MediaRepository.java | 2 +- .../securesms/mediasend/MediaSendActivity.kt | 4 +- .../securesms/mediasend/MediaSendFragment.kt | 444 +++++++++--------- .../MediaSendFragmentPagerAdapter.java | 4 +- .../securesms/mediasend/MediaSendViewModel.kt | 6 +- .../securesms/providers/BlobProvider.java | 13 +- .../securesms/util/BitmapUtil.java | 6 +- 12 files changed, 319 insertions(+), 347 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index c2f7bd1b3f..58b08a5eec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -107,7 +107,6 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ScreenLockActionBarActivity @@ -197,7 +196,6 @@ import org.thoughtcrime.securesms.util.isFullyScrolled import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.scrollAmount import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity @@ -825,7 +823,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) ) { - val media = Media(mediaURI, filename, mimeType, 0, 0, 0, 0, Optional.absent(), Optional.absent()) + val media = Media(mediaURI, filename, mimeType, 0, 0, 0, 0, null, null) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, ""), PICK_FROM_LIBRARY) return } else { @@ -1908,7 +1906,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val recipient = viewModel.recipient ?: return val mimeType = MediaUtil.getMimeType(this, contentUri)!! val filename = FilenameUtils.getFilenameFromUri(this, contentUri, mimeType) - val media = Media(contentUri, filename, mimeType, 0, 0, 0, 0, Optional.absent(), Optional.absent()) + val media = Media(contentUri, filename, mimeType, 0, 0, 0, 0, null, null) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } @@ -2121,9 +2119,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, for (media in mediaList) { val mediaFilename: String? = media.filename when { - MediaUtil.isVideoType(media.mimeType) -> { slideDeck.addSlide(VideoSlide(this, media.uri, mediaFilename, 0, media.caption.orNull())) } - MediaUtil.isGif(media.mimeType) -> { slideDeck.addSlide(GifSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption.orNull())) } - MediaUtil.isImageType(media.mimeType) -> { slideDeck.addSlide(ImageSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption.orNull())) } + MediaUtil.isVideoType(media.mimeType) -> { slideDeck.addSlide(VideoSlide(this, media.uri, mediaFilename, 0, media.caption)) } + MediaUtil.isGif(media.mimeType) -> { slideDeck.addSlide(GifSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } + MediaUtil.isImageType(media.mimeType) -> { slideDeck.addSlide(ImageSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } else -> { Log.d(TAG, "Asked to send an unexpected media type: '" + media.mimeType + "'. Skipping.") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 5299885f7a..4d474da047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -562,7 +562,9 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { throw new MmsException("No attachment data found!"); } - dataInfo = setAttachmentData(dataInfo.file, mediaStream.getStream()); + final File oldFile = dataInfo.file; + + dataInfo = setAttachmentData(mediaStream.getStream()); ContentValues contentValues = new ContentValues(); contentValues.put(SIZE, dataInfo.length); @@ -570,9 +572,18 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { contentValues.put(WIDTH, mediaStream.getWidth()); contentValues.put(HEIGHT, mediaStream.getHeight()); contentValues.put(DATA_RANDOM, dataInfo.random); + contentValues.put(DATA, dataInfo.file.getAbsolutePath()); database.update(TABLE_NAME, contentValues, PART_ID_WHERE, databaseAttachment.getAttachmentId().toStrings()); + if (oldFile != null && oldFile.exists()) { + try { + oldFile.delete(); + } catch (Exception e) { + Log.w(TAG, "Error deleting an old attachment file", e); + } + } + return new DatabaseAttachment(databaseAttachment.getAttachmentId(), databaseAttachment.getMmsId(), databaseAttachment.hasData(), @@ -696,20 +707,12 @@ public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, try { File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); File dataFile = File.createTempFile("part", ".mms", partsDirectory); - return setAttachmentData(dataFile, in); - } catch (IOException e) { - throw new MmsException(e); - } - } - private @NonNull DataInfo setAttachmentData(@NonNull File destination, @NonNull InputStream in) - throws MmsException - { - try { - Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, destination, false); + Log.d("AttachmentDatabase", "Writing attachment data to: " + dataFile.getAbsolutePath()); + Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); long length = Util.copy(in, out.second); - return new DataInfo(destination, length, out.first); + return new DataInfo(dataFile, length, out.first); } catch (IOException e) { throw new MmsException(e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java index 759a0b245c..1f13efd396 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -119,8 +119,8 @@ private int getCursorPosition(int position) { mediaRecord.getAttachment().getWidth(), mediaRecord.getAttachment().getHeight(), mediaRecord.getAttachment().getSize(), - Optional.absent(), - Optional.fromNullable(mediaRecord.getAttachment().getCaption()) + null, + mediaRecord.getAttachment().getCaption() ); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java deleted file mode 100644 index bd1e71decb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import org.session.libsignal.utilities.guava.Optional; - -/** - * Represents a piece of media that the user has on their device. - */ -public class Media implements Parcelable { - - public static final String ALL_MEDIA_BUCKET_ID = "org.thoughtcrime.securesms.ALL_MEDIA"; - - private final Uri uri; - private final String filename; - private final String mimeType; - private final long date; - private final int width; - private final int height; - private final long size; - private final Optional bucketId; - private Optional caption; - - public Media(@NonNull Uri uri, @NonNull String filename, @NonNull String mimeType, long date, int width, int height, long size, Optional bucketId, Optional caption) { - this.uri = uri; - this.filename = filename; - this.mimeType = mimeType; - this.date = date; - this.width = width; - this.height = height; - this.size = size; - this.bucketId = bucketId; - this.caption = caption; - } - - protected Media(Parcel in) { - uri = in.readParcelable(Uri.class.getClassLoader()); - filename = in.readString(); - mimeType = in.readString(); - date = in.readLong(); - width = in.readInt(); - height = in.readInt(); - size = in.readLong(); - bucketId = Optional.fromNullable(in.readString()); - caption = Optional.fromNullable(in.readString()); - } - - public Uri getUri() { return uri; } - public String getFilename() { return filename; } - public String getMimeType() { return mimeType; } - public long getDate() { return date; } - public int getWidth() { return width; } - public int getHeight() { return height; } - public long getSize() { return size; } - public Optional getBucketId() { return bucketId; } - public Optional getCaption() { return caption; } - public void setCaption(String caption) { this.caption = Optional.fromNullable(caption); } - - @Override - public int describeContents() { return 0; } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(uri, flags); - dest.writeString(filename); - dest.writeString(mimeType); - dest.writeLong(date); - dest.writeInt(width); - dest.writeInt(height); - dest.writeLong(size); - dest.writeString(bucketId.orNull()); - dest.writeString(caption.orNull()); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Media createFromParcel(Parcel in) { return new Media(in); } - - @Override - public Media[] newArray(int size) { return new Media[size]; } - }; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Media media = (Media)o; - return uri.equals(media.uri); - } - - @Override - public int hashCode() { return uri.hashCode(); } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt new file mode 100644 index 0000000000..c72ced5375 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents a piece of media that the user has on their device. + */ +@Parcelize +data class Media( + val uri: Uri, + val filename: String, + val mimeType: String, + val date: Long, + val width: Int, + val height: Int, + val size: Long, + val bucketId: String?, + val caption: String?, +) : Parcelable { + + // The equality check here is performed based only on the URI of the media. + // This behavior very opinionated and shouldn't really be in a generic equality check in the first place. + // However there are too much code working under this assumption and we can't simply change it to + // a generic solution. + // + // To later dev: once sufficient refactors are done, we can remove this equality + // check and rely on the data class default equality check instead. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Media) return false + + if (uri != other.uri) return false + + return true + } + + override fun hashCode(): Int { + return uri.hashCode() + } + + + companion object { + const val ALL_MEDIA_BUCKET_ID: String = "org.thoughtcrime.securesms.ALL_MEDIA" + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index bfe23f7d24..3b3c6ef811 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -194,7 +194,7 @@ void getPopulatedMedia(@NonNull Context context, @NonNull List media, @No long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); String filename = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)); - media.add(new Media(uri, filename, mimetype, date, width, height, size, Optional.of(bucketId), Optional.absent())); + media.add(new Media(uri, filename, mimetype, date, width, height, size, bucketId, null)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index c5ab98cb2a..2fc1876264 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -254,8 +254,8 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme width, height, data.size.toLong(), - Optional.of(Media.ALL_MEDIA_BUCKET_ID), - Optional.absent() + Media.ALL_MEDIA_BUCKET_ID, + null ) } catch (e: Exception) { return@run null diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 56f64a8ed5..efd46bd7e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -1,11 +1,9 @@ package org.thoughtcrime.securesms.mediasend -import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Rect import android.net.Uri -import android.os.AsyncTask import android.os.Bundle import android.text.Editable import android.text.TextWatcher @@ -17,46 +15,39 @@ import android.view.ViewGroup import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.view.WindowManager import android.view.inputmethod.EditorInfo -import android.widget.ImageButton import android.widget.TextView -import android.widget.TextView.OnEditorActionListener -import androidx.core.os.BundleCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import network.loki.messenger.R -import org.session.libsession.utilities.Address +import network.loki.messenger.databinding.MediasendFragmentBinding import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.TextSecurePreferences.Companion.isEnterSendsEnabled -import org.session.libsession.utilities.Util.cancelRunnableOnMain -import org.session.libsession.utilities.Util.isEmpty -import org.session.libsession.utilities.Util.runOnMainDelayed import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.SettableFuture -import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.components.ComposeText -import org.thoughtcrime.securesms.components.ControllableViewPager -import org.thoughtcrime.securesms.components.InputAwareLayout import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardHiddenListener import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener -import org.thoughtcrime.securesms.imageeditor.model.EditorModel import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener -import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.PushCharacterCalculator -import org.thoughtcrime.securesms.util.Stopwatch -import java.io.ByteArrayOutputStream -import java.io.IOException +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.util.Locale -import java.util.concurrent.ExecutionException /** * Allows the user to edit and caption a set of media items before choosing to send them. @@ -64,35 +55,24 @@ import java.util.concurrent.ExecutionException @AndroidEntryPoint class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, OnKeyboardShownListener, OnKeyboardHiddenListener { - private var hud: InputAwareLayout? = null - private var captionAndRail: View? = null - private var sendButton: ImageButton? = null - private var composeText: ComposeText? = null - private var composeContainer: ViewGroup? = null - private var playbackControlsContainer: ViewGroup? = null - private var charactersLeft: TextView? = null - private var closeButton: View? = null - private var loader: View? = null - - private var fragmentPager: ControllableViewPager? = null + private var binding: MediasendFragmentBinding? = null + private var fragmentPagerAdapter: MediaSendFragmentPagerAdapter? = null - private var mediaRail: RecyclerView? = null private var mediaRailAdapter: MediaRailAdapter? = null private var visibleHeight = 0 private var viewModel: MediaSendViewModel? = null - private var controller: Controller? = null private val visibleBounds = Rect() private val characterCalculator = PushCharacterCalculator() + private val controller: Controller + get() = (parentFragment as? Controller) ?: requireActivity() as Controller + override fun onAttach(context: Context) { super.onAttach(context) - check(requireActivity() is Controller) { "Parent activity must implement controller interface." } - - controller = requireActivity() as Controller viewModel = ViewModelProvider(requireActivity()).get( MediaSendViewModel::class.java ) @@ -102,8 +82,8 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.mediasend_fragment, container, false) + ): View { + return MediasendFragmentBinding.inflate(inflater, container, false).root } override fun onCreate(savedInstanceState: Bundle?) { @@ -112,83 +92,76 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - hud = view.findViewById(R.id.mediasend_hud) - captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail) - sendButton = view.findViewById(R.id.mediasend_send_button) - composeText = view.findViewById(R.id.mediasend_compose_text) - composeContainer = view.findViewById(R.id.mediasend_compose_container) - fragmentPager = view.findViewById(R.id.mediasend_pager) - mediaRail = view.findViewById(R.id.mediasend_media_rail) - playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container) - charactersLeft = view.findViewById(R.id.mediasend_characters_left) - closeButton = view.findViewById(R.id.mediasend_close_button) - loader = view.findViewById(R.id.loader) - - val sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg) - - sendButton!!.setOnClickListener(View.OnClickListener { v: View? -> - if (hud!!.isKeyboardOpen()) { - hud!!.hideSoftkey(composeText, null) + val binding = MediasendFragmentBinding.bind(view).also { + this.binding = it + } + + binding.mediasendSendButton.setOnClickListener { v: View? -> + if (binding.mediasendHud.isKeyboardOpen) { + binding.mediasendHud.hideSoftkey(binding.mediasendComposeText, null) } - processMedia(fragmentPagerAdapter!!.allMedia, fragmentPagerAdapter!!.savedState) - }) + + fragmentPagerAdapter?.let { processMedia(it.allMedia, it.savedState) } + } val composeKeyPressedListener = ComposeKeyPressedListener() - composeText!!.setOnKeyListener(composeKeyPressedListener) - composeText!!.addTextChangedListener(composeKeyPressedListener) - composeText!!.setOnClickListener(composeKeyPressedListener) - composeText!!.setOnFocusChangeListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnKeyListener(composeKeyPressedListener) + binding.mediasendComposeText.addTextChangedListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnClickListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnFocusChangeListener(composeKeyPressedListener) - composeText!!.requestFocus() + binding.mediasendComposeText.requestFocus() fragmentPagerAdapter = MediaSendFragmentPagerAdapter(childFragmentManager) - fragmentPager!!.setAdapter(fragmentPagerAdapter) + binding.mediasendPager.setAdapter(fragmentPagerAdapter) val pageChangeListener = FragmentPageChangeListener() - fragmentPager!!.addOnPageChangeListener(pageChangeListener) - fragmentPager!!.post(Runnable { pageChangeListener.onPageSelected(fragmentPager!!.currentItem) }) + binding.mediasendPager.addOnPageChangeListener(pageChangeListener) + binding.mediasendPager.post(Runnable { pageChangeListener.onPageSelected(binding.mediasendPager.currentItem) }) mediaRailAdapter = MediaRailAdapter(Glide.with(this), this, true) - mediaRail!!.setLayoutManager( + binding.mediasendMediaRail.setLayoutManager( LinearLayoutManager( requireContext(), LinearLayoutManager.HORIZONTAL, false ) ) - mediaRail!!.setAdapter(mediaRailAdapter) + binding.mediasendMediaRail.setAdapter(mediaRailAdapter) - hud!!.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this) - hud!!.addOnKeyboardShownListener(this) - hud!!.addOnKeyboardHiddenListener(this) + binding.mediasendHud.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this) + binding.mediasendHud.addOnKeyboardShownListener(this) + binding.mediasendHud.addOnKeyboardHiddenListener(this) - composeText!!.append(viewModel!!.body) + binding.mediasendComposeText.append(viewModel?.body) - val recipient = Recipient.from( - requireContext(), - arguments!!.getParcelable(KEY_ADDRESS)!!, false - ) - val displayName = Optional.fromNullable(recipient.name) - .or( - Optional.fromNullable(recipient.profileName) - .or(recipient.address.toString()) - ) - composeText!!.setHint(getString(R.string.message), null) - composeText!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + binding.mediasendComposeText.setHint(getString(R.string.message), null) + binding.mediasendComposeText.setOnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> val isSend = actionId == EditorInfo.IME_ACTION_SEND - if (isSend) sendButton!!.performClick() + if (isSend) binding.mediasendSendButton.performClick() isSend - }) + } - closeButton!!.setOnClickListener(View.OnClickListener { v: View? -> requireActivity().onBackPressed() }) + binding.mediasendCloseButton.setOnClickListener { requireActivity().onBackPressed() } + } + + override fun onDestroyView() { + super.onDestroyView() + + binding = null } override fun onStart() { super.onStart() - fragmentPagerAdapter!!.restoreState(viewModel!!.drawState) - viewModel!!.onImageEditorStarted() + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.restoreState(viewModel.drawState) + viewModel.onImageEditorStarted() + } requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) @@ -200,216 +173,262 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, override fun onStop() { super.onStop() - fragmentPagerAdapter!!.saveAllState() - viewModel!!.saveDrawState(fragmentPagerAdapter!!.savedState) + + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.saveAllState() + viewModel.saveDrawState(adapter.savedState) + } } override fun onGlobalLayout() { - hud!!.rootView.getWindowVisibleDisplayFrame(visibleBounds) + val hud = binding?.mediasendHud ?: return + + hud.rootView.getWindowVisibleDisplayFrame(visibleBounds) val currentVisibleHeight = visibleBounds.height() if (currentVisibleHeight != visibleHeight) { - hud!!.layoutParams.height = currentVisibleHeight - hud!!.layout( + hud.layoutParams.height = currentVisibleHeight + hud.layout( visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom ) - hud!!.requestLayout() + hud.requestLayout() visibleHeight = currentVisibleHeight } } override fun onRailItemClicked(distanceFromActive: Int) { - viewModel!!.onPageChanged(fragmentPager!!.currentItem + distanceFromActive) + val currentItem = binding?.mediasendPager?.currentItem ?: return + viewModel?.onPageChanged(currentItem + distanceFromActive) } override fun onRailItemDeleteClicked(distanceFromActive: Int) { - viewModel!!.onMediaItemRemoved( + val currentItem = binding?.mediasendPager?.currentItem ?: return + + viewModel?.onMediaItemRemoved( requireContext(), - fragmentPager!!.currentItem + distanceFromActive + currentItem + distanceFromActive ) } override fun onKeyboardShown() { - if (composeText!!.hasFocus()) { - mediaRail!!.visibility = View.VISIBLE - composeContainer!!.visibility = View.VISIBLE + val binding = binding ?: return + + if (binding.mediasendComposeText.hasFocus()) { + binding.mediasendMediaRail.visibility = View.VISIBLE + binding.mediasendComposeContainer.visibility = View.VISIBLE } else { - mediaRail!!.visibility = View.GONE - composeContainer!!.visibility = View.VISIBLE + binding.mediasendMediaRail.visibility = View.GONE + binding.mediasendComposeContainer.visibility = View.VISIBLE } } override fun onKeyboardHidden() { - composeContainer!!.visibility = View.VISIBLE - mediaRail!!.visibility = View.VISIBLE + binding?.apply { + mediasendComposeContainer.visibility = View.VISIBLE + mediasendMediaRail.visibility = View.VISIBLE + } } fun onTouchEventsNeeded(needed: Boolean) { - if (fragmentPager != null) { - fragmentPager!!.isEnabled = !needed - } + binding?.mediasendPager?.isEnabled = !needed } fun handleBackPress(): Boolean { - if (hud!!.isInputOpen) { - hud!!.hideCurrentInput(composeText) + val hud = binding?.mediasendHud ?: return false + val composeText = binding?.mediasendComposeText ?: return false + + if (hud.isInputOpen) { + hud.hideCurrentInput(composeText) return true } return false } private fun initViewModel() { - viewModel!!.getSelectedMedia().observe( + val viewModel = requireNotNull(viewModel) { + "ViewModel is not initialized" + } + + viewModel.getSelectedMedia().observe( this ) { media: List? -> - if (isEmpty(media)) { - controller!!.onNoMediaAvailable() + if (media.isNullOrEmpty()) { + controller.onNoMediaAvailable() return@observe } - fragmentPagerAdapter!!.setMedia(media!!) - mediaRail!!.visibility = View.VISIBLE - mediaRailAdapter!!.setMedia(media) + fragmentPagerAdapter?.setMedia(media) + + binding?.mediasendMediaRail?.visibility = View.VISIBLE + mediaRailAdapter?.setMedia(media) } - viewModel!!.getPosition().observe(this) { position: Int? -> + viewModel.getPosition().observe(this) { position: Int? -> if (position == null || position < 0) return@observe - fragmentPager!!.setCurrentItem(position, true) - mediaRailAdapter!!.setActivePosition(position) - mediaRail!!.smoothScrollToPosition(position) + binding?.mediasendPager?.setCurrentItem(position, true) + mediaRailAdapter?.setActivePosition(position) + binding?.mediasendMediaRail?.smoothScrollToPosition(position) - val playbackControls = fragmentPagerAdapter!!.getPlaybackControls(position) + val playbackControls = fragmentPagerAdapter?.getPlaybackControls(position) if (playbackControls != null) { val params = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) playbackControls.layoutParams = params - playbackControlsContainer!!.removeAllViews() - playbackControlsContainer!!.addView(playbackControls) + binding?.mediasendPlaybackControlsContainer?.removeAllViews() + binding?.mediasendPlaybackControlsContainer?.addView(playbackControls) } else { - playbackControlsContainer!!.removeAllViews() + binding?.mediasendPlaybackControlsContainer?.removeAllViews() } } - viewModel!!.getBucketId().observe(this) { bucketId: String? -> + viewModel.getBucketId().observe(this) { bucketId: String? -> if (bucketId == null) return@observe - mediaRailAdapter!!.setAddButtonListener { controller!!.onAddMediaClicked(bucketId) } + mediaRailAdapter!!.setAddButtonListener { controller.onAddMediaClicked(bucketId) } } } private fun presentCharactersRemaining() { - val messageBody = composeText!!.textTrimmed + val binding = binding ?: return + val messageBody = binding.mediasendComposeText.textTrimmed val characterState = characterCalculator.calculateCharacters(messageBody) if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { - charactersLeft!!.text = String.format( + binding.mediasendCharactersLeft.text = String.format( Locale.getDefault(), "%d/%d (%d)", characterState.charactersRemaining, characterState.maxTotalMessageSize, characterState.messagesSpent ) - charactersLeft!!.visibility = View.VISIBLE + binding.mediasendCharactersLeft.visibility = View.VISIBLE } else { - charactersLeft!!.visibility = View.GONE + binding.mediasendCharactersLeft.visibility = View.GONE } } - @SuppressLint("StaticFieldLeak") private fun processMedia(mediaList: List, savedState: Map) { - val futures: MutableMap> = HashMap() + val binding = binding ?: return // If the view is destroyed, this process should not continue - for (media in mediaList) { - val state = savedState[media.uri] + val context = requireContext().applicationContext - if (state is ImageEditorFragment.Data) { - val model = state.readModel() - if (model != null && model.isChanged) { - futures[media] = render(requireContext(), model) - } - } - } - - object : AsyncTask>() { - private var renderTimer: Stopwatch? = null - private var progressTimer: Runnable? = null - - override fun onPreExecute() { - renderTimer = Stopwatch("ProcessMedia") - progressTimer = Runnable { - loader!!.visibility = View.VISIBLE - } - runOnMainDelayed(progressTimer!!, 250) + lifecycleScope.launch { + val delayedShowLoader = launch { + delay(250) + binding.loader.isVisible = true } - override fun doInBackground(vararg params: Void?): List { - val context = requireContext() - val updatedMedia: MutableList = ArrayList(mediaList.size) - - for (media in mediaList) { - if (futures.containsKey(media)) { - try { - val bitmap = futures[media]!!.get() - val baos = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos) - - val uri = BlobProvider.getInstance() - .forData(baos.toByteArray()) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk( - context - ) { e: IOException? -> - Log.w( - TAG, - "Failed to write to disk.", - e - ) + val updatedMedia = supervisorScope { + // For each media, render the image in the background if necessary + val renderingTasks = mediaList + .asSequence() + .map { media -> + media to (savedState[media.uri] as? ImageEditorFragment.Data) + ?.readModel() + ?.takeIf { it.isChanged } + } + .associate { (media, model) -> + media.uri to async { + runCatching { + if (model != null) { + // While we render the bitmap in the background, make sure + // we limit the number of parallel tasks to avoid overwhelming the memory, + // as bitmaps are memory intensive. + withContext(Dispatchers.Default.limitedParallelism(2)) { + val bitmap = model.render(context) + try { + // Compress the bitmap to JPEG + val jpegOut = requireNotNull( + File.createTempFile( + "media_preview", + ".jpg", + context.cacheDir + ) + ) { + "Unable to create temporary file" + } + + val (jpegSize, uri) = try { + FileOutputStream(jpegOut).use { out -> + bitmap.compress( + Bitmap.CompressFormat.JPEG, + 80, + out + ) + } + + // Once we have the JPEG file, save it as our blob + val jpegSize = jpegOut.length() + jpegSize to BlobProvider.getInstance() + .forData(FileInputStream(jpegOut), jpegSize) + .withMimeType(MediaTypes.IMAGE_JPEG) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + } finally { + // Clean up the temporary file + jpegOut.delete() + } + + media.copy( + uri = uri, + mimeType = MediaTypes.IMAGE_JPEG, + width = bitmap.width, + height = bitmap.height, + size = jpegSize, + ) + } finally { + bitmap.recycle() + } + } + } else { + // No changes to the original media, copy and return as is + val newUri = BlobProvider.getInstance() + .forData(requireNotNull(context.contentResolver.openInputStream(media.uri)) { + "Invalid URI" + }, media.size) + .withMimeType(media.mimeType) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + + media.copy(uri = newUri) } - .get() - - val updated = Media( - uri, - media.filename, - MediaTypes.IMAGE_JPEG, - media.date, - bitmap.width, - bitmap.height, - baos.size().toLong(), - media.bucketId, - media.caption - ) - - updatedMedia.add(updated) - renderTimer!!.split("item") - } catch (e: Exception) { - Log.w(TAG, "Failed to render image. Using base image.", e) - updatedMedia.add(media) + } } - } else { - updatedMedia.add(media) } + + // For each media, if there's a rendered version, use that or keep the original + mediaList.map { media -> + renderingTasks[media.uri]?.await()?.let { rendered -> + if (rendered.isFailure) { + Log.w(TAG, "Error rendering image", rendered.exceptionOrNull()) + media + } else { + rendered.getOrThrow() + } + } ?: media } - return updatedMedia } - override fun onPostExecute(media: List) { - controller!!.onSendClicked(media, composeText!!.textTrimmed) - cancelRunnableOnMain(progressTimer!!) - loader!!.visibility = View.GONE - renderTimer!!.stop(TAG) - } - }.execute() + controller.onSendClicked(updatedMedia, binding.mediasendComposeText.textTrimmed) + delayedShowLoader.cancel() + binding.loader.isVisible = false + } } fun onRequestFullScreen(fullScreen: Boolean) { - captionAndRail!!.visibility = + binding?.mediasendCaptionAndRail?.visibility = if (fullScreen) View.GONE else View.VISIBLE } @@ -427,13 +446,13 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, if (event.action == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_ENTER) { if (isEnterSendsEnabled(requireContext())) { - sendButton!!.dispatchKeyEvent( + binding?.mediasendSendButton?.dispatchKeyEvent( KeyEvent( KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER ) ) - sendButton!!.dispatchKeyEvent( + binding?.mediasendSendButton?.dispatchKeyEvent( KeyEvent( KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER @@ -447,11 +466,12 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, } override fun onClick(v: View) { - hud!!.showSoftkey(composeText) + val binding = binding ?: return + binding.mediasendHud.showSoftkey(binding.mediasendComposeText) } override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { - beforeLength = composeText!!.textTrimmed.length + beforeLength = binding?.mediasendComposeText?.textTrimmed?.length ?: return } override fun afterTextChanged(s: Editable) { @@ -483,13 +503,5 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, fragment.arguments = args return fragment } - - private fun render(context: Context, model: EditorModel): ListenableFuture { - val future = SettableFuture() - - AsyncTask.THREAD_POOL_EXECUTOR.execute { future.set(model.render(context)) } - - return future - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java index 4d6107044f..ea2ee1b403 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -17,6 +17,8 @@ import java.util.List; import java.util.Map; +import kotlin.collections.CollectionsKt; + class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { private final List media; @@ -83,7 +85,7 @@ public int getCount() { } List getAllMedia() { - return media; + return CollectionsKt.toList(media); } void setMedia(@NonNull List media) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index c5150b2974..1831eb97aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -82,9 +82,9 @@ internal class MediaSendViewModel @Inject constructor( if (filteredMedia.size > 0) { val computedId: String = Stream.of(filteredMedia) .skip(1) - .reduce(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID), + .reduce(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID, { id: String?, m: Media -> - if (equals(id, m.bucketId.or(Media.ALL_MEDIA_BUCKET_ID))) { + if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { return@reduce id } else { return@reduce Media.ALL_MEDIA_BUCKET_ID @@ -118,7 +118,7 @@ internal class MediaSendViewModel @Inject constructor( error.setValue(Error.ITEM_TOO_LARGE) bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) } else { - bucketId.setValue(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID)) + bucketId.setValue(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID) } countButtonVisibility = CountButtonState.Visibility.FORCED_OFF diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 15240e4d3c..8d05abd81e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -25,6 +25,7 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import kotlin.Pair; @@ -178,7 +179,7 @@ public static boolean isAuthority(@NonNull Uri uri) { @WorkerThread @NonNull - private static Future writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { + private static CompletableFuture writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); String directory = getDirectory(blobSpec.getStorageType()); File outputFile = new File(getOrCreateCacheDirectory(context, directory), buildFileName(blobSpec.id)); @@ -186,7 +187,7 @@ private static Future writeBlobSpecToDisk(@NonNull Context context, @NonNul final Uri uri = buildUri(blobSpec); - return SignalExecutors.UNBOUNDED.submit(() -> { + return CompletableFuture.supplyAsync(() -> { try { Util.copy(blobSpec.getData(), outputStream); return uri; @@ -195,9 +196,9 @@ private static Future writeBlobSpecToDisk(@NonNull Context context, @NonNul errorListener.onError(e); } - throw e; + throw new RuntimeException(e); } - }); + }, SignalExecutors.UNBOUNDED); } private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { @@ -266,7 +267,7 @@ protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { * period from one {@link Application#onCreate()} to the next. */ @WorkerThread - public Future createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public CompletableFuture createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), errorListener); } @@ -275,7 +276,7 @@ public Future createForSingleSessionOnDisk(@NonNull Context context, @Nulla * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. */ @WorkerThread - public Future createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public CompletableFuture createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), errorListener); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index 93b21512ea..5e1a85df51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -118,7 +118,9 @@ private static ScaleResult createScaledBytes(@NonNull Context context, do { totalAttempts++; ByteArrayOutputStream baos = new ByteArrayOutputStream(); - scaledBitmap.compress(format, quality, baos); + if (!scaledBitmap.compress(format, quality, baos)) { + Log.d(TAG, "Unable to compress image with quality " + quality); + } bytes = baos.toByteArray(); Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes."); @@ -144,7 +146,7 @@ private static ScaleResult createScaledBytes(@NonNull Context context, } } - if (bytes.length <= 0) { + if (bytes.length == 0) { throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes."); } From 04de99265c6f861a20e683a5af62ce77596e8efc Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 8 Apr 2025 13:24:58 +0930 Subject: [PATCH 095/867] Fix/ses 3518 qa fixes (#1075) * Window insets in app bars * Fixing broken draw for inputbar buttons (visible in light themes) * Fixing qa tag for legacy group recreation * SES-3597 - doc attachment corners * Fixing FAB animation * Extracting quotes and audio out of the message bubble. Ability to send text with documents (with no confirmation yet!) * SES-3603 Making sure quotes use underlying original text * Making sure we still render text when suppressing thumbnail * Reverting for now until we find a solution for confirming documents before sending * Cleaning up ui * Making sure items respect their bg shape --- .../conversation/v2/ConversationActivityV2.kt | 2 + .../conversation/v2/messages/QuoteView.kt | 21 ++++---- .../v2/messages/VisibleMessageContentView.kt | 43 ++++++++++------- .../securesms/home/HomeActivity.kt | 16 ++++++- .../securesms/preferences/SettingsActivity.kt | 20 ++++++-- .../thoughtcrime/securesms/ui/Components.kt | 33 ++++++++++++- .../securesms/ui/components/AppBar.kt | 1 + .../securesms/ui/theme/Dimensions.kt | 3 ++ .../thoughtcrime/securesms/ui/theme/Themes.kt | 11 +++-- .../thoughtcrime/securesms/util/GlowView.kt | 3 +- ...ived.xml => message_bubble_background.xml} | 0 .../view_doc_attachment_icon_background.xml | 10 ++++ .../res/layout/activity_conversation_v2.xml | 2 +- .../res/layout/view_attachment_control.xml | 2 +- .../view_conversation_typing_container.xml | 2 +- app/src/main/res/layout/view_document.xml | 6 ++- app/src/main/res/layout/view_quote.xml | 1 - .../layout/view_visible_message_content.xml | 48 +++++++++---------- .../main/res/layout/view_voice_message.xml | 1 + app/src/main/res/values-sw360dp/dimens.xml | 2 +- app/src/main/res/values-sw400dp/dimens.xml | 2 +- app/src/main/res/values/dimens.xml | 2 +- app/src/main/res/values/styles.xml | 2 +- .../src/main/res/values/strings.xml | 1 + .../messaging/messages/visible/Attachment.kt | 1 + .../NonTranslatableStringConstants.kt | 3 +- 26 files changed, 162 insertions(+), 76 deletions(-) rename app/src/main/res/drawable/{message_bubble_background_received.xml => message_bubble_background.xml} (100%) create mode 100644 app/src/main/res/drawable/view_doc_attachment_icon_background.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 58b08a5eec..6e070371b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -2086,6 +2086,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Note: The only multi-attachment message type is when sending images - all others // attempt send the attachment immediately upon file selection. sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) + //todo: The current system sends the document the moment it has been selected, without text (body is set to null above) - We will want to fix this and allow the user to add text with a document AND be able to confirm before sending + //todo: Simply setting body to getMessageBody() above isn't good enough as it doesn't give the user a chance to confirm their message before sending it. } override fun onFailure(e: ExecutionException?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index bd3a88c973..07a7d05623 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -9,6 +9,7 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.use import androidx.core.text.toSpannable import androidx.core.view.isVisible +import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewQuoteBinding @@ -16,13 +17,11 @@ import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.database.SessionContactDatabase -import com.bumptech.glide.RequestManager -import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.toPx import javax.inject.Inject @@ -106,25 +105,25 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.audioSlide != null -> { val isVoiceNote = attachments.isVoiceNote if (isVoiceNote) { - binding.quoteViewBodyTextView.text = resources.getString(R.string.messageVoice) + updateQuoteTextIfEmpty(resources.getString(R.string.messageVoice)) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_mic) } else { - binding.quoteViewBodyTextView.text = resources.getString(R.string.audio) + updateQuoteTextIfEmpty(resources.getString(R.string.audio)) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_volume_2) } } attachments.documentSlide != null -> { binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_file) - binding.quoteViewBodyTextView.text = resources.getString(R.string.document) + updateQuoteTextIfEmpty(resources.getString(R.string.document)) } attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! if (MediaUtil.isVideo(slide.asAttachment())){ - binding.quoteViewBodyTextView.text = resources.getString(R.string.video) + updateQuoteTextIfEmpty(resources.getString(R.string.video)) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_square_play) } else { - binding.quoteViewBodyTextView.text = resources.getString(R.string.image) + updateQuoteTextIfEmpty(resources.getString(R.string.image)) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_image) } @@ -145,6 +144,12 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } } } + + private fun updateQuoteTextIfEmpty(text: String){ + if(binding.quoteViewBodyTextView.text.isNullOrEmpty()){ + binding.quoteViewBodyTextView.text = text + } + } // endregion // region Convenience diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 61d81b037c..8dcada7ec5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Rect import android.text.Spannable @@ -27,8 +28,6 @@ import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil @@ -81,6 +80,8 @@ class VisibleMessageContentView : ConstraintLayout { val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) binding.contentParent.mainColor = color + binding.documentView.root.backgroundTintList = ColorStateList.valueOf(color) + binding.voiceMessageView.root.backgroundTintList = ColorStateList.valueOf(color) binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.isDone } @@ -269,24 +270,30 @@ class VisibleMessageContentView : ConstraintLayout { hideBody = false if (overallAttachmentState == AttachmentState.DONE || message.isOutgoing) { - if(suppressThumbnails) return // suppress thumbnail should hide the image, but we still want to show the attachment control if the state demands it + if(!suppressThumbnails) { // suppress thumbnail should hide the image, but we still want to show the attachment control if the state demands it - binding.attachmentControlView.root.isVisible = false + binding.attachmentControlView.root.isVisible = false - // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups - // bind after add view because views are inflated and calculated during bind - binding.albumThumbnailView.root.isVisible = true - binding.albumThumbnailView.root.bind( - glideRequests = glide, - message = message, - isStart = isStartOfMessageCluster, - isEnd = isEndOfMessageCluster - ) - binding.albumThumbnailView.root.modifyLayoutParams { - horizontalBias = if (message.isOutgoing) 1f else 0f - } - onContentClick.add { event -> - binding.albumThumbnailView.root.calculateHitObject(event, message, thread, downloadPendingAttachment) + // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups + // bind after add view because views are inflated and calculated during bind + binding.albumThumbnailView.root.isVisible = true + binding.albumThumbnailView.root.bind( + glideRequests = glide, + message = message, + isStart = isStartOfMessageCluster, + isEnd = isEndOfMessageCluster + ) + binding.albumThumbnailView.root.modifyLayoutParams { + horizontalBias = if (message.isOutgoing) 1f else 0f + } + onContentClick.add { event -> + binding.albumThumbnailView.root.calculateHitObject( + event, + message, + thread, + downloadPendingAttachment + ) + } } } else { databaseAttachments?.let { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index a5a6713912..1afa12fceb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.View import android.widget.Toast import androidx.activity.viewModels import androidx.core.os.bundleOf @@ -389,8 +390,19 @@ class HomeActivity : ScreenLockActionBarActivity(), binding.sessionToolbar.isVisible = !isShown binding.recyclerView.isVisible = !isShown binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown - binding.globalSearchRecycler.isInvisible = !isShown - binding.conversationListContainer.isInvisible = isShown + binding.globalSearchRecycler.isVisible = isShown + binding.conversationListContainer.isVisible = !isShown + if(isShown){ + binding.newConversationButton.animate().cancel() + binding.newConversationButton.isVisible = false + } else { + binding.newConversationButton.apply { + alpha = 0.0f + visibility = View.VISIBLE + animate().cancel() + animate().setStartDelay(350).setDuration(250L).alpha(1.0f).setListener(null).start() + } + } } private fun updateLegacyConfigView() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 8a169bfb51..7cbfcffdae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -51,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -99,6 +101,8 @@ import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -494,12 +498,21 @@ class SettingsActivity : ScreenLockActionBarActivity() { Column { // add the debug menu in non release builds if (BuildConfig.BUILD_TYPE != "release") { - LargeItemButton("Debug Menu", R.drawable.ic_settings) { push() } + LargeItemButton( + "Debug Menu", + R.drawable.ic_settings, + shape = getCellTopShape() + ) { push() } Divider() } Crossfade(if (hasPaths) R.drawable.ic_status else R.drawable.ic_path_yellow, label = "path") { - LargeItemButtonWithDrawable(R.string.onionRoutingPath, it) { push() } + LargeItemButtonWithDrawable( + R.string.onionRoutingPath, + it, + shape = if (BuildConfig.BUILD_TYPE != "release") RectangleShape + else getCellTopShape() + ) { push() } } Divider() @@ -544,7 +557,8 @@ class SettingsActivity : ScreenLockActionBarActivity() { LargeItemButton(R.string.sessionClearData, R.drawable.ic_trash_2, Modifier.contentDescription(R.string.AccessibilityId_sessionClearData), - dangerButtonColors() + dangerButtonColors(), + shape = getCellBottomShape() ) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 86b8fc2c07..a9521c7492 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TileMode @@ -133,11 +134,12 @@ fun LargeItemButtonWithDrawable( @DrawableRes icon: Int, modifier: Modifier = Modifier, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButtonWithDrawable( textId, icon, modifier, - LocalType.current.h8, colors, onClick + LocalType.current.h8, colors, shape, onClick ) } @@ -148,6 +150,7 @@ fun ItemButtonWithDrawable( modifier: Modifier = Modifier, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { val context = LocalContext.current @@ -164,6 +167,7 @@ fun ItemButtonWithDrawable( }, textStyle = textStyle, colors = colors, + shape = shape, onClick = onClick ) } @@ -174,6 +178,7 @@ fun LargeItemButton( @DrawableRes icon: Int, modifier: Modifier = Modifier, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( @@ -183,6 +188,7 @@ fun LargeItemButton( minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, + shape = shape, onClick = onClick ) } @@ -193,6 +199,7 @@ fun LargeItemButton( @DrawableRes icon: Int, modifier: Modifier = Modifier, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( @@ -202,6 +209,7 @@ fun LargeItemButton( minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, + shape = shape, onClick = onClick ) } @@ -214,6 +222,7 @@ fun ItemButton( minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( @@ -229,6 +238,7 @@ fun ItemButton( minHeight = minHeight, textStyle = textStyle, colors = colors, + shape = shape, onClick = onClick ) } @@ -244,6 +254,7 @@ fun ItemButton( minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( @@ -259,6 +270,7 @@ fun ItemButton( minHeight = minHeight, textStyle = textStyle, colors = colors, + shape = shape, onClick = onClick ) } @@ -276,6 +288,7 @@ fun ItemButton( minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { TextButton( @@ -283,7 +296,7 @@ fun ItemButton( colors = colors, onClick = onClick, contentPadding = PaddingValues(), - shape = RectangleShape, + shape = shape, ) { Box( modifier = Modifier @@ -345,6 +358,22 @@ fun Cell( } } +@Composable +fun getCellTopShape() = RoundedCornerShape( + topStart = LocalDimensions.current.shapeSmall, + topEnd = LocalDimensions.current.shapeSmall, + bottomEnd = 0.dp, + bottomStart = 0.dp +) + +@Composable +fun getCellBottomShape() = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomEnd = LocalDimensions.current.shapeSmall, + bottomStart = LocalDimensions.current.shapeSmall +) + @Composable fun Modifier.contentDescription(text: GetString?): Modifier { return text?.let { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index 4183c1fbc8..941598d530 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -134,6 +134,7 @@ fun ActionAppBar( ) { CenterAlignedTopAppBar( modifier = modifier, + windowInsets = WindowInsets(0, 0, 0, 0), // insets handled in BaseActionBarActivity for now title = { if (!actionMode) { AppBarText(title = title, singleLine = singleLine) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index f342bc7d9e..8798337ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -29,4 +29,7 @@ data class Dimensions( val iconLarge: Dp = 46.dp, val iconXLarge: Dp = 60.dp, val iconXXLarge: Dp = 80.dp, + + val shapeSmall: Dp = 12.dp, + val shapeMedium: Dp = 16.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 9ef7c23da7..177512b6e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -47,7 +47,7 @@ fun SessionMaterialTheme( } /** - * Apply a given [ThemeColors], and our typography and shapes as a Material 2 Compose Theme. + * Apply a given [ThemeColors], and our typography and shapes as a Material 3 Compose Theme. **/ @Composable fun SessionMaterialTheme( @@ -57,7 +57,7 @@ fun SessionMaterialTheme( MaterialTheme( colorScheme = colors.toMaterialColors(), typography = sessionTypography.asMaterialTypography(), - shapes = sessionShapes, + shapes = sessionShapes(), ) { CompositionLocalProvider( LocalColors provides colors, @@ -72,9 +72,10 @@ fun SessionMaterialTheme( val pillShape = RoundedCornerShape(percent = 50) val buttonShape = pillShape -val sessionShapes = Shapes( - small = RoundedCornerShape(12.dp), - medium = RoundedCornerShape(16.dp) +@Composable +fun sessionShapes() = Shapes( + small = RoundedCornerShape(LocalDimensions.current.shapeSmall), + medium = RoundedCornerShape(LocalDimensions.current.shapeMedium) ) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 76dd38ea14..0285857409 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -198,7 +198,8 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { val h = height.toFloat() c.drawCircle(w / 2, h / 2, w / 2, fillPaint) if (strokeColor != 0) { - c.drawCircle(w / 2, h / 2, w / 2, strokePaint) + // Adjust radius to account for stroke width + c.drawCircle(w / 2, h / 2, w / 2 - strokePaint.strokeWidth / 2, strokePaint) } super.onDraw(c) } diff --git a/app/src/main/res/drawable/message_bubble_background_received.xml b/app/src/main/res/drawable/message_bubble_background.xml similarity index 100% rename from app/src/main/res/drawable/message_bubble_background_received.xml rename to app/src/main/res/drawable/message_bubble_background.xml diff --git a/app/src/main/res/drawable/view_doc_attachment_icon_background.xml b/app/src/main/res/drawable/view_doc_attachment_icon_background.xml new file mode 100644 index 0000000000..56b9f3007d --- /dev/null +++ b/app/src/main/res/drawable/view_doc_attachment_icon_background.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index f935a44b0f..eb75a92f87 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -262,7 +262,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" style="@style/Widget.Session.Button.Common.ProminentOutline" - android:contentDescription="@string/AccessibilityId_messageRequestsAccept" + android:contentDescription="@string/AccessibilityId_recreate_legacy_group" android:text="@string/recreateGroup" /> diff --git a/app/src/main/res/layout/view_attachment_control.xml b/app/src/main/res/layout/view_attachment_control.xml index c9c4b2134d..aebd079db6 100644 --- a/app/src/main/res/layout/view_attachment_control.xml +++ b/app/src/main/res/layout/view_attachment_control.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/message_bubble_background_received" + android:background="@drawable/message_bubble_background" android:contentDescription="@string/AccessibilityId_attachmentsClickToDownload" android:gravity="center" android:orientation="horizontal" diff --git a/app/src/main/res/layout/view_conversation_typing_container.xml b/app/src/main/res/layout/view_conversation_typing_container.xml index d2097f95c6..f8bf25fbf6 100644 --- a/app/src/main/res/layout/view_conversation_typing_container.xml +++ b/app/src/main/res/layout/view_conversation_typing_container.xml @@ -14,7 +14,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="7dp" - android:background="@drawable/message_bubble_background_received"> + android:background="@drawable/message_bubble_background"> + android:background="@drawable/view_doc_attachment_icon_background"> diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index c748148c8c..2b653ae467 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -56,14 +56,6 @@ android:layout_width="300dp" android:layout_height="wrap_content"/> - - - + - + - + diff --git a/app/src/main/res/values-sw360dp/dimens.xml b/app/src/main/res/values-sw360dp/dimens.xml index 485456ec3e..2d3f2c3fb8 100644 --- a/app/src/main/res/values-sw360dp/dimens.xml +++ b/app/src/main/res/values-sw360dp/dimens.xml @@ -7,5 +7,5 @@ 167dp 83dp 83dp - 240dp + 240dp \ No newline at end of file diff --git a/app/src/main/res/values-sw400dp/dimens.xml b/app/src/main/res/values-sw400dp/dimens.xml index 9376913d52..7dc4f477fd 100644 --- a/app/src/main/res/values-sw400dp/dimens.xml +++ b/app/src/main/res/values-sw400dp/dimens.xml @@ -15,5 +15,5 @@ 199dp 99dp 99dp - 300dp + 300dp \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 0c57ec0680..36aef5d685 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -123,7 +123,7 @@ 320dp 40dp - 200dp + 200dp 34dp 26dp 26dp diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f46ad041b4..7165580f2a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -243,7 +243,7 @@ - - - - + + From 61615f32ede639d648892364ec512ab50585d85b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 09:43:43 +1000 Subject: [PATCH 187/867] Fixing banner issue --- app/src/main/res/layout/activity_conversation_v2.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 9607e0aac3..1b48f59ba4 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -12,11 +12,11 @@ - - + android:layout_height="wrap_content" + android:background="?input_bar_background" + android:orientation="vertical"> - + - + + android:layout_height="wrap_content" + android:orientation="vertical" /> + android:layout_width="match_parent" + android:layout_height="match_parent" + android:padding="4dp"> + + + + + + + + + + + + - + + - \ No newline at end of file + \ No newline at end of file From cca0404cbb10cd90771994497718efdde060a622 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 12:28:57 +1000 Subject: [PATCH 190/867] Show/Hide Note to self --- .../v2/settings/ConversationSettingsScreen.kt | 3 +- .../settings/ConversationSettingsViewModel.kt | 79 ++++++++++++++++++- .../src/main/res/values/strings.xml | 4 + 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 6a09a96957..dc5417a4d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -259,7 +259,8 @@ fun ConversationSettings( buttons = listOf( DialogButtonModel( text = GetString(data.showSimpleDialog.positiveText), - color = LocalColors.current.danger, + color = if(data.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger + else LocalColors.current.text, onClick = data.showSimpleDialog.onPositive ), DialogButtonModel( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 303b72c242..1d0be55269 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -31,6 +31,8 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.database.StorageProtocol @@ -71,6 +73,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val navigator: ConversationSettingsNavigator, private val threadDb: ThreadDatabase, private val groupManagerV2: GroupManagerV2, + private val prefs: TextSecurePreferences, ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow( @@ -207,6 +210,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( val mainOptions = mutableListOf() val dangerOptions = mutableListOf() + val ntsHidden = prefs.hasHiddenNoteToSelf() + mainOptions.addAll(listOf( optionCopyAccountId, optionSearch, @@ -215,8 +220,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( optionAttachments, )) + if(ntsHidden) mainOptions.add(optionShowNTS) + else dangerOptions.add(optionHideNTS) + dangerOptions.addAll(listOf( - optionHideNTS, optionClearMessages, )) @@ -482,7 +489,6 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } - private fun blockUser() { val conversation = recipient ?: return viewModelScope.launch { @@ -505,6 +511,63 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmHideNTS(){ + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.noteToSelfHide), + message = context.getString(R.string.noteToSelfHideDescription), //todo UCS need latest from crowdin here + positiveText = context.getString(R.string.hide), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_hide_nts_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_hide_nts_cancel), + onPositive = ::hideNoteToSelf, + onNegative = {} + ) + ) + } + } + + private fun confirmShowNTS(){ + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.showNoteToSelf), + message = context.getText(R.string.showNoteToSelfDescription), + positiveText = context.getString(R.string.show), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_show_nts_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_show_nts_cancel), + positiveStyleDanger = false, + onPositive = ::showNoteToSelf, + onNegative = {} + ) + ) + } + } + + private fun hideNoteToSelf() { + prefs.setHasHiddenNoteToSelf(true) + configFactory.withMutableUserConfigs { + it.userProfile.setNtsPriority(PRIORITY_HIDDEN) + } + // update state to reflect the change + viewModelScope.launch { + getStateFromRecipient() + } + } + + fun showNoteToSelf() { + prefs.setHasHiddenNoteToSelf(false) + configFactory.withMutableUserConfigs { + it.userProfile.setNtsPriority(PRIORITY_VISIBLE) + } + // update state to reflect the change + viewModelScope.launch { + getStateFromRecipient() + } + } + fun onCommand(command: Commands) { when (command) { is Commands.CopyAccountId -> copyAccountId() @@ -651,7 +714,16 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.noteToSelfHide), icon = R.drawable.ic_eye_off, qaTag = R.string.qa_conversation_settings_hide_nts, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmHideNTS + ) + } + + private val optionShowNTS: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.showNoteToSelf), + icon = R.drawable.ic_eye, + qaTag = R.string.qa_conversation_settings_hide_nts, + onClick = ::confirmShowNTS ) } @@ -747,6 +819,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val title: String, val message: CharSequence, val positiveText: String, + val positiveStyleDanger: Boolean = true, val negativeText: String, val positiveQaTag: String?, val negativeQaTag: String?, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 84c77f16f5..e077e2e96e 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ invite-contacts-menu-option leave-group-confirm-button delete-group-menu-option + hide-nts-confirm-button + hide-nts-cancel-button + show-nts-confirm-button + show-nts-cancel-button block-user-confirm-button block-user-cancel-button unblock-user-confirm-button From 5368e3ee07429ab635de311cbeabead90b799751 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 13:51:09 +1000 Subject: [PATCH 191/867] UCS Delete Contact --- .../settings/ConversationSettingsNavHost.kt | 4 ++ .../settings/ConversationSettingsNavigator.kt | 9 ++++ .../settings/ConversationSettingsViewModel.kt | 52 +++++++++++++++++-- .../search/SearchContactActionBottomSheet.kt | 2 +- .../src/main/res/values/strings.xml | 2 + 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 0f28b8152c..ef8094efab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -101,6 +101,10 @@ fun ConversationSettingsNavHost( } NavigationAction.NavigateUp -> navController.navigateUp() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt index 05ca504751..7204738632 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.settings +import android.content.Intent import androidx.navigation.NavOptionsBuilder import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.channels.Channel @@ -28,6 +29,10 @@ class ConversationSettingsNavigator @Inject constructor(){ suspend fun navigateUp() { _navigationActions.send(NavigationAction.NavigateUp) } + + suspend fun navigateToIntent(intent: Intent) { + _navigationActions.send(NavigationAction.NavigateToIntent(intent)) + } } sealed interface NavigationAction { @@ -37,4 +42,8 @@ sealed interface NavigationAction { ): NavigationAction data object NavigateUp: NavigationAction + + data class NavigateToIntent( + val intent: Intent + ): NavigationAction } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 1d0be55269..72c4b5e8fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.conversation.v2.settings import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE @@ -51,6 +53,7 @@ import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.AvatarUIData @@ -492,8 +495,6 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun blockUser() { val conversation = recipient ?: return viewModelScope.launch { - repository.setBlocked(conversation, true) - if (conversation.isContactRecipient || conversation.isGroupV2Recipient) { repository.setBlocked(conversation, true) } @@ -516,7 +517,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.noteToSelfHide), - message = context.getString(R.string.noteToSelfHideDescription), //todo UCS need latest from crowdin here + message = context.getText(R.string.hideNoteToSelfDescription), positiveText = context.getString(R.string.hide), negativeText = context.getString(R.string.cancel), positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_hide_nts_confirm), @@ -568,6 +569,49 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmDeleteContact(){ + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.contactDelete), + message = Phrase.from(context, R.string.deleteContactDescription) + .put(NAME_KEY, recipient?.name ?: "") + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.delete), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_contact_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_contact_cancel), + onPositive = ::deleteContact, + onNegative = {} + ) + ) + } + } + + private fun deleteContact() { + val conversation = recipient ?: return + viewModelScope.launch { + withContext(Dispatchers.Default) { + storage.deleteContactAndSyncConfig(conversation.address.toString()) + } + + goBackHome() + } + } + + private suspend fun goBackHome(){ + navigator.navigateToIntent( + Intent(context, HomeActivity::class.java).apply { + // pop back to home activity + addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + ) + } + fun onCommand(command: Commands) { when (command) { is Commands.CopyAccountId -> copyAccountId() @@ -705,7 +749,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.contactDelete), icon = R.drawable.ic_user_round_trash, qaTag = R.string.qa_conversation_settings_delete_contact, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmDeleteContact ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt index bfaf95e2fc..2a6c1e1d64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt @@ -99,7 +99,7 @@ class SearchContactActionBottomSheet : BottomSheetDialogFragment() { showSessionDialog { title(R.string.contactDelete) text( - Phrase.from(context, R.string.contactDeleteDescription) + Phrase.from(context, R.string.deleteContactDescription) .put(NAME_KEY, contactName) .put(NAME_KEY, contactName) .format()) diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index e077e2e96e..34b9481795 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -124,6 +124,8 @@ block-user-cancel-button unblock-user-confirm-button unblock-user-cancel-button + delete-contact-confirm-button + delete-contact-cancel-button Accept name change Invite button From 2cb2fc7d4806ac37b796a82afd0f30b731d68f95 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 14:04:32 +1000 Subject: [PATCH 192/867] UCS delete conversation --- .../settings/ConversationSettingsViewModel.kt | 31 ++++++++++++++++++- .../src/main/res/values/strings.xml | 2 ++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 72c4b5e8fa..29d29f4e3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -600,6 +600,35 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmDeleteConversation(){ + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.conversationsDelete), + message = Phrase.from(context, R.string.deleteConversationDescription) + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.delete), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_conversation_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_conversation_cancel), + onPositive = ::deleteConversation, + onNegative = {} + ) + ) + } + } + + private fun deleteConversation() { + viewModelScope.launch { + withContext(Dispatchers.Default) { + storage.deleteConversation(threadId) + } + + goBackHome() + } + } + private suspend fun goBackHome(){ navigator.navigateToIntent( Intent(context, HomeActivity::class.java).apply { @@ -740,7 +769,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.conversationsDelete), icon = R.drawable.ic_trash_2, qaTag = R.string.qa_conversation_settings_delete_conversation, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmDeleteConversation ) } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 34b9481795..e7f3b5eb1a 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -126,6 +126,8 @@ unblock-user-cancel-button delete-contact-confirm-button delete-contact-cancel-button + delete-conversation-confirm-button + delete-conversation-cancel-button Accept name change Invite button From 37c16996c43b441a41c336e4d57b8b03801b1b43 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 14:52:02 +1000 Subject: [PATCH 193/867] UCS Leave Community --- .../settings/ConversationSettingsViewModel.kt | 37 ++++++++++++++++++- .../src/main/res/values/strings.xml | 2 + 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 29d29f4e3e..0e86544bef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -43,6 +43,7 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences @@ -51,6 +52,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.DatabaseContentProviders +import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity @@ -77,6 +79,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val threadDb: ThreadDatabase, private val groupManagerV2: GroupManagerV2, private val prefs: TextSecurePreferences, + private val lokiThreadDatabase: LokiThreadDatabase, ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow( @@ -629,6 +632,38 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmLeaveCommunity(){ + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.communityLeave), + message = Phrase.from(context, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.leave), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_leave_community_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_leave_community_cancel), + onPositive = ::leaveCommunity, + onNegative = {} + ) + ) + } + } + + private fun leaveCommunity() { + viewModelScope.launch { + withContext(Dispatchers.Default) { + val community = lokiThreadDatabase.getOpenGroupChat(threadId) + if (community != null) { + OpenGroupManager.delete(community.server, community.room, context) + } + } + + goBackHome() + } + } + private suspend fun goBackHome(){ navigator.navigateToIntent( Intent(context, HomeActivity::class.java).apply { @@ -869,7 +904,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.communityLeave), icon = R.drawable.ic_log_out, qaTag = R.string.qa_conversation_settings_leave_community, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmLeaveCommunity ) } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index e7f3b5eb1a..abff971a96 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -128,6 +128,8 @@ delete-contact-cancel-button delete-conversation-confirm-button delete-conversation-cancel-button + leave-community-confirm-option + leave-community-cancel-option Accept name change Invite button From f9f7e80fc5d2d8ace7c1f2a076f9d7afb2f4fee8 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 2 May 2025 16:31:39 +1000 Subject: [PATCH 194/867] Fix incorrect removeLast usage for list --- .../libsession/messaging/sending_receiving/MessageReceiver.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 389663c925..38cc13097e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -116,7 +116,7 @@ object MessageReceiver { } // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than // likely be the one we want) but try older ones in case that didn't work) - var encryptionKeyPair = encryptionKeyPairs.removeLast() + var encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) fun decrypt() { try { val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), encryptionKeyPair) @@ -124,7 +124,7 @@ object MessageReceiver { sender = decryptionResult.second } catch (e: Exception) { if (encryptionKeyPairs.isNotEmpty()) { - encryptionKeyPair = encryptionKeyPairs.removeLast() + encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) decrypt() } else { Log.e("Loki", "Failed to decrypt group message", e) From bca46c2b3dc8c6aab60fab5285082f40d864bea3 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 2 May 2025 16:34:46 +1000 Subject: [PATCH 195/867] Bump version code to 406 --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index ba7226f285..1eb6562cbf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ configurations.configureEach { exclude module: "commons-logging" } -def canonicalVersionCode = 405 +def canonicalVersionCode = 406 def canonicalVersionName = "1.23.0" def postFixSize = 10 From c66f3f15a98cc8281404154f6e76ede1a5aba04d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 16:42:35 +1000 Subject: [PATCH 196/867] UCS leave/delete group --- .../conversation/v2/ConversationActivityV2.kt | 4 - .../conversation/v2/ConversationViewModel.kt | 38 ---------- .../v2/menus/ConversationMenuHelper.kt | 12 +-- .../settings/ConversationSettingsViewModel.kt | 74 ++++++++++++++++++- .../src/main/res/values/strings.xml | 4 + 5 files changed, 82 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 43d5771206..4e1c44bd75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1405,10 +1405,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, .let(::startActivity) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == android.R.id.home) false else viewModel.onOptionItemSelected(this, item) - } - fun block(deleteThread: Boolean) { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") val invitingAdmin = viewModel.invitingAdmin diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 98d7674681..b988f4ed3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.Context -import android.view.MenuItem import android.widget.Toast import androidx.annotation.StringRes import androidx.lifecycle.ViewModel @@ -10,7 +9,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import app.cash.copper.flow.observeQuery import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy import com.goterl.lazysodium.utils.KeyPair import com.squareup.phrase.Phrase import dagger.assisted.Assisted @@ -18,7 +16,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -59,7 +56,6 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.audio.AudioSlidePlayer -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase @@ -82,7 +78,6 @@ import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.avatarOptions -import org.thoughtcrime.securesms.util.observeChanges import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State import java.time.ZoneId @@ -1211,39 +1206,6 @@ class ConversationViewModel( } } - fun onOptionItemSelected( - // This must be the context of the activity as requirement from ConversationMenuHelper - context: Context, - item: MenuItem - ): Boolean { - val recipient = recipient ?: return false - - val inProgress = ConversationMenuHelper.onOptionItemSelected( - context = context, - item = item, - thread = recipient, - threadID = threadId, - factory = configFactory, - storage = storage, - groupManager = groupManagerV2, - deprecationManager = legacyGroupDeprecationManager, - ) - - if (inProgress != null) { - viewModelScope.launch { - inProgress.consumeEach { status -> - when (status) { - ConversationMenuHelper.GroupLeavingStatus.Left, - ConversationMenuHelper.GroupLeavingStatus.Error -> _uiState.update { it.copy(showLoader = false) } - else -> _uiState.update { it.copy(showLoader = true) } - } - } - } - } - - return true - } - fun getUsername(accountId: String) = usernameUtils.getContactNameWithAccountID(accountId) fun showDisappearingMessages() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 56ae5d98e8..05909e92e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -193,9 +193,9 @@ object ConversationMenuHelper { R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_edit_group -> { editGroup(context, thread) } R.id.menu_group_members -> { showGroupMembers(context, thread) } - R.id.menu_leave_group -> { return leaveGroup( + /* R.id.menu_leave_group -> { return leaveGroup( context, thread, threadID, factory, storage, groupManager, deprecationManager - ) } + ) }*/ R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) } @@ -311,7 +311,7 @@ object ConversationMenuHelper { Error, } - fun leaveGroup( +/* fun leaveGroup( context: Context, thread: Recipient, threadID: Long, @@ -393,9 +393,9 @@ object ConversationMenuHelper { } return null - } + }*/ - private fun confirmAndLeaveGroup( + /* private fun confirmAndLeaveGroup( context: Context, groupName: String, isAdmin: Boolean, @@ -454,7 +454,7 @@ object ConversationMenuHelper { } button(R.string.cancel) } - } + }*/ private fun inviteContacts(context: Context, thread: Recipient) { if (!thread.isCommunityRecipient) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 0e86544bef..3575ed0c96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -663,6 +663,76 @@ class ConversationSettingsViewModel @AssistedInject constructor( goBackHome() } } +//todo UCS kicked/destroyed groups should allow settings, and they have their own designs + + private fun getGroupName(): String{ + val conversation = recipient ?: return "" + val accountId = AccountId(conversation.address.toString()) + return configFactory.withGroupConfigs(accountId) { + it.groupInfo.getName() + } ?: groupV2?.name ?: "" + } + + private fun confirmLeaveGroup(){ + val groupData = groupV2 ?: return + _uiState.update { + + var title = R.string.groupDelete + var message: CharSequence = "" + var positiveButton = R.string.delete + var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel + + val groupName = getGroupName() + + if(groupData.kicked || groupData.destroyed){ + message = Phrase.from(context, R.string.groupDeleteDescriptionMember) + .put(GROUP_NAME_KEY, groupName) + .format() + + } else if (groupData.hasAdminKey()) { + message = Phrase.from(context, R.string.groupLeaveDescriptionAdmin) + .put(GROUP_NAME_KEY, groupName) + .format() + } else { + message = Phrase.from(context, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, groupName) + .format() + + title = R.string.groupLeave + positiveButton = R.string.leave + positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + } + + + it.copy( + showSimpleDialog = Dialog( + title = context.getString(title), + message = message, + positiveText = context.getString(positiveButton), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(positiveQaTag), + negativeQaTag = context.getString(negativeQaTag), + onPositive = ::leaveGroup, + onNegative = {} + ) + ) + } + } + + private fun leaveGroup() { + val conversation = recipient ?: return + viewModelScope.launch { + withContext(Dispatchers.Default) { + //todo UCS I need a loader here + groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) + //todo UCS I need to handle errors here potentially + } + + goBackHome() + } + } private suspend fun goBackHome(){ navigator.navigateToIntent( @@ -876,7 +946,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.groupLeave), icon = R.drawable.ic_log_out, qaTag = R.string.qa_conversation_settings_leave_group, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmLeaveGroup ) } @@ -885,7 +955,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.groupDelete), icon = R.drawable.ic_trash_2, qaTag = R.string.qa_conversation_settings_delete_group, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmLeaveGroup ) } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index abff971a96..6d4d45c632 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -130,6 +130,10 @@ delete-conversation-cancel-button leave-community-confirm-option leave-community-cancel-option + delete-group-confirm-button + delete-group-cancel-button + leave-group-confirm-button + leave-group-cancel-button Accept name change Invite button From e8a3adcf6dc97186bd8076dd31bb71ed2c2eecd8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 2 May 2025 16:57:31 +1000 Subject: [PATCH 197/867] Handling kicked/destroyed group, which need a menu showing only "Delete Group" --- .../conversation/v2/ConversationViewModel.kt | 6 +- .../v2/menus/ConversationMenuHelper.kt | 8 +- .../settings/ConversationSettingsViewModel.kt | 134 ++++++++++-------- 3 files changed, 82 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b988f4ed3e..0876d28a3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -235,11 +235,7 @@ class ConversationViewModel( } val showOptionsMenu: Boolean - get() = !isMessageRequestThread && !isDeprecatedLegacyGroup && !isInactiveGroupV2Thread - - private val isInactiveGroupV2Thread: Boolean - get() = recipient?.isGroupV2Recipient == true && - configFactory.getGroup(AccountId(recipient!!.address.toString()))?.shouldPoll == false + get() = !isMessageRequestThread && !isDeprecatedLegacyGroup private val isDeprecatedLegacyGroup: Boolean get() = recipient?.isLegacyGroupRecipient == true && legacyGroupDeprecationManager.isDeprecated diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 05909e92e6..24b867bbd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -311,7 +311,7 @@ object ConversationMenuHelper { Error, } -/* fun leaveGroup( + fun leaveGroup( context: Context, thread: Recipient, threadID: Long, @@ -393,9 +393,9 @@ object ConversationMenuHelper { } return null - }*/ + } - /* private fun confirmAndLeaveGroup( + private fun confirmAndLeaveGroup( context: Context, groupName: String, isAdmin: Boolean, @@ -454,7 +454,7 @@ object ConversationMenuHelper { } button(R.string.cancel) } - }*/ + } private fun inviteContacts(context: Context, thread: Recipient) { if (!thread.isCommunityRecipient) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 3575ed0c96..48d246ce6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -211,7 +211,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val pinned = threadDb.isPinned(threadId) // organise the setting options - val optionData = when { + val optionData = options@when { conversation.isLocalNumber -> { val mainOptions = mutableListOf() val dangerOptions = mutableListOf() @@ -280,77 +280,95 @@ class ConversationSettingsViewModel @AssistedInject constructor( } conversation.isGroupV2Recipient -> { - val mainOptions = mutableListOf() - val adminOptions = mutableListOf() - val dangerOptions = mutableListOf() - - mainOptions.add(optionSearch) + // if the user is kicked or the group destroyed, only show "Delete Group" + if(groupV2 != null && groupV2?.shouldPoll == false){ + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory( + danger = true, + items = listOf(optionDeleteGroup) + ) + ) + ) + ) + } else { + val mainOptions = mutableListOf() + val adminOptions = mutableListOf() + val dangerOptions = mutableListOf() - // for non admins, disappearing messages is in the non admin section - if(!isAdmin){ - mainOptions.add(optionDisappearingMessage(disappearingSubtitle)) - } + mainOptions.add(optionSearch) - mainOptions.addAll(listOf( - if(pinned) optionUnpin else optionPin, - optionNotifications(null), //todo UCS notifications logic - optionGroupMembers, - optionAttachments, - )) + // for non admins, disappearing messages is in the non admin section + if (!isAdmin) { + mainOptions.add(optionDisappearingMessage(disappearingSubtitle)) + } - // apply different options depending on admin status - if(isAdmin){ - dangerOptions.addAll( + mainOptions.addAll( listOf( - optionClearMessages, - optionDeleteGroup + if (pinned) optionUnpin else optionPin, + optionNotifications(null), //todo UCS notifications logic + optionGroupMembers, + optionAttachments, ) ) - // admin options - adminOptions.addAll(listOf( - optionManageMembers, - optionDisappearingMessage(disappearingSubtitle) - )) + // apply different options depending on admin status + if (isAdmin) { + dangerOptions.addAll( + listOf( + optionClearMessages, + optionDeleteGroup + ) + ) - // the returned options for group admins - listOf( - OptionsCategory( - items = listOf( - OptionsSubCategory(items = mainOptions), + // admin options + adminOptions.addAll( + listOf( + optionManageMembers, + optionDisappearingMessage(disappearingSubtitle) ) - ), - OptionsCategory( - name = context.getString(R.string.adminSettings), - items = listOf( - OptionsSubCategory(items = adminOptions), - OptionsSubCategory( - danger = true, - items = dangerOptions + ) + + // the returned options for group admins + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + ) + ), + OptionsCategory( + name = context.getString(R.string.adminSettings), + items = listOf( + OptionsSubCategory(items = adminOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) ) ) ) - ) - } else { - dangerOptions.addAll( - listOf( - optionClearMessages, - optionLeaveGroup + } else { + dangerOptions.addAll( + listOf( + optionClearMessages, + optionLeaveGroup + ) ) - ) - // the returned options for group non-admins - listOf( - OptionsCategory( - items = listOf( - OptionsSubCategory(items = mainOptions), - OptionsSubCategory( - danger = true, - items = dangerOptions + // the returned options for group non-admins + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) ) ) ) - ) + } } } @@ -595,6 +613,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun deleteContact() { val conversation = recipient ?: return viewModelScope.launch { + //todo UCS I need a loader here? withContext(Dispatchers.Default) { storage.deleteContactAndSyncConfig(conversation.address.toString()) } @@ -624,6 +643,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun deleteConversation() { viewModelScope.launch { + //todo UCS I need a loader here? withContext(Dispatchers.Default) { storage.deleteConversation(threadId) } @@ -653,6 +673,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun leaveCommunity() { viewModelScope.launch { + //todo UCS I need a loader here? withContext(Dispatchers.Default) { val community = lokiThreadDatabase.getOpenGroupChat(threadId) if (community != null) { @@ -663,7 +684,6 @@ class ConversationSettingsViewModel @AssistedInject constructor( goBackHome() } } -//todo UCS kicked/destroyed groups should allow settings, and they have their own designs private fun getGroupName(): String{ val conversation = recipient ?: return "" @@ -685,7 +705,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val groupName = getGroupName() - if(groupData.kicked || groupData.destroyed){ + if(!groupData.shouldPoll){ message = Phrase.from(context, R.string.groupDeleteDescriptionMember) .put(GROUP_NAME_KEY, groupName) .format() From ba507310ea8ca659c748e59cfa343a57536fec58 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 10:35:37 +1000 Subject: [PATCH 198/867] Added loading state in UCS --- .../v2/settings/ConversationSettingsScreen.kt | 6 +++ .../settings/ConversationSettingsViewModel.kt | 42 +++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index dc5417a4d4..2c2f6dfd44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.ExpandableText import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -270,6 +271,11 @@ fun ConversationSettings( ) ) } + + // Loading + if (data.showLoading) { + LoadingDialog() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 48d246ce6f..e10305d3cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -613,11 +613,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun deleteContact() { val conversation = recipient ?: return viewModelScope.launch { - //todo UCS I need a loader here? + showLoading() withContext(Dispatchers.Default) { storage.deleteContactAndSyncConfig(conversation.address.toString()) } + hideLoading() goBackHome() } } @@ -643,11 +644,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun deleteConversation() { viewModelScope.launch { - //todo UCS I need a loader here? + showLoading() withContext(Dispatchers.Default) { storage.deleteConversation(threadId) } + hideLoading() goBackHome() } } @@ -673,7 +675,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun leaveCommunity() { viewModelScope.launch { - //todo UCS I need a loader here? + showLoading() withContext(Dispatchers.Default) { val community = lokiThreadDatabase.getOpenGroupChat(threadId) if (community != null) { @@ -681,6 +683,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + hideLoading() goBackHome() } } @@ -744,13 +747,23 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun leaveGroup() { val conversation = recipient ?: return viewModelScope.launch { + showLoading() withContext(Dispatchers.Default) { - //todo UCS I need a loader here - groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) - //todo UCS I need to handle errors here potentially + try { + groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) + hideLoading() + goBackHome() + } catch (e: Exception){ + withContext(Dispatchers.Main) { + hideLoading() + + val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) + .put(GROUP_NAME_KEY, getGroupName()) + .format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() + } + } } - - goBackHome() } } @@ -776,6 +789,18 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun showLoading(){ + _uiState.update { + it.copy(showLoading = true) + } + } + + private fun hideLoading(){ + _uiState.update { + it.copy(showLoading = false) + } + } + private fun navigateTo(destination: ConversationSettingsDestination){ viewModelScope.launch { navigator.navigate(destination) @@ -1007,6 +1032,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val descriptionQaTag: String? = null, val accountId: String? = null, val showSimpleDialog: Dialog? = null, + val showLoading: Boolean = false, val categories: List = emptyList() ) From 12961cd96d55d5fe2d4c60965a1c728af4cd22f2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 10:36:14 +1000 Subject: [PATCH 199/867] comment to fix --- .../conversation/v2/settings/ConversationSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index e10305d3cf..7524cc4aa7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -788,7 +788,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } } - +//todo UCS it seems the app bar does weird things, like when bringing up the keyboard in a conversation private fun showLoading(){ _uiState.update { it.copy(showLoading = true) From 3c9ee9ef2f47f601010987ef81710dad67c3888d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 11:20:30 +1000 Subject: [PATCH 200/867] Fixed conversation app bar insets --- .../securesms/conversation/v2/ConversationActivityV2.kt | 8 +++++++- .../v2/settings/ConversationSettingsViewModel.kt | 2 +- .../securesms/ui/components/ConversationAppBar.kt | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 4e1c44bd75..2932a4dc3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -108,6 +108,7 @@ import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder @@ -480,6 +481,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, fun showOpenUrlDialog(url: String) = viewModel.onCommand(ShowOpenUrlDialog(url)) // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?) { + applyCommonPropertiesForCompose() + super.onCreate(savedInstanceState) + } + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) @@ -488,7 +494,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // set the compose dialog content binding.dialogOpenUrl.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { + setThemedContent { val dialogsState by viewModel.dialogsState.collectAsState() ConversationV2Dialogs( dialogsState = dialogsState, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 7524cc4aa7..e10305d3cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -788,7 +788,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } } -//todo UCS it seems the app bar does weird things, like when bringing up the keyboard in a conversation + private fun showLoading(){ _uiState.update { it.copy(showLoading = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 8668c2aa73..c04dc7882f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -63,6 +64,7 @@ fun ConversationAppBar( val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) CenterAlignedTopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), title = { Column( horizontalAlignment = Alignment.CenterHorizontally From 6dc5b7601e116370c8e1f7838d064f85f8e2f955 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 5 May 2025 11:22:02 +1000 Subject: [PATCH 201/867] Migrate to gradle version catalog (#1129) --- app/build.gradle | 241 +++++++++++++++--------------- build.gradle | 16 +- content-descriptions/build.gradle | 16 +- gradle.properties | 27 ---- gradle/libs.versions.toml | 210 ++++++++++++++++++++++++++ libsession/build.gradle | 84 ++++++----- libsignal/build.gradle | 26 ++-- 7 files changed, 402 insertions(+), 218 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/app/build.gradle b/app/build.gradle index 1eb6562cbf..42374e4d04 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -272,161 +272,158 @@ preBuild.dependsOn ipToCode dependencies { implementation project(':content-descriptions') - ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") - ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") - ksp("com.github.bumptech.glide:ksp:$glideVersion") - implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion") - implementation("androidx.hilt:hilt-work:$androidxHiltVersion") - - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - implementation "androidx.appcompat:appcompat:$appcompatVersion" - implementation 'androidx.recyclerview:recyclerview:1.4.0' - implementation "com.google.android.material:material:$materialVersion" - implementation 'com.google.android.flexbox:flexbox:3.0.0' - implementation 'androidx.legacy:legacy-support-v13:1.0.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation "androidx.preference:preference-ktx:$preferenceVersion" - implementation 'androidx.legacy:legacy-preference-v14:1.0.0' - implementation 'androidx.gridlayout:gridlayout:1.0.0' - implementation 'androidx.exifinterface:exifinterface:1.3.4' - implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" - implementation "androidx.paging:paging-runtime-ktx:$pagingVersion" - implementation 'androidx.activity:activity-ktx:1.10.1' - implementation 'androidx.activity:activity-compose:1.10.1' - implementation 'androidx.fragment:fragment-ktx:1.8.6' - implementation "androidx.core:core-ktx:$coreVersion" - implementation "androidx.work:work-runtime-ktx:2.10.0" - - playImplementation ("com.google.firebase:firebase-messaging:24.0.0") { + ksp(libs.androidx.hilt.compiler) + ksp(libs.dagger.hilt.compiler) + ksp(libs.glide.ksp) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + + implementation(libs.hilt.android) + implementation libs.androidx.appcompat + implementation libs.androidx.recyclerview + implementation libs.material + implementation libs.flexbox + implementation libs.androidx.legacy.support.v13 + implementation libs.androidx.cardview + implementation libs.androidx.preference.ktx + implementation libs.androidx.legacy.preference.v14 + implementation libs.androidx.gridlayout + implementation libs.androidx.exifinterface + implementation libs.androidx.constraintlayout + implementation libs.androidx.lifecycle.common.java8 + implementation libs.androidx.lifecycle.runtime.ktx + implementation libs.androidx.lifecycle.livedata.ktx + implementation libs.androidx.lifecycle.process + implementation libs.androidx.lifecycle.viewmodel.compose + implementation libs.androidx.lifecycle.extensions + implementation libs.androidx.paging.runtime.ktx + implementation libs.androidx.activity.ktx + implementation libs.androidx.activity.compose + implementation libs.androidx.fragment.ktx + implementation libs.androidx.core.ktx + implementation libs.androidx.work.runtime.ktx + + playImplementation (libs.firebase.messaging) { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } - if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300' - - implementation 'androidx.media3:media3-exoplayer:1.4.0' - implementation 'androidx.media3:media3-ui:1.4.0' - implementation 'org.conscrypt:conscrypt-android:2.5.2' - implementation 'org.signal:aesgcmprovider:0.0.3' - implementation 'io.github.webrtc-sdk:android:125.6422.07' - implementation "me.leolin:ShortcutBadger:1.1.16" - implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' - implementation 'com.github.chrisbanes:PhotoView:2.1.3' - implementation "com.github.bumptech.glide:glide:$glideVersion" - implementation "com.github.bumptech.glide:compose:1.0.0-beta01" - implementation 'com.makeramen:roundedimageview:2.1.0' - implementation 'org.greenrobot:eventbus:3.0.0' - implementation 'com.vanniktech:android-image-cropper:4.5.0' - implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { + if (project.hasProperty('huawei')) huaweiImplementation libs.huawei.push + + implementation libs.androidx.media3.exoplayer + implementation libs.androidx.media3.ui + implementation libs.conscrypt.android + implementation libs.aesgcmprovider + implementation libs.android + implementation libs.shortcutbadger + implementation libs.httpclient.android + implementation libs.photoview + implementation libs.glide + implementation libs.compose + implementation libs.roundedimageview + implementation libs.eventbus + implementation libs.android.image.cropper + implementation (libs.subsampling.scale.image.view) { exclude group: 'com.android.support', module: 'support-annotations' } - implementation ('com.tomergoldst.android:tooltips:1.0.6') { + implementation (libs.tooltips) { exclude group: 'com.android.support', module: 'appcompat-v7' } - implementation ('com.klinkerapps:android-smsmms:4.0.1') { + implementation (libs.kinkerapps.android.smsmms) { exclude group: 'com.squareup.okhttp', module: 'okhttp' exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } - implementation 'com.annimon:stream:1.1.8' - implementation 'androidx.sqlite:sqlite-ktx:2.3.1' - implementation 'net.zetetic:sqlcipher-android:4.7.2' + implementation libs.stream + implementation libs.androidx.sqlite.ktx + implementation libs.sqlcipher.android implementation project(":libsignal") implementation project(":libsession") - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" - implementation "com.github.session-foundation.session-android-curve-25519:curve25519-java:$curve25519Version" + implementation libs.kotlinx.serialization.json + implementation libs.curve25519.java implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.12.1@aar" - implementation "com.google.protobuf:protobuf-java:$protobufVersion" - implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation "com.squareup.phrase:phrase:$phraseVersion" - implementation 'app.cash.copper:copper-flow:1.0.0' - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" - implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" - implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" - implementation "com.opencsv:opencsv:4.6" - testImplementation "junit:junit:$junitVersion" - testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.11.0" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - androidTestImplementation "org.mockito:mockito-android:4.11.0" - androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "androidx.test:core:$testCoreVersion" - testImplementation "androidx.arch.core:core-testing:2.2.0" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation libs.protobuf.java + implementation libs.jackson.databind + implementation libs.okhttp + implementation libs.phrase + implementation libs.copper.flow + implementation libs.kotlinx.coroutines.android + implementation libs.kovenant + implementation libs.kovenant.android + implementation libs.rxbinding + implementation libs.opencsv + testImplementation libs.junit + testImplementation libs.assertj.core + testImplementation libs.mockito.inline + testImplementation libs.mockito.kotlin + androidTestImplementation libs.mockito.android + androidTestImplementation libs.mockito.kotlin + testImplementation libs.androidx.core + testImplementation libs.androidx.core.testing + testImplementation libs.kotlinx.coroutines.testing + androidTestImplementation libs.kotlinx.coroutines.testing // Core library - androidTestImplementation "androidx.test:core:$testCoreVersion" + androidTestImplementation libs.androidx.core // AndroidJUnitRunner and JUnit Rules - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation libs.androidx.runner + androidTestImplementation libs.androidx.rules // Assertions - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.ext:truth:1.5.0' - testImplementation 'com.google.truth:truth:1.1.3' - androidTestImplementation 'com.google.truth:truth:1.1.3' + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.truth + testImplementation libs.truth + androidTestImplementation libs.truth // Espresso dependencies - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' - androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3" - debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3" - androidTestUtil 'androidx.test:orchestrator:1.4.2' - - testImplementation 'org.robolectric:robolectric:4.12.2' - testImplementation 'org.robolectric:shadows-multidex:4.12.2' - testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric - testImplementation 'app.cash.turbine:turbine:1.1.0' + androidTestImplementation libs.androidx.espresso.core + androidTestImplementation libs.androidx.espresso.contrib + androidTestImplementation libs.androidx.espresso.intents + androidTestImplementation libs.androidx.espresso.accessibility + androidTestImplementation libs.androidx.espresso.web + androidTestImplementation libs.androidx.idling.concurrent + androidTestImplementation libs.androidx.espresso.idling.resource + androidTestImplementation libs.androidx.compose.ui.test.junit4 + debugImplementation libs.androidx.compose.ui.test.manifest + androidTestUtil libs.androidx.orchestrator + + testImplementation libs.robolectric + testImplementation libs.robolectric.shadows.multidex + testImplementation libs.conscrypt.openjdk.uber // For Robolectric + testImplementation libs.turbine // compose - Dependency composeBom = platform('androidx.compose:compose-bom:2025.03.01') - implementation composeBom - testImplementation composeBom - androidTestImplementation composeBom + implementation platform(libs.androidx.compose.bom) + testImplementation platform(libs.androidx.compose.bom) + androidTestImplementation platform(libs.androidx.compose.bom) - implementation "androidx.compose.ui:ui" - implementation "androidx.compose.animation:animation" - implementation "androidx.compose.ui:ui-tooling" - implementation "androidx.compose.runtime:runtime-livedata" - implementation "androidx.compose.foundation:foundation-layout" - implementation "androidx.compose.material3:material3" + implementation libs.androidx.compose.ui + implementation libs.androidx.compose.animation + implementation libs.androidx.compose.ui.tooling + implementation libs.androidx.compose.runtime.livedata + implementation libs.androidx.compose.foundation.layout + implementation libs.androidx.compose.material3 - androidTestImplementation "androidx.compose.ui:ui-test-junit4-android" - debugImplementation "androidx.compose.ui:ui-test-manifest" + androidTestImplementation libs.androidx.ui.test.junit4.android + debugImplementation libs.androidx.compose.ui.test.manifest // Navigation - implementation "androidx.navigation:navigation-fragment-ktx:$navVersion" - implementation "androidx.navigation:navigation-ui-ktx:$navVersion" - implementation "androidx.navigation:navigation-compose:$navVersion" + implementation libs.androidx.navigation.fragment.ktx + implementation libs.androidx.navigation.ui.ktx + implementation libs.androidx.navigation.compose - implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" - implementation "com.google.accompanist:accompanist-permissions:0.36.0" - implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" + implementation libs.accompanist.themeadapter.appcompat + implementation libs.accompanist.permissions + implementation libs.accompanist.drawablepainter - implementation "androidx.camera:camera-camera2:1.3.2" - implementation "androidx.camera:camera-lifecycle:1.3.2" - implementation "androidx.camera:camera-view:1.3.2" + implementation libs.androidx.camera.camera2 + implementation libs.androidx.camera.lifecycle + implementation libs.androidx.camera.view - // Note: ZXing 3.5.3 is the latest stable release as of 2024/08/21 - implementation "com.google.zxing:core:$zxingVersion" + implementation libs.zxing.core // Note: 1.1.0 is the latest stable release as of 2024/12/18 - implementation "androidx.biometric:biometric:1.1.0" + implementation libs.androidx.biometric } static def getLastCommitTimestamp() { diff --git a/build.gradle b/build.gradle index 9fab16bbe9..ac5ae33460 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,6 @@ buildscript { dependencies { classpath files('libs/gradle-witness.jar') - classpath "com.google.gms:google-services:$googleServicesVersion" classpath "com.squareup:javapoet:1.13.0" if (project.hasProperty('huawei')) classpath 'com.huawei.agconnect:agcp:1.9.1.300' } @@ -21,13 +20,14 @@ buildscript { // List plugins AND their versions here, but don't apply. This allows you to use the plugin // in your module without specifying the version. plugins { - id 'com.android.application' version "$gradlePluginVersion" apply false - id 'com.android.library' version "$gradlePluginVersion" apply false - id 'org.jetbrains.kotlin.android' version "$kotlinVersion" apply false - id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlinVersion" apply false - id 'org.jetbrains.kotlin.plugin.compose' version "$kotlinVersion" apply false - id 'com.google.devtools.ksp' version "$kspVersion" apply false - id 'com.google.dagger.hilt.android' version "$daggerHiltVersion" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.plugin.serialization) apply false + alias(libs.plugins.kotlin.plugin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.google.services) apply false } allprojects { diff --git a/content-descriptions/build.gradle b/content-descriptions/build.gradle index ba445007a5..173e7d24a2 100644 --- a/content-descriptions/build.gradle +++ b/content-descriptions/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { @@ -32,10 +32,10 @@ android { dependencies { - implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation libs.androidx.core.ktx + implementation libs.androidx.appcompat + implementation libs.material + testImplementation libs.junit + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 15c9132726..3a36026d73 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,34 +14,7 @@ android.enableJetifier=true org.gradle.jvmargs=-Xmx3096M -Dkotlin.daemon.jvm.options\="-Xmx3096M" -gradlePluginVersion=8.9.0 -googleServicesVersion=4.3.12 -kotlinVersion=2.1.10 -kspVersion=2.1.10-1.0.31 -navVersion=2.8.0-beta05 android.useAndroidX=true -appcompatVersion=1.7.0 -coreVersion=1.16.0-rc01 -coroutinesVersion=1.9.0 -curve25519Version=0.6.0 -jetpackHiltVersion=1.2.0 -daggerHiltVersion=2.55 -androidxHiltVersion = 1.2.0 -glideVersion=4.16.0 -jacksonDatabindVersion=2.9.8 -junitVersion=4.13.2 -kotlinxJsonVersion=1.3.3 -kovenantVersion=3.3.0 -phraseVersion=1.2.0 -lifecycleVersion=2.7.0 -materialVersion=1.12.0 -mockitoKotlinVersion=4.1.0 -okhttpVersion=4.12.0 -pagingVersion=3.0.0 -preferenceVersion=1.2.1 -protobufVersion=4.29.3 -testCoreVersion=1.5.0 -zxingVersion=3.5.3 android.nonTransitiveRClass=false android.nonFinalResIds=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..ec038baf7a --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,210 @@ +[versions] +accompanistPermissionsVersion = "0.36.0" +accompanistThemeadapterAppcompatVersion = "0.33.1-alpha" +activityKtxVersion = "1.10.1" +aesgcmproviderVersion = "0.0.3" +androidImageCropperVersion = "4.5.0" +androidSmsmmsVersion = "4.0.1" +androidVersion = "125.6422.07" +annotationVersion = "1.5.0" +assertjCoreVersion = "3.11.1" +biometricVersion = "1.1.0" +cameraCamera2Version = "1.3.2" +cardviewVersion = "1.0.0" +composeBomVersion = "2025.03.01" +composeVersion = "1.0.0-beta01" +conscryptAndroidVersion = "2.5.2" +constraintlayoutVersion = "2.2.1" +copperFlowVersion = "1.0.0" +coreTestingVersion = "2.2.0" +curve25519JavaVersion = "0.6.0" +espressoCoreVersion = "3.5.1" +eventbusVersion = "3.0.0" +exifinterfaceVersion = "1.3.4" +firebaseMessagingVersion = "24.0.0" +flexboxVersion = "3.0.0" +fragmentKtxVersion = "1.8.6" +gradlePluginVersion = "8.9.0" +googleServicesVersion = "4.4.2" +gridlayoutVersion = "1.0.0" +httpclientAndroidVersion = "4.3.5" +jnaVersion = "5.12.1" +junit = "1.1.5" +kotlinVersion = "2.1.10" +kotlinxDatetimeVersion = "0.6.0" +kryoVersion = "5.1.1" +kspVersion = "2.1.10-1.0.31" +legacySupportV13Version = "1.0.0" +libsessionUtilAndroidVersion = "1.0.4" +lifecycleExtensionsVersion = "2.2.0" +media3ExoplayerVersion = "1.4.0" +mockitoInlineVersion = "4.11.0" +navVersion = "2.8.0-beta05" +appcompatVersion = "1.7.0" +coreVersion = "1.16.0-rc01" +coroutinesVersion = "1.9.0" +curve25519Version = "0.6.0" +jetpackHiltVersion = "1.2.0" +daggerHiltVersion = "2.55" +androidxHiltVersion = "1.2.0" +glideVersion = "4.16.0" +jacksonDatabindVersion = "2.9.8" +junitVersion = "4.13.2" +kotlinxJsonVersion = "1.3.3" +kovenantVersion = "3.3.0" +opencsvVersion = "4.6" +orchestratorVersion = "1.4.2" +photoviewVersion = "2.1.3" +phraseVersion = "1.2.0" +lifecycleVersion = "2.7.0" +materialVersion = "1.12.0" +mockitoKotlinVersion = "4.1.0" +okhttpVersion = "4.12.0" +pagingVersion = "3.0.0" +preferenceVersion = "1.2.1" +protobufVersion = "4.29.3" +recyclerviewVersion = "1.4.0" +robolectricVersion = "4.12.2" +roundedimageviewVersion = "2.1.0" +runnerVersion = "1.5.2" +rxbindingVersion = "3.1.0" +shortcutbadgerVersion = "1.1.16" +sqlcipherAndroidVersion = "4.7.2" +streamVersion = "1.1.8" +sqliteKtxVersion = "2.3.1" +subsamplingScaleImageViewVersion = "3.6.0" +testCoreVersion = "1.5.0" +tooltipsVersion = "1.0.6" +truthVersion = "1.1.3" +turbineVersion = "1.1.0" +uiTestJunit4Version = "1.5.3" +workRuntimeKtxVersion = "2.10.0" +zxingVersion = "3.5.3" +huaweiPushVersion = "6.7.0.300" + +[libraries] +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistThemeadapterAppcompatVersion" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } +accompanist-themeadapter-appcompat = { module = "com.google.accompanist:accompanist-themeadapter-appcompat", version.ref = "accompanistThemeadapterAppcompatVersion" } +aesgcmprovider = { module = "org.signal:aesgcmprovider", version.ref = "aesgcmproviderVersion" } +android = { module = "io.github.webrtc-sdk:android", version.ref = "androidVersion" } +android-image-cropper = { module = "com.vanniktech:android-image-cropper", version.ref = "androidImageCropperVersion" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotationVersion" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometricVersion" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCamera2Version" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCamera2Version" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCamera2Version" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBomVersion" } +androidx-core = { module = "androidx.test:core", version.ref = "testCoreVersion" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTestingVersion" } +androidx-espresso-accessibility = { module = "androidx.test.espresso:espresso-accessibility", version.ref = "espressoCoreVersion" } +androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoCoreVersion" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCoreVersion" } +androidx-espresso-idling-resource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espressoCoreVersion" } +androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoCoreVersion" } +androidx-espresso-web = { module = "androidx.test.espresso:espresso-web", version.ref = "espressoCoreVersion" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-idling-concurrent = { module = "androidx.test.espresso.idling:idling-concurrent", version.ref = "espressoCoreVersion" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navVersion" } +androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navVersion" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navVersion" } +androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestratorVersion" } +androidx-rules = { module = "androidx.test:rules", version.ref = "testCoreVersion" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runnerVersion" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqliteKtxVersion" } +androidx-truth = { module = "androidx.test.ext:truth", version.ref = "testCoreVersion" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4Version" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiTestJunit4Version" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCoreVersion" } +conscrypt-openjdk-uber = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscryptAndroidVersion" } +copper-flow = { module = "app.cash.copper:copper-flow", version.ref = "copperFlowVersion" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetimeVersion" } +kryo = { module = "com.esotericsoftware:kryo", version.ref = "kryoVersion" } +libsession-util-android = { module = "org.sessionfoundation:libsession-util-android", version.ref = "libsessionUtilAndroidVersion" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxingVersion" } +curve25519-java = { module = "com.github.session-foundation.session-android-curve-25519:curve25519-java", version.ref = "curve25519JavaVersion" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabindVersion" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jnaVersion" } +junit = { module = "junit:junit", version.ref = "junitVersion" } +kinkerapps-android-smsmms = { module = "com.klinkerapps:android-smsmms", version.ref = "androidSmsmmsVersion" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityKtxVersion" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtxVersion" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" } +androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "cardviewVersion" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreVersion" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } +androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayoutVersion" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltVersion" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHiltVersion" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHiltVersion" } +androidx-legacy-preference-v14 = { module = "androidx.legacy:legacy-preference-v14", version.ref = "legacySupportV13Version" } +androidx-legacy-support-v13 = { module = "androidx.legacy:legacy-support-v13", version.ref = "legacySupportV13Version" } +androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycleVersion" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleVersion" } +androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycleExtensionsVersion" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleVersion" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleVersion" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleVersion" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3ExoplayerVersion" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3ExoplayerVersion" } +androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "pagingVersion" } +androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceVersion" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerviewVersion" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtxVersion" } +compose = { module = "com.github.bumptech.glide:compose", version.ref = "composeVersion" } +conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroidVersion" } +eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbusVersion" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessagingVersion" } +flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexboxVersion" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersion" } +glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glideVersion" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "daggerHiltVersion" } +dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHiltVersion" } +httpclient-android = { module = "org.apache.httpcomponents:httpclient-android", version.ref = "httpclientAndroidVersion" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } +kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } +kovenant-android = { module = "nl.komponents.kovenant:kovenant-android", version.ref = "kovenantVersion" } +kovenant = { module = "nl.komponents.kovenant:kovenant", version.ref = "kovenantVersion" } +material = { module = "com.google.android.material:material", version.ref = "materialVersion" } +mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoInlineVersion" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInlineVersion" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } +opencsv = { module = "com.opencsv:opencsv", version.ref = "opencsvVersion" } +photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoviewVersion" } +phrase = { module = "com.squareup.phrase:phrase", version.ref = "phraseVersion" } +protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufVersion" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectricVersion" } +roundedimageview = { module = "com.makeramen:roundedimageview", version.ref = "roundedimageviewVersion" } +rxbinding = { module = "com.jakewharton.rxbinding3:rxbinding", version.ref = "rxbindingVersion" } +robolectric-shadows-multidex = { module = "org.robolectric:shadows-multidex", version.ref = "robolectricVersion" } +shortcutbadger = { module = "me.leolin:ShortcutBadger", version.ref = "shortcutbadgerVersion" } +sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipherAndroidVersion" } +stream = { module = "com.annimon:stream", version.ref = "streamVersion" } +subsampling-scale-image-view = { module = "com.davemorrissey.labs:subsampling-scale-image-view", version.ref = "subsamplingScaleImageViewVersion" } +tooltips = { module = "com.tomergoldst.android:tooltips", version.ref = "tooltipsVersion" } +truth = { module = "com.google.truth:truth", version.ref = "truthVersion" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbineVersion" } +huawei-push = { module = 'com.huawei.hms:push', version.ref = 'huaweiPushVersion' } + + +[plugins] +android-application = { id = "com.android.application", version.ref = "gradlePluginVersion" } +android-library = { id = "com.android.library", version.ref = "gradlePluginVersion" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" } +kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" } +kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinVersion" } +ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "daggerHiltVersion" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index 64561bcec7..bc1d32c636 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -1,10 +1,10 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) id 'kotlin-parcelize' - id 'com.google.devtools.ksp' - id 'com.google.dagger.hilt.android' } android { @@ -49,43 +49,47 @@ dependencies { implementation project(":libsignal") implementation project(":liblazysodium") - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") - ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") + implementation(libs.hilt.android) + ksp(libs.dagger.hilt.compiler) + ksp(libs.androidx.hilt.compiler) - api 'org.sessionfoundation:libsession-util-android:1.0.4' + api libs.libsession.util.android - implementation "net.java.dev.jna:jna:5.12.1@aar" - implementation "androidx.core:core-ktx:$coreVersion" - implementation "androidx.appcompat:appcompat:$appcompatVersion" - implementation "androidx.preference:preference-ktx:$preferenceVersion" - implementation "com.google.android.material:material:$materialVersion" - implementation "com.google.protobuf:protobuf-java:$protobufVersion" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - implementation "com.github.bumptech.glide:glide:$glideVersion" - implementation 'com.annimon:stream:1.1.8' - implementation 'com.makeramen:roundedimageview:2.1.0' - implementation 'com.esotericsoftware:kryo:5.1.1' - implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "com.github.session-foundation.session-android-curve-25519:curve25519-java:$curve25519Version" - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation "com.squareup.phrase:phrase:$phraseVersion" - implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" - implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" + implementation (libs.jna) { + artifact { + type = 'aar' + } + } + + implementation libs.androidx.core.ktx + implementation libs.androidx.appcompat + implementation libs.androidx.preference.ktx + implementation libs.material + implementation libs.protobuf.java + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core + implementation libs.glide + implementation libs.stream + implementation libs.roundedimageview + implementation libs.kryo + implementation libs.jackson.databind + implementation libs.curve25519.java + implementation libs.okhttp + implementation libs.phrase + implementation libs.kotlin.reflect + implementation libs.kotlinx.coroutines.android + implementation libs.kotlinx.serialization.json + implementation libs.kovenant - // Note: kotlinx-datetime:0.6.0 is the latest version as of 2024/08/09 -AL - implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.6.0' + implementation libs.kotlinx.datetime - testImplementation "junit:junit:$junitVersion" - testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.11.0" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "androidx.test:core:$testCoreVersion" - testImplementation "androidx.arch.core:core-testing:2.1.0" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" - implementation 'org.greenrobot:eventbus:3.0.0' + testImplementation libs.junit + testImplementation libs.assertj.core + testImplementation libs.mockito.inline + testImplementation libs.mockito.kotlin + testImplementation libs.androidx.core + testImplementation libs.androidx.core.testing + testImplementation libs.kotlinx.coroutines.testing + testImplementation libs.conscrypt.openjdk.uber + implementation libs.eventbus } \ No newline at end of file diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 74801dfac9..5c2b71dfb5 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { @@ -26,15 +26,15 @@ android { } dependencies { - implementation "androidx.annotation:annotation:1.5.0" - implementation "com.google.protobuf:protobuf-java:$protobufVersion" - implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "com.github.session-foundation.session-android-curve-25519:curve25519-java:$curve25519Version" - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" - testImplementation "junit:junit:$junitVersion" - testImplementation "org.assertj:assertj-core:3.11.1" - testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" + implementation libs.androidx.annotation + implementation libs.protobuf.java + implementation libs.jackson.databind + implementation libs.curve25519.java + implementation libs.okhttp + implementation libs.kotlin.reflect + implementation libs.kotlinx.coroutines.android + implementation libs.kovenant + testImplementation libs.junit + testImplementation libs.assertj.core + testImplementation libs.conscrypt.openjdk.uber } From e1b500b5cc3c2dd9bbe403c2a6ed71149d2b21a8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 11:26:33 +1000 Subject: [PATCH 202/867] Clean up --- .../securesms/conversation/v2/ConversationViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 0876d28a3e..0b98530622 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1065,7 +1065,6 @@ class ConversationViewModel( fun updateRecipient() { _recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId)) updateAppBarData(recipient) - // update ui accordingly } /** From 143dc087e343797bb56625ea16ff9fade3193fe0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 13:14:19 +1000 Subject: [PATCH 203/867] Fixed tests --- .../conversation/v2/ConversationViewModel.kt | 9 +++++--- .../securesms/dependencies/ContentModule.kt | 8 ++++++- .../securesms/util/RecipientChangeSource.kt | 20 ++++++++++++++++++ .../v2/ConversationViewModelTest.kt | 21 +++++++++++++++++-- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 0b98530622..6417ac9925 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.RecipientChangeSource import org.thoughtcrime.securesms.util.avatarOptions import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State @@ -104,7 +105,8 @@ class ConversationViewModel( val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, private val expiredGroupManager: ExpiredGroupManager, private val usernameUtils: UsernameUtils, - private val avatarUtils: AvatarUtils + private val avatarUtils: AvatarUtils, + private val recipientChangeSource: RecipientChangeSource ) : ViewModel() { @@ -322,8 +324,7 @@ class ConversationViewModel( // update state on recipient changes viewModelScope.launch(Dispatchers.Default) { - context.contentResolver - .observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI).collect { + recipientChangeSource.changes().collect { _uiState.update { it.copy( shouldExit = recipient == null, @@ -1243,6 +1244,7 @@ class ConversationViewModel( private val expiredGroupManager: ExpiredGroupManager, private val usernameUtils: UsernameUtils, private val avatarUtils: AvatarUtils, + private val recipientChangeSource: RecipientChangeSource, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -1267,6 +1269,7 @@ class ConversationViewModel( expiredGroupManager = expiredGroupManager, usernameUtils = usernameUtils, avatarUtils = avatarUtils, + recipientChangeSource = recipientChangeSource ) as T } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt index 89098a0f16..07ca3ee5ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt @@ -6,12 +6,18 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.thoughtcrime.securesms.util.RecipientChangeSource +import org.thoughtcrime.securesms.util.ContentObserverRecipientChangeSource @Module @InstallIn(SingletonComponent::class) object ContentModule { @Provides - fun providesContentResolver(@ApplicationContext context: Context) =context.contentResolver + fun providesContentResolver(@ApplicationContext context: Context) = context.contentResolver + + @Provides + fun provideRecipientChangeSource(@ApplicationContext context: Context): RecipientChangeSource = + ContentObserverRecipientChangeSource(context.contentResolver) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt new file mode 100644 index 0000000000..1b2e614d0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.util + +import android.content.ContentResolver +import app.cash.copper.Query +import app.cash.copper.flow.observeQuery +import kotlinx.coroutines.flow.Flow +import org.thoughtcrime.securesms.database.DatabaseContentProviders + +/** Emits every time the Recipients table changes. */ +interface RecipientChangeSource { + fun changes(): Flow +} + +/** Real implementation used in production. */ +class ContentObserverRecipientChangeSource( + private val contentResolver: ContentResolver +) : RecipientChangeSource { + override fun changes(): Flow = + contentResolver.observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI) +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 6d0e4b02e4..2e613f33f2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -1,7 +1,11 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application +import android.content.ContentResolver +import android.content.Context +import app.cash.copper.Query import com.goterl.lazysodium.utils.KeyPair +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first @@ -29,6 +33,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.RecipientChangeSource import java.time.ZonedDateTime class ConversationViewModelTest: BaseViewModelTest() { @@ -53,6 +58,17 @@ class ConversationViewModelTest: BaseViewModelTest() { .doReturn(AvatarUIData(elements = emptyList())) } + private val testContentResolver = mock() + + private val context = mock { + on { contentResolver } doReturn testContentResolver + on { getString(any()) } doReturn "" + } + + object NoopRecipientChangeSource : RecipientChangeSource { + override fun changes(): Flow = emptyFlow() + } + private val viewModel: ConversationViewModel by lazy { ConversationViewModel( threadId = threadId, @@ -75,9 +91,10 @@ class ConversationViewModelTest: BaseViewModelTest() { }, expiredGroupManager = mock(), usernameUtils = mock(), - context = application, + context = context, avatarUtils = avatarUtils, - lokiAPIDb = mock() + lokiAPIDb = mock(), + recipientChangeSource = NoopRecipientChangeSource ) } From 4806ca0b5d34e1083c3439d98bf5d0f436fca6bc Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 13:29:11 +1000 Subject: [PATCH 204/867] Fixing up dev merge with latest dependencies --- app/build.gradle | 3 +-- .../conversation/v2/ConversationViewModelTest.kt | 4 ++-- gradle/libs.versions.toml | 11 +++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 42374e4d04..e806e3b6fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -354,9 +354,8 @@ dependencies { implementation libs.opencsv testImplementation libs.junit testImplementation libs.assertj.core - testImplementation libs.mockito.inline testImplementation libs.mockito.kotlin - androidTestImplementation libs.mockito.android + testImplementation libs.mockito.core androidTestImplementation libs.mockito.kotlin testImplementation libs.androidx.core testImplementation libs.androidx.core.testing diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index c757ac4996..b0d263d317 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -49,6 +49,8 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var recipient: Recipient private lateinit var messageRecord: MessageRecord + private val testContentResolver = mock() + private val application = mock { on { getString(any()) } doReturn "" on { contentResolver } doReturn testContentResolver @@ -60,8 +62,6 @@ class ConversationViewModelTest: BaseViewModelTest() { .doReturn(AvatarUIData(elements = emptyList())) } - private val testContentResolver = mock() - object NoopRecipientChangeSource : RecipientChangeSource { override fun changes(): Flow = emptyFlow() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec038baf7a..de8aeb7627 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ assertjCoreVersion = "3.11.1" biometricVersion = "1.1.0" cameraCamera2Version = "1.3.2" cardviewVersion = "1.0.0" -composeBomVersion = "2025.03.01" +composeBomVersion = "2025.04.01" composeVersion = "1.0.0-beta01" conscryptAndroidVersion = "2.5.2" constraintlayoutVersion = "2.2.1" @@ -38,8 +38,8 @@ legacySupportV13Version = "1.0.0" libsessionUtilAndroidVersion = "1.0.4" lifecycleExtensionsVersion = "2.2.0" media3ExoplayerVersion = "1.4.0" -mockitoInlineVersion = "4.11.0" -navVersion = "2.8.0-beta05" +mockitoCoreVersion = "5.17.0" +navVersion = "2.8.9" appcompatVersion = "1.7.0" coreVersion = "1.16.0-rc01" coroutinesVersion = "1.9.0" @@ -58,7 +58,7 @@ photoviewVersion = "2.1.3" phraseVersion = "1.2.0" lifecycleVersion = "2.7.0" materialVersion = "1.12.0" -mockitoKotlinVersion = "4.1.0" +mockitoKotlinVersion = "5.4.0" okhttpVersion = "4.12.0" pagingVersion = "3.0.0" preferenceVersion = "1.2.1" @@ -177,8 +177,7 @@ kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kovenant-android = { module = "nl.komponents.kovenant:kovenant-android", version.ref = "kovenantVersion" } kovenant = { module = "nl.komponents.kovenant:kovenant", version.ref = "kovenantVersion" } material = { module = "com.google.android.material:material", version.ref = "materialVersion" } -mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoInlineVersion" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInlineVersion" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCoreVersion" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } opencsv = { module = "com.opencsv:opencsv", version.ref = "opencsvVersion" } From 6be30d5f4e5cc00130618e4ae879ee7db8164b2d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 13:53:05 +1000 Subject: [PATCH 205/867] Base set up for clearing messages confirmation --- .../settings/ConversationSettingsViewModel.kt | 65 ++++++++++++++++++- .../v2/ConversationViewModelTest.kt | 2 +- .../src/main/res/values/strings.xml | 4 ++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index e10305d3cf..6d104fd704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -43,6 +43,7 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY @@ -688,6 +689,68 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmClearMessages(){ + //todo UCS group admin should show a multi choice dialog + val conversation = recipient ?: return + + // default to 1on1 + var message: CharSequence = Phrase.from(context, R.string.clearMessagesChatDescriptionUpdated) + .put(NAME_KEY,conversation.name) + .format() + + when{ + conversation.isGroupV2Recipient -> { + message = if(groupV2?.hasAdminKey() == true){ + Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) + .put(GROUP_NAME_KEY, getGroupName()) + .format() + } else { + Phrase.from(context, R.string.clearMessagesGroupDescriptionUpdated) + .put(GROUP_NAME_KEY, getGroupName()) + .format() + } + } + + conversation.isCommunityRecipient -> { + message = Phrase.from(context, R.string.clearMessagesCommunityUpdated) + .put(COMMUNITY_NAME_KEY, conversation.name) + .format() + } + + conversation.isLocalNumber -> { + message = context.getText(R.string.clearMessagesNoteToSelfDescriptionUpdated) + } + } + + _uiState.update { + it.copy( + showSimpleDialog = Dialog( + title = context.getString(R.string.clearMessages), + message = message, + positiveText = context.getString(R.string.clear), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_cancel), + onPositive = ::clearMessages, + onNegative = {} + ) + ) + } + } + + private fun clearMessages() { + viewModelScope.launch { + showLoading() + withContext(Dispatchers.Default) { + + } + + hideLoading() + // goBackHome() + } + } + + private fun getGroupName(): String{ val conversation = recipient ?: return "" val accountId = AccountId(conversation.address.toString()) @@ -910,7 +973,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.clearMessages), icon = R.drawable.ic_message_trash_custom, qaTag = R.string.qa_conversation_settings_clear_messages, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::confirmClearMessages ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index b0d263d317..1188ab8bb2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -50,7 +50,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var messageRecord: MessageRecord private val testContentResolver = mock() - + private val application = mock { on { getString(any()) } doReturn "" on { contentResolver } doReturn testContentResolver diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 6d4d45c632..61b32b83d3 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -134,6 +134,10 @@ delete-group-cancel-button leave-group-confirm-button leave-group-cancel-button + clear-all-messages-confirm-button + clear-all-messages-cancel-button + clear-device-radio-option + clear-everyone-radio-option Accept name change Invite button From 267de32cffee06c99ab786f2c0fe29226844c5e1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 14:42:53 +1000 Subject: [PATCH 206/867] Custom dialog for clearing messages from a group admin --- .../v2/settings/ConversationSettingsScreen.kt | 88 ++++++++++++++++++- .../settings/ConversationSettingsViewModel.kt | 24 +++-- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 2c2f6dfd44..9be343112d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -33,6 +34,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier @@ -40,15 +44,20 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel @@ -57,8 +66,10 @@ import org.thoughtcrime.securesms.ui.ExpandableText import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LargeItemButton import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.TitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape @@ -272,6 +283,14 @@ fun ConversationSettings( ) } + // Group admin clear messages + if(data.showGroupAdminClearMessagesDialog) { + GroupAdminClearMessagesDialog( + groupName = data.name, + sendCommand = sendCommand + ) + } + // Loading if (data.showLoading) { LoadingDialog() @@ -343,6 +362,73 @@ fun ConversationSettingsSubCategory( } } +@Composable +fun GroupAdminClearMessagesDialog( + modifier: Modifier = Modifier, + groupName: String, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, +){ + var deleteForEveryone by remember { mutableStateOf(false) } + + val context = LocalContext.current + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideGroupAdminClearMessagesDialog) + }, + title = annotatedStringResource(R.string.groupLeave), + text = annotatedStringResource(Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) + .put(GROUP_NAME_KEY, groupName) + .format()), + content = { + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearDeviceOnly)), + selected = !deleteForEveryone + ) + ) { + deleteForEveryone = false + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearMessagesForEveryone)), + selected = deleteForEveryone, + ) + ) { + deleteForEveryone = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.clear)), + color = LocalColors.current.danger, + onClick = { + // clear messages based on chosen option + sendCommand( + if(deleteForEveryone) ClearMessagesGroupEveryone + else ClearMessagesGroupDeviceOnly + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) +} + @OptIn(ExperimentalSharedTransitionApi::class) @SuppressLint("UnusedContentLambdaTargetStateParameter") @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 6d104fd704..91773041aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -690,7 +690,6 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmClearMessages(){ - //todo UCS group admin should show a multi choice dialog val conversation = recipient ?: return // default to 1on1 @@ -700,12 +699,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( when{ conversation.isGroupV2Recipient -> { - message = if(groupV2?.hasAdminKey() == true){ - Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) - .put(GROUP_NAME_KEY, getGroupName()) - .format() + if(groupV2?.hasAdminKey() == true){ + // group admin clearing messages have a dedicated custom dialog + _uiState.update { it.copy(showGroupAdminClearMessagesDialog = true) } + return + } else { - Phrase.from(context, R.string.clearMessagesGroupDescriptionUpdated) + message = Phrase.from(context, R.string.clearMessagesGroupDescriptionUpdated) .put(GROUP_NAME_KEY, getGroupName()) .format() } @@ -849,6 +849,14 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.HideSimpleDialog -> _uiState.update { it.copy(showSimpleDialog = null) } + + is Commands.HideGroupAdminClearMessagesDialog -> _uiState.update { + it.copy(showGroupAdminClearMessagesDialog = false) + } + + //todo UCS properly handle both cases + is Commands.ClearMessagesGroupDeviceOnly -> clearMessages() + is Commands.ClearMessagesGroupEveryone -> clearMessages() } } @@ -873,6 +881,9 @@ class ConversationSettingsViewModel @AssistedInject constructor( sealed interface Commands { data object CopyAccountId : Commands data object HideSimpleDialog : Commands + data object HideGroupAdminClearMessagesDialog : Commands + data object ClearMessagesGroupDeviceOnly : Commands + data object ClearMessagesGroupEveryone : Commands } @AssistedFactory @@ -1095,6 +1106,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val descriptionQaTag: String? = null, val accountId: String? = null, val showSimpleDialog: Dialog? = null, + val showGroupAdminClearMessagesDialog: Boolean = false, val showLoading: Boolean = false, val categories: List = emptyList() ) From d25e0ca92edaa1461f78cc66d2aeed18f337f828 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 15:43:35 +1000 Subject: [PATCH 207/867] Clearing message logic (not complete) --- .../securesms/configs/ConfigToDatabaseSync.kt | 2 ++ .../settings/ConversationSettingsViewModel.kt | 13 ++++--- .../securesms/database/LokiMessageDatabase.kt | 4 +-- .../securesms/database/MmsSmsColumns.java | 2 ++ .../securesms/database/MmsSmsDatabase.java | 35 ++++++++++++++++--- .../securesms/database/Storage.kt | 13 +++++++ .../securesms/groups/GroupManagerV2Impl.kt | 6 ++++ .../repository/ConversationRepository.kt | 13 +++++++ .../libsession/database/StorageProtocol.kt | 1 + .../messaging/groups/GroupManagerV2.kt | 6 ++++ 10 files changed, 81 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index dda4662d20..eb0a4518f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -212,6 +212,8 @@ class ConfigToDatabaseSync @Inject constructor( // Mark visible messages as deleted, and control messages actually deleted. conversationRepository.markAsDeletedLocally(visibleMessages.toSet(), context.getString(R.string.deleteMessageDeletedGlobally)) conversationRepository.deleteMessages(controlMessages.toSet(), threadId) + + //todo UCS if the current user is an admin of this group they should also remove the message from the swarm } groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> val messagesWithAttachment = mmsSmsDatabase.getAllMessageRecordsBefore(threadId, TimeUnit.SECONDS.toMillis(removeAttachmentsBefore)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 91773041aa..1be776da00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -75,6 +75,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val repository: ConversationRepository, private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, + private val conversationRepository: ConversationRepository, private val textSecurePreferences: TextSecurePreferences, private val navigator: ConversationSettingsNavigator, private val threadDb: ThreadDatabase, @@ -731,22 +732,21 @@ class ConversationSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.cancel), positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_confirm), negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_cancel), - onPositive = ::clearMessages, + onPositive = { clearMessages(false) }, onNegative = {} ) ) } } - private fun clearMessages() { + private fun clearMessages(clearForEveryoneGroupsV2: Boolean) { viewModelScope.launch { showLoading() withContext(Dispatchers.Default) { - + conversationRepository.clearAllMessages(threadId, clearForEveryoneGroupsV2) } hideLoading() - // goBackHome() } } @@ -854,9 +854,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( it.copy(showGroupAdminClearMessagesDialog = false) } - //todo UCS properly handle both cases - is Commands.ClearMessagesGroupDeviceOnly -> clearMessages() - is Commands.ClearMessagesGroupEveryone -> clearMessages() + is Commands.ClearMessagesGroupDeviceOnly -> clearMessages(false) + is Commands.ClearMessagesGroupEveryone -> clearMessages(true) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 57fb743252..c70454d1c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -19,8 +19,8 @@ class LokiMessageDatabase(context: Context, helper: Provider databaseHelper) { @@ -349,6 +350,22 @@ public Set getAllMessageRecordsBefore(long threadId, long timesta return identifiedMessages; } + public Set getAllMessageForThread(long threadId) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + Set identifiedMessages = new HashSet<>(); + + // Try everything with resources so that they auto-close on end of scope + try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + identifiedMessages.add(messageRecord); + } + } + } + return identifiedMessages; + } + public long getLastOutgoingTimestamp(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; @@ -539,7 +556,8 @@ private Cursor queryTables(String[] projection, String selection, String order, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION + MmsSmsColumns.HAS_MENTION, + "mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, }; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, @@ -567,7 +585,8 @@ private Cursor queryTables(String[] projection, String selection, String order, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION + MmsSmsColumns.HAS_MENTION, + "sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, }; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); @@ -578,12 +597,16 @@ private Cursor queryTables(String[] projection, String selection, String order, smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0"); + " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0" + + " LEFT OUTER JOIN " + LokiMessageDatabase.smsHashTable + " AS sms_hash" + + " ON sms_hash.message_id = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID); mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1"); + " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1" + + " LEFT OUTER JOIN " + LokiMessageDatabase.mmsHashTable + " AS mms_hash" + + " ON mms_hash.message_id = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); Set mmsColumnsPresent = new HashSet<>(); @@ -612,6 +635,7 @@ private Cursor queryTables(String[] projection, String selection, String order, mmsColumnsPresent.add(MmsDatabase.STATUS); mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); + mmsColumnsPresent.add(MmsSmsColumns.SERVER_HASH); mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); @@ -685,6 +709,7 @@ private Cursor queryTables(String[] projection, String selection, String order, smsColumnsPresent.add(ReactionDatabase.DATE_SENT); smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); + smsColumnsPresent.add(MmsSmsColumns.SERVER_HASH); @SuppressWarnings("deprecation") String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 4293c6ce44..e675e84188 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -299,6 +299,19 @@ open class Storage @Inject constructor( } } + override fun clearAllMessages(threadId: Long) { + val messages = mmsSmsDatabase.getAllMessageForThread(threadId) + val (mmsMessages, smsMessages) = messages.partition { it.isMms } + if (mmsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(mmsMessages.map(MessageRecord::id), threadId, isSms = false) + } + if (smsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(smsMessages.map(MessageRecord::id), threadId, isSms = true) + } + + return messages.map { it.hash } //todo UCS I need to join tables in order to get this hash returned with the rest + } + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { val threadDb = threadDatabase getRecipientForThread(threadId)?.let { recipient -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 297a082ba4..e8db64455f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -426,6 +426,12 @@ class GroupManagerV2Impl @Inject constructor( SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) } + override suspend fun clearAllMessagesForEveryone(threadId: Long) { + //todo UCS change the delete_before + + //todo UCS remove messages from swarm SnodeAPI.deleteMessage + } + override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { val closedGroup = configFactory.getGroup(group) ?: return@launchAndWait val groupAdminKey = closedGroup.adminKey?.data diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index c428e32511..aa94ab452d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -93,6 +93,7 @@ interface ConversationRepository { suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result fun hasReceived(threadId: Long): Boolean fun getInvitingAdmin(threadId: Long): Recipient? + suspend fun clearAllMessages(threadId: Long, syncGroupV2: Boolean) } class DefaultConversationRepository @Inject constructor( @@ -421,6 +422,18 @@ class DefaultConversationRepository @Inject constructor( } } + override suspend fun clearAllMessages(threadId: Long, syncGroupV2: Boolean) { + withContext(Dispatchers.Default) { + // delete data locally + storage.clearAllMessages(threadId) + + // if required, also sync groupV2 data + if (syncGroupV2) { + groupManager.clearAllMessagesForEveryone(threadId) + } + } + } + override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient) = runCatching { withContext(Dispatchers.Default) { storage.setRecipientApproved(recipient, true) diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 979e8b81a8..5be4210ec9 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -240,6 +240,7 @@ interface StorageProtocol { fun conversationHasOutgoing(userPublicKey: String): Boolean fun deleteMessagesByHash(threadId: Long, hashes: List) fun deleteMessagesByUser(threadId: Long, userSessionId: String) + fun clearAllMessages(threadId: Long) // Last Inbox Message Id fun getLastInboxMessageId(server: String): Long? diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 02bfd32f2c..9bc9863684 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -30,6 +30,12 @@ interface GroupManagerV2 { removeMessages: Boolean ) + /** + * Clears all messages from the group for everyone on the config side + * This does not delete the messages from the local db (this is handled by the storage class. + */ + suspend fun clearAllMessagesForEveryone(threadId: Long) + /** * Remove all messages from the group for the given members. * From dc15d230d68267c06ffcd5c59c97eb947fee4ff9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 5 May 2025 17:13:08 +1000 Subject: [PATCH 208/867] Reminder of logic for clearing messages --- .../settings/ConversationSettingsViewModel.kt | 2 +- .../securesms/database/MmsSmsDatabase.java | 28 ++++++++++--------- .../securesms/database/Storage.kt | 12 ++++---- .../securesms/groups/GroupManagerV2Impl.kt | 15 ++++++++-- .../repository/ConversationRepository.kt | 12 ++++---- .../libsession/database/StorageProtocol.kt | 2 +- .../messaging/groups/GroupManagerV2.kt | 2 +- .../session/libsession/snode/SnodeClock.kt | 4 +++ 8 files changed, 46 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 1be776da00..7c9870aa4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -743,7 +743,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( viewModelScope.launch { showLoading() withContext(Dispatchers.Default) { - conversationRepository.clearAllMessages(threadId, clearForEveryoneGroupsV2) + conversationRepository.clearAllMessages(threadId, if(clearForEveryoneGroupsV2 && groupV2 != null) AccountId(groupV2!!.groupAccountId) else null) } hideLoading() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index e6a673d7c4..752773ae86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -350,22 +350,24 @@ public Set getAllMessageRecordsBefore(long threadId, long timesta return identifiedMessages; } - public Set getAllMessageForThread(long threadId) { + public List> getAllMessagesWithHash(long threadId) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - Set identifiedMessages = new HashSet<>(); + List> out = new ArrayList<>(); - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - identifiedMessages.add(messageRecord); - } + try (Cursor cursor = queryTables(PROJECTION, selection, null, null); + MmsSmsDatabase.Reader reader = readerFor(cursor)) { + + MessageRecord record; + while ((record = reader.getNext()) != null) { + @Nullable String hash = + cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); + + out.add(new Pair<>(record, hash)); // android.util.Pair } } - return identifiedMessages; + return out; } - public long getLastOutgoingTimestamp(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; @@ -635,7 +637,7 @@ private Cursor queryTables(String[] projection, String selection, String order, mmsColumnsPresent.add(MmsDatabase.STATUS); mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); - mmsColumnsPresent.add(MmsSmsColumns.SERVER_HASH); + mmsColumnsPresent.add("mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); @@ -709,7 +711,7 @@ private Cursor queryTables(String[] projection, String selection, String order, smsColumnsPresent.add(ReactionDatabase.DATE_SENT); smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); - smsColumnsPresent.add(MmsSmsColumns.SERVER_HASH); + smsColumnsPresent.add("sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); @SuppressWarnings("deprecation") String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index e675e84188..860ffeabda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -299,17 +299,17 @@ open class Storage @Inject constructor( } } - override fun clearAllMessages(threadId: Long) { - val messages = mmsSmsDatabase.getAllMessageForThread(threadId) - val (mmsMessages, smsMessages) = messages.partition { it.isMms } + override fun clearAllMessages(threadId: Long): List { + val messages = mmsSmsDatabase.getAllMessagesWithHash(threadId) + val (mmsMessages, smsMessages) = messages.partition { it.first.isMms } if (mmsMessages.isNotEmpty()) { - messageDataProvider.deleteMessages(mmsMessages.map(MessageRecord::id), threadId, isSms = false) + messageDataProvider.deleteMessages(mmsMessages.map{ it.first.id }, threadId, isSms = false) } if (smsMessages.isNotEmpty()) { - messageDataProvider.deleteMessages(smsMessages.map(MessageRecord::id), threadId, isSms = true) + messageDataProvider.deleteMessages(smsMessages.map{ it.first.id }, threadId, isSms = true) } - return messages.map { it.hash } //todo UCS I need to join tables in order to get this hash returned with the rest + return messages.map { it.second } // return the message hashes } override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index e8db64455f..2b619ed1da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -426,10 +426,19 @@ class GroupManagerV2Impl @Inject constructor( SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) } - override suspend fun clearAllMessagesForEveryone(threadId: Long) { - //todo UCS change the delete_before + override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { + // remove messages from swarm SnodeAPI.deleteMessage + val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.data?.let { + OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) + } ?: return + + // change the delete_before + configFactory.withMutableGroupConfigs(groupAccountId) { configs -> + configs.groupInfo.setDeleteBefore(clock.currentTimeSeconds()) + } - //todo UCS remove messages from swarm SnodeAPI.deleteMessage + val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() + if(cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) } override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index aa94ab452d..0ad9829ea2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -14,7 +14,6 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.userAuth import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest @@ -93,7 +92,7 @@ interface ConversationRepository { suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result fun hasReceived(threadId: Long): Boolean fun getInvitingAdmin(threadId: Long): Recipient? - suspend fun clearAllMessages(threadId: Long, syncGroupV2: Boolean) + suspend fun clearAllMessages(threadId: Long, groupId: AccountId?) } class DefaultConversationRepository @Inject constructor( @@ -422,14 +421,15 @@ class DefaultConversationRepository @Inject constructor( } } - override suspend fun clearAllMessages(threadId: Long, syncGroupV2: Boolean) { + override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?) { withContext(Dispatchers.Default) { // delete data locally - storage.clearAllMessages(threadId) + val deletedHashes = storage.clearAllMessages(threadId) + Log.i("", "Cleared messages with hashes: $deletedHashes") // if required, also sync groupV2 data - if (syncGroupV2) { - groupManager.clearAllMessagesForEveryone(threadId) + if (groupId != null) { + groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) } } } diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 5be4210ec9..16767de7c0 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -240,7 +240,7 @@ interface StorageProtocol { fun conversationHasOutgoing(userPublicKey: String): Boolean fun deleteMessagesByHash(threadId: Long, hashes: List) fun deleteMessagesByUser(threadId: Long, userSessionId: String) - fun clearAllMessages(threadId: Long) + fun clearAllMessages(threadId: Long): List // Last Inbox Message Id fun getLastInboxMessageId(server: String): Long? diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 9bc9863684..38ce4501a9 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -34,7 +34,7 @@ interface GroupManagerV2 { * Clears all messages from the group for everyone on the config side * This does not delete the messages from the local db (this is handled by the storage class. */ - suspend fun clearAllMessagesForEveryone(threadId: Long) + suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) /** * Remove all messages from the group for the given members. diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt index be6f2fd8d6..8b9667e2bf 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt @@ -76,6 +76,10 @@ class SnodeClock() { return instantState.value?.now() ?: System.currentTimeMillis() } + fun currentTimeSeconds(): Long { + return currentTimeMills() / 1000 + } + private class Instant( val systemUptime: Long, val networkTime: Long, From 493a3cf947a9e5b7faecc920d4988857f7adb54a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 09:50:14 +1000 Subject: [PATCH 209/867] Making sure group admins remove messages from swarm when getting a delete_before change --- .../securesms/configs/ConfigToDatabaseSync.kt | 24 ++++++++++++++++--- .../securesms/database/MmsSmsDatabase.java | 15 +++++++----- .../securesms/groups/GroupManagerV2Impl.kt | 3 ++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index eb0a4518f3..f62fbd8c9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -25,6 +25,8 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ConfigFactoryProtocol @@ -32,6 +34,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey @@ -207,17 +210,32 @@ class ConfigToDatabaseSync @Inject constructor( } else { groupInfoConfig.deleteBefore?.let { removeBefore -> val messages = mmsSmsDatabase.getAllMessageRecordsBefore(threadId, TimeUnit.SECONDS.toMillis(removeBefore)) - val (controlMessages, visibleMessages) = messages.partition { it.isControlMessage } + val (controlMessages, visibleMessages) = messages.map { it.first }.partition { it.isControlMessage } // Mark visible messages as deleted, and control messages actually deleted. conversationRepository.markAsDeletedLocally(visibleMessages.toSet(), context.getString(R.string.deleteMessageDeletedGlobally)) conversationRepository.deleteMessages(controlMessages.toSet(), threadId) - //todo UCS if the current user is an admin of this group they should also remove the message from the swarm + // if the current user is an admin of this group they should also remove the message from the swarm + // as a safety measure + val groupAdminAuth = configFactory.getGroup(groupInfoConfig.id)?.adminKey?.data?.let { + OwnedSwarmAuth.ofClosedGroup(groupInfoConfig.id, it) + } ?: return + + // remove messages from swarm SnodeAPI.deleteMessage + GlobalScope.launch(Dispatchers.Default) { + val cleanedHashes: List = + messages.map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull() + if (cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage( + groupInfoConfig.id.hexString, + groupAdminAuth, + cleanedHashes + ) + } } groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> val messagesWithAttachment = mmsSmsDatabase.getAllMessageRecordsBefore(threadId, TimeUnit.SECONDS.toMillis(removeAttachmentsBefore)) - .filterTo(mutableSetOf()) { it is MmsMessageRecord && it.containsAttachment } + .map{ it.first}.filterTo(mutableSetOf()) { it is MmsMessageRecord && it.containsAttachment } conversationRepository.markAsDeletedLocally(messagesWithAttachment, context.getString(R.string.deleteMessageDeletedGlobally)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 752773ae86..d26700951b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -334,16 +334,19 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, return identifiedMessages; } - public Set getAllMessageRecordsBefore(long threadId, long timestampMills) { + public List> getAllMessageRecordsBefore(long threadId, long timestampMills) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.NORMALIZED_DATE_SENT + " < " + timestampMills; - Set identifiedMessages = new HashSet<>(); + List> identifiedMessages = new ArrayList<>(); // Try everything with resources so that they auto-close on end of scope try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { - identifiedMessages.add(messageRecord); + @Nullable String hash = + cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); + + identifiedMessages.add(new Pair<>(messageRecord, hash)); } } } @@ -353,7 +356,7 @@ public Set getAllMessageRecordsBefore(long threadId, long timesta public List> getAllMessagesWithHash(long threadId) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - List> out = new ArrayList<>(); + List> identifiedMessages = new ArrayList<>(); try (Cursor cursor = queryTables(PROJECTION, selection, null, null); MmsSmsDatabase.Reader reader = readerFor(cursor)) { @@ -363,10 +366,10 @@ public List> getAllMessagesWithHash(long threadId) { @Nullable String hash = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); - out.add(new Pair<>(record, hash)); // android.util.Pair + identifiedMessages.add(new Pair<>(record, hash)); } } - return out; + return identifiedMessages; } public long getLastOutgoingTimestamp(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 2b619ed1da..a139a220e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -427,7 +427,7 @@ class GroupManagerV2Impl @Inject constructor( } override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { - // remove messages from swarm SnodeAPI.deleteMessage + // only admins can perform these tasks val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.data?.let { OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) } ?: return @@ -437,6 +437,7 @@ class GroupManagerV2Impl @Inject constructor( configs.groupInfo.setDeleteBefore(clock.currentTimeSeconds()) } + // remove messages from swarm SnodeAPI.deleteMessage val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() if(cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) } From ddc6ed09efa6421d6c9032528042230c37ad155d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 02:21:51 +0200 Subject: [PATCH 210/867] Update app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt Co-authored-by: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> --- .../org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index f62fbd8c9c..870649a5db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -225,7 +225,7 @@ class ConfigToDatabaseSync @Inject constructor( // remove messages from swarm SnodeAPI.deleteMessage GlobalScope.launch(Dispatchers.Default) { val cleanedHashes: List = - messages.map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull() + messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() if (cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage( groupInfoConfig.id.hexString, groupAdminAuth, From 340b9bcd2832225aa44d8d6b21b85e9d72502db2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 10:25:11 +1000 Subject: [PATCH 211/867] Added comment --- .../securesms/repository/ConversationRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 0ad9829ea2..124a63ff4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -92,6 +92,13 @@ interface ConversationRepository { suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result fun hasReceived(threadId: Long): Boolean fun getInvitingAdmin(threadId: Long): Recipient? + + /** + * This will delete all messages from the database. + * If a groupId is passed along, and if the user is an admin of that group, + * this will also remove the messages from the swarm and update + * the delete_before flag for that group to now + */ suspend fun clearAllMessages(threadId: Long, groupId: AccountId?) } From 9082850d418b640fb466a79a05f16cd8f087ae9e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 11:05:22 +1000 Subject: [PATCH 212/867] Added try catch and toast for "Clear all messages" --- .../settings/ConversationSettingsViewModel.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 7c9870aa4a..1ab62b2045 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -742,8 +742,25 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun clearMessages(clearForEveryoneGroupsV2: Boolean) { viewModelScope.launch { showLoading() - withContext(Dispatchers.Default) { - conversationRepository.clearAllMessages(threadId, if(clearForEveryoneGroupsV2 && groupV2 != null) AccountId(groupV2!!.groupAccountId) else null) + try { + withContext(Dispatchers.Default) { + conversationRepository.clearAllMessages( + threadId, + if (clearForEveryoneGroupsV2 && groupV2 != null) AccountId(groupV2!!.groupAccountId) else null + ) + } + + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + 2, // we don't care about the number, just that it is multiple messages since we are doing "Clear All" + 2 + ), Toast.LENGTH_LONG).show() + } catch (e: Exception){ + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.deleteMessageFailed, + 2, // we don't care about the number, just that it is multiple messages since we are doing "Clear All" + 2 + ), Toast.LENGTH_LONG).show() } hideLoading() From 0c0b635b710fcc2c9d49744d5f6d8c3a27912d31 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 11:16:27 +1000 Subject: [PATCH 213/867] No status indicator when thread is empty --- .../java/org/thoughtcrime/securesms/home/ConversationView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 0fdf6cdbe1..ab05ab5a49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -115,7 +115,7 @@ class ConversationView : LinearLayout { binding.statusIndicatorImageView.visibility = View.VISIBLE binding.statusIndicatorImageView.imageTintList = ColorStateList.valueOf(ThemeUtil.getThemedColor(context, android.R.attr.textColorTertiary)) // tertiary in the current xml styling is actually what figma uses as secondary text color... when { - !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE + !thread.isOutgoing || thread.lastMessage == null -> binding.statusIndicatorImageView.visibility = View.GONE thread.isFailed -> { val drawable = ContextCompat.getDrawable(context, R.drawable.ic_triangle_alert)?.mutate() binding.statusIndicatorImageView.setImageDrawable(drawable) From 218a2e1ea85efac3bbba6e6d62176900b58aff81 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 May 2025 14:59:35 +1000 Subject: [PATCH 214/867] Clean up --- app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index 2365891aa8..0f942e5cd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -94,14 +94,12 @@ inline fun T.afterMeasured(crossinline block: T.() -> Unit) { * As such we need to repeat it for every component that wants to use testTag, until such * a time as we have one root composable */ -@OptIn(ExperimentalComposeUiApi::class) @Composable fun Modifier.qaTag(tag: String?): Modifier { if (tag == null) return this return this.semantics { testTagsAsResourceId = true }.testTag(tag) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun Modifier.qaTag(@StringRes tagResId: Int) = semantics { testTagsAsResourceId = true }.testTag(stringResource(tagResId)) From 9c3a89494a09fa1066304408bea64bc7af036379 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 6 May 2025 15:19:38 +1000 Subject: [PATCH 215/867] [SES-3601] - App disguise (#1132) --- app/src/main/AndroidManifest.xml | 85 ++++++ .../securesms/ApplicationContext.kt | 2 + .../securesms/debugmenu/DebugMenu.kt | 22 +- .../securesms/disguise/AppDisguiseManager.kt | 164 ++++++++++ .../appearance/AppDisguiseSettings.kt | 288 ++++++++++++++++++ .../appearance/AppDisguiseSettingsActivity.kt | 18 ++ .../AppDisguiseSettingsViewModel.kt | 58 ++++ .../appearance/AppearanceSettingsActivity.kt | 5 + .../thoughtcrime/securesms/ui/Components.kt | 4 +- .../securesms/ui/components/AppBar.kt | 2 +- .../securesms/ui/components/SessionSwitch.kt | 27 ++ .../ic_launcher_calculator_background.xml | 21 ++ .../ic_launcher_meetings_background.xml | 21 ++ .../ic_launcher_meetings_foreground.xml | 19 ++ .../drawable/ic_launcher_news_background.xml | 21 ++ .../drawable/ic_launcher_news_foreground.xml | 63 ++++ .../drawable/ic_launcher_notes_background.xml | 21 ++ .../drawable/ic_launcher_notes_foreground.xml | 46 +++ .../ic_launcher_stocks_background.xml | 9 + .../ic_launcher_stocks_foreground.xml | 27 ++ .../ic_launcher_weather_background.xml | 23 ++ .../ic_launcher_weather_foreground.xml | 51 ++++ .../layout/activity_appearance_settings.xml | 35 +++ .../ic_launcher_calculator.xml | 5 + .../ic_launcher_meetings.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_news.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_notes.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_stocks.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_weather.xml | 5 + .../ic_launcher_calculator_foreground.webp | Bin 0 -> 1848 bytes .../ic_launcher_calculator_foreground.webp | Bin 0 -> 1224 bytes .../ic_launcher_calculator_foreground.webp | Bin 0 -> 2770 bytes .../ic_launcher_calculator_foreground.webp | Bin 0 -> 4934 bytes .../ic_launcher_calculator_foreground.webp | Bin 0 -> 7208 bytes .../utilities/TextSecurePreferences.kt | 13 + 35 files changed, 1051 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt create mode 100644 app/src/main/res/drawable/ic_launcher_calculator_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_meetings_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_meetings_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_news_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_news_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_notes_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_notes_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_stocks_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_stocks_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_weather_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_weather_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_calculator.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_meetings.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_stocks.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_calculator_foreground.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_calculator_foreground.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_calculator_foreground.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_calculator_foreground.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_calculator_foreground.webp diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf48374e35..f7f852d8eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -315,6 +315,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 386dac37f7..9d5e938da3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -79,6 +79,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseModule.init +import org.thoughtcrime.securesms.disguise.AppDisguiseManager import org.thoughtcrime.securesms.emoji.EmojiSource.Companion.refresh import org.thoughtcrime.securesms.groups.ExpiredGroupManager import org.thoughtcrime.securesms.groups.GroupPollerManager @@ -164,6 +165,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, @Inject lateinit var removeGroupMemberHandler: RemoveGroupMemberHandler // Exists here only to start upon app starts @Inject lateinit var snodeClock: SnodeClock @Inject lateinit var migrationManager: DatabaseMigrationManager + @Inject lateinit var appDisguiseManager: AppDisguiseManager @get:Deprecated(message = "Use proper DI to inject this component") @Inject diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 63518284ad..315043ab57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -23,8 +23,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -62,6 +60,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.components.SessionSwitch import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -453,25 +452,6 @@ fun DebugSwitchRow( } -// todo Get proper styling that works well with ax on all themes and then move this composable in the components file -@Composable -fun SessionSwitch( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = LocalColors.current.primary, - checkedTrackColor = LocalColors.current.background, - uncheckedThumbColor = LocalColors.current.text, - uncheckedTrackColor = LocalColors.current.background, - ) - ) -} - @Composable fun ColumnScope.DebugCell( title: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt new file mode 100644 index 0000000000..c83cf0d168 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.disguise + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage the app disguise feature, where you can observe the list of app aliases and selected alias. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class AppDisguiseManager @Inject constructor( + application: Application, + private val prefs: TextSecurePreferences, +) { + private val scope: CoroutineScope = GlobalScope + + val allAppAliases: Flow> = flow { + emit( + application.packageManager + .queryIntentActivities( + Intent(Intent.ACTION_MAIN) + .setPackage(application.packageName) + .addCategory(Intent.CATEGORY_LAUNCHER), + PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS + ) + .asSequence() + .filter { + it.activityInfo.targetActivity != null + } + .map { info -> + AppAlias( + activityAliasName = info.activityInfo.name, + defaultEnabled = info.activityInfo.enabled, + appName = info.activityInfo.labelRes.takeIf { it != 0 }, + appIcon = info.activityInfo.icon.takeIf { it != 0 }, + ) + } + .toList() + ) + }.flowOn(Dispatchers.Default) + .shareIn(scope, started = SharingStarted.Lazily, replay = 1) + + private val prefChangeNotification = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** + * The currently selected app alias name. This doesn't equate to if the app disguise is on or off. + */ + val selectedAppAliasName: StateFlow = prefChangeNotification + .mapLatest { prefs.selectedActivityAliasName } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = prefs.selectedActivityAliasName + ) + + /** + * Whether the app disguise is on or off. + */ + val isOn: StateFlow = prefChangeNotification + .mapLatest { prefs.isAppDiguiseOn } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = prefs.isAppDiguiseOn + ) + + init { + scope.launch { + combine( + selectedAppAliasName, + allAppAliases, + isOn, + ) { selected, all, on -> + val enabledAlias = when { + on -> all.firstOrNull { it.activityAliasName == selected } ?: all.first { it.defaultEnabled } + else -> all.first { it.defaultEnabled } + } + + all.map { alias -> + // Set the state to enabled or disabled based on the selected alias, + // and also taking the default state into account. This is trying to + // not change the state if the default is sufficient. + val state = when { + alias === enabledAlias && alias.defaultEnabled -> { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } + + alias === enabledAlias -> { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + + alias.defaultEnabled -> { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + else -> { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } + } + + ComponentName(application, alias.activityAliasName) to state + } + }.collectLatest { all -> + all.forEach { (name, state) -> + Log.d(TAG, "Set state $name: $state") + + application.packageManager.setComponentEnabledSetting( + name, + state, + PackageManager.DONT_KILL_APP + ) + } + } + } + } + + fun setSelectedAliasName(name: String?) { + Log.d(TAG, "setSelectedAliasName: $name") + prefs.selectedActivityAliasName = name + prefChangeNotification.tryEmit(Unit) + } + + fun setOn(on: Boolean) { + prefs.isAppDiguiseOn = on + prefChangeNotification.tryEmit(Unit) + } + + data class AppAlias( + val activityAliasName: String, + val defaultEnabled: Boolean, + @StringRes val appName: Int?, + @DrawableRes val appIcon: Int?, + ) +} + +private const val TAG = "AppDisguiseManager" \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt new file mode 100644 index 0000000000..8a90727136 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import android.graphics.drawable.AdaptiveIconDrawable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.math.ceil +import kotlin.math.min + +@Composable +fun AppDisguiseSettingsScreen( + viewModel: AppDisguiseSettingsViewModel, + onBack: () -> Unit +) { + AppDisguiseSettings( + onBack = onBack, + setOn = viewModel::setOn, + isOn = viewModel.isOn.collectAsState().value, + showList = viewModel.showAlternativeIconList.collectAsState().value, + items = viewModel.alternativeIcons.collectAsState().value, + onItemSelected = viewModel::onIconSelected + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppDisguiseSettings( + items: List, + isOn: Boolean, + showList: Boolean, + setOn: (Boolean) -> Unit, + onItemSelected: (String) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BackAppBar(title = stringResource(R.string.sessionAppearance), onBack = onBack) + } + ) { paddings -> + Column( + modifier = Modifier + .padding(paddings) + .padding(LocalDimensions.current.smallSpacing) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + ) { + Text( + stringResource(R.string.appIcon), + style = LocalType.current.large, + color = LocalColors.current.textSecondary + ) + + Cell { + Row( + modifier = Modifier + .toggleable(value = isOn, onValueChange = setOn) + .padding(LocalDimensions.current.xsSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.appIconEnableIconAndName), + modifier = Modifier.weight(1f), + style = LocalType.current.large, + color = LocalColors.current.text + ) + + SessionSwitch(checked = isOn, onCheckedChange = null) + } + } + + Crossfade(showList) { show -> + if (show) { + BoxWithConstraints { + // Calculate the number of columns based on the min width we want each column + // to be. + val minColumnWidth = LocalDimensions.current.xxsSpacing + ICON_ITEM_SIZE_DP.dp + val numColumn = + (constraints.maxWidth / LocalDensity.current.run { minColumnWidth.toPx() }).toInt() + val numRows = ceil(items.size.toFloat() / numColumn).toInt() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + ) { + Text( + stringResource(R.string.appIconAndNameSelectionTitle), + style = LocalType.current.large, + color = LocalColors.current.textSecondary + ) + + Cell { + Column( + modifier = Modifier.padding(LocalDimensions.current.xsSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + repeat(numRows) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + for (index in row * numColumn.. Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + val theme = LocalContext.current.theme + + val bitmap = remember(icon, resources, theme) { + (ResourcesCompat.getDrawable(resources, icon, theme) as AdaptiveIconDrawable).toBitmap() + .asImageBitmap() + } + + val textColor = LocalColors.current.text + val borderColor = LocalColors.current.textSecondary + val density = LocalDensity.current + val borderStroke = Stroke(density.run { 2.dp.toPx() }) + val cornerRadius = CornerRadius(density.run { 4.dp.toPx() }) + + Column( + modifier = modifier + .padding(LocalDimensions.current.xxxsSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + BitmapPainter(bitmap), + modifier = Modifier + .size(ICON_ITEM_SIZE_DP.dp) + .drawWithContent { + drawContent() + if (selected) { + drawRoundRect(borderColor, style = borderStroke, cornerRadius = cornerRadius) + } + } + .selectable(selected, onClick = onSelected) + .padding(4.dp), + contentDescription = null + ) + + Text( + stringResource(name), + textAlign = TextAlign.Center, + style = LocalType.current.large, + color = textColor, + ) + } +} + +@Preview +@Preview(device = Devices.TABLET) +@Composable +private fun AppDisguiseSettingsPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + AppDisguiseSettings( + items = listOf( + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_weather, + name = R.string.appNameWeather, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "2", + icon = R.mipmap.ic_launcher_stocks, + name = R.string.appNameStocks, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "3", + icon = R.mipmap.ic_launcher_news, + name = R.string.appNameNews, + selected = true + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_notes, + name = R.string.appNameNotes, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_meetings, + name = R.string.appNameMeetingSE, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_calculator, + name = R.string.appNameCalculator, + selected = false + ), + ), + isOn = true, + showList = true, + setOn = { }, + onItemSelected = { }, + onBack = { }, + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt new file mode 100644 index 0000000000..fb1a9847d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeScreenLockActivity + +@AndroidEntryPoint +class AppDisguiseSettingsActivity : FullComposeScreenLockActivity() { + + @Composable + override fun ComposeContent() { + AppDisguiseSettingsScreen( + viewModel = hiltViewModel(), + onBack = this::finish + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt new file mode 100644 index 0000000000..1b1b94d093 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.thoughtcrime.securesms.disguise.AppDisguiseManager +import javax.inject.Inject + +@HiltViewModel +class AppDisguiseSettingsViewModel @Inject constructor( + private val manager: AppDisguiseManager +) : ViewModel() { + // Whether the app disguise is enabled + val isOn: StateFlow get() = manager.isOn + + // Whether to show the selection items + val showAlternativeIconList: StateFlow get() = isOn + + // The contents of the selection items + val alternativeIcons: StateFlow> = combine( + manager.allAppAliases, + manager.selectedAppAliasName + ) { aliases, selected -> + aliases.mapNotNull { alias -> + IconAndName( + id = alias.activityAliasName, + icon = alias.appIcon ?: return@mapNotNull null, + name = alias.appName ?: return@mapNotNull null, + selected = alias.activityAliasName == selected + ) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + + fun onIconSelected(id: String) { + manager.setSelectedAliasName(id) + } + + fun setOn(on: Boolean) { + manager.setOn(on) + } + + data class IconAndName( + val id: String, + @DrawableRes val icon: Int, + @StringRes val name: Int, + val selected: Boolean, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index c70113fe96..bf6642e68e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.preferences.appearance +import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.util.SparseArray @@ -129,6 +130,10 @@ class AppearanceSettingsActivity: ScreenLockActionBarActivity(), View.OnClickLis // system settings toggle systemSettingsSwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setNewFollowSystemSettings(isChecked) } systemSettingsSwitchHolder.setOnClickListener { systemSettingsSwitch.toggle() } + + systemSettingsAppIcon.setOnClickListener { + startActivity(Intent(this@AppearanceSettingsActivity, AppDisguiseSettingsActivity::class.java)) + } } lifecycleScope.launchWhenResumed { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index a9521c7492..7432e92317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -347,12 +347,12 @@ fun Cell( ) { Box( modifier = modifier + .clip(MaterialTheme.shapes.small) .background( color = LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small ) .wrapContentHeight() - .fillMaxWidth(), + .fillMaxWidth() ) { content() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index e7c859058b..33ef9611b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -121,7 +121,7 @@ fun BackAppBar( ) } -@ExperimentalMaterial3Api +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionAppBar( title: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt new file mode 100644 index 0000000000..5a8f768d3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.thoughtcrime.securesms.ui.theme.LocalColors + +// todo Get proper styling that works well with ax on all themes and then move this composable in the components file +@Composable +fun SessionSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier +) { + Switch( + checked = checked, + modifier = modifier, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = LocalColors.current.primary, + checkedTrackColor = LocalColors.current.background, + uncheckedThumbColor = LocalColors.current.text, + uncheckedTrackColor = LocalColors.current.background, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_calculator_background.xml b/app/src/main/res/drawable/ic_launcher_calculator_background.xml new file mode 100644 index 0000000000..1e9175c0cf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_calculator_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_meetings_background.xml b/app/src/main/res/drawable/ic_launcher_meetings_background.xml new file mode 100644 index 0000000000..d5505ca09d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_meetings_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml b/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml new file mode 100644 index 0000000000..8541179349 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_news_background.xml b/app/src/main/res/drawable/ic_launcher_news_background.xml new file mode 100644 index 0000000000..0f8152c178 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_news_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_news_foreground.xml b/app/src/main/res/drawable/ic_launcher_news_foreground.xml new file mode 100644 index 0000000000..975dc532c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_news_foreground.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_notes_background.xml b/app/src/main/res/drawable/ic_launcher_notes_background.xml new file mode 100644 index 0000000000..f5bbeb9e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_notes_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_notes_foreground.xml b/app/src/main/res/drawable/ic_launcher_notes_foreground.xml new file mode 100644 index 0000000000..9a4b2abfed --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_notes_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_stocks_background.xml b/app/src/main/res/drawable/ic_launcher_stocks_background.xml new file mode 100644 index 0000000000..26c9ceba92 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_stocks_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml b/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml new file mode 100644 index 0000000000..0d8e493bbd --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_weather_background.xml b/app/src/main/res/drawable/ic_launcher_weather_background.xml new file mode 100644 index 0000000000..3f12e75253 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_weather_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_weather_foreground.xml b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml new file mode 100644 index 0000000000..4bc8563555 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index b58b3ca5c3..73b192ef34 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -374,5 +374,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calculator.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calculator.xml new file mode 100644 index 0000000000..770271bb99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calculator.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_meetings.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_meetings.xml new file mode 100644 index 0000000000..9ef4bf88e7 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_meetings.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml new file mode 100644 index 0000000000..1faf61b144 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml new file mode 100644 index 0000000000..cb16d04dbe --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_stocks.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_stocks.xml new file mode 100644 index 0000000000..04a7a1d685 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_stocks.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml new file mode 100644 index 0000000000..d4300281b2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_calculator_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_calculator_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..a7e6609a9bfa3cb6a69e016e7092e03337267d08 GIT binary patch literal 1848 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_calculator_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calculator_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..9482f6bc8111fb9fd93c67898d2a5ac88287f9d0 GIT binary patch literal 4934 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 EUW%Y~V*mgE literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calculator_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calculator_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..06d8c0ccd5a8408c17ff5458922c63da2931dd60 GIT binary patch literal 7208 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 oa{~r%egx_4=Ad~yYBBt Date: Tue, 6 May 2025 16:45:14 +1000 Subject: [PATCH 216/867] Handle singular/plural for toast and empty state for Select Cotnacts --- .../settings/ConversationSettingsNavHost.kt | 15 ++++--- .../settings/ConversationSettingsViewModel.kt | 6 +-- .../groups/compose/InviteContactsScreen.kt | 41 +++++++++++++++---- .../repository/ConversationRepository.kt | 10 +++-- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index ef8094efab..af15ab3a42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -34,7 +34,7 @@ import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsD import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteDisappearingMessages import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteFullscreenAvatar import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteGroupMembers -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteContacts +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToGroup import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteManageMembers import org.thoughtcrime.securesms.groups.EditGroupViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel @@ -65,7 +65,7 @@ sealed interface ConversationSettingsDestination { ): ConversationSettingsDestination @Serializable - data class RouteInviteContacts( + data class RouteInviteToGroup( val groupId: String, val excludingAccountIDs: List ): ConversationSettingsDestination @@ -78,6 +78,9 @@ sealed interface ConversationSettingsDestination { @Serializable data object RouteFullscreenAvatar: ConversationSettingsDestination + + @Serializable + data object RouteInviteToCommunity: ConversationSettingsDestination } @SuppressLint("RestrictedApi") @@ -199,7 +202,7 @@ fun ConversationSettingsNavHost( viewModel = viewModel, navigateToInviteContact = { navController.navigate( - RouteInviteContacts( + RouteInviteToGroup( groupId = data.groupId, excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList() ) @@ -209,9 +212,9 @@ fun ConversationSettingsNavHost( ) } - // Invite Contacts - horizontalSlideComposable { backStackEntry -> - val data: RouteInviteContacts = backStackEntry.toRoute() + // Invite Contacts to group + horizontalSlideComposable { backStackEntry -> + val data: RouteInviteToGroup = backStackEntry.toRoute() val viewModel = hiltViewModel { factory -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 1ab62b2045..917897f4b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -743,7 +743,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( viewModelScope.launch { showLoading() try { - withContext(Dispatchers.Default) { + val messagesDeleted = withContext(Dispatchers.Default) { conversationRepository.clearAllMessages( threadId, if (clearForEveryoneGroupsV2 && groupV2 != null) AccountId(groupV2!!.groupAccountId) else null @@ -752,8 +752,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( Toast.makeText(context, context.resources.getQuantityString( R.plurals.deleteMessageDeleted, - 2, // we don't care about the number, just that it is multiple messages since we are doing "Clear All" - 2 + messagesDeleted, + messagesDeleted ), Toast.LENGTH_LONG).show() } catch (e: Exception){ Toast.makeText(context, context.resources.getQuantityString( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 2d7d22087e..6602e55534 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData @@ -92,14 +93,23 @@ fun InviteContacts( val scrollState = rememberLazyListState() BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - LazyColumn( - state = scrollState, - contentPadding = PaddingValues(bottom = bottomContentPadding), - ) { - multiSelectMemberList( - contacts = contacts, - onContactItemClicked = onContactItemClicked, + if(contacts.isEmpty()){ + Text( + text = stringResource(id = R.string.contactNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.TopCenter), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) + } else { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { + multiSelectMemberList( + contacts = contacts, + onContactItemClicked = onContactItemClicked, + ) + } } } @@ -156,3 +166,20 @@ private fun PreviewSelectContacts() { } } +@Preview +@Composable +private fun PreviewSelectEmptyContacts() { + val contacts = emptyList() + + PreviewTheme { + InviteContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 124a63ff4b..3057faa13a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -98,8 +98,10 @@ interface ConversationRepository { * If a groupId is passed along, and if the user is an admin of that group, * this will also remove the messages from the swarm and update * the delete_before flag for that group to now + * + * Returns the amount of deleted messages */ - suspend fun clearAllMessages(threadId: Long, groupId: AccountId?) + suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int } class DefaultConversationRepository @Inject constructor( @@ -428,8 +430,8 @@ class DefaultConversationRepository @Inject constructor( } } - override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?) { - withContext(Dispatchers.Default) { + override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { + return withContext(Dispatchers.Default) { // delete data locally val deletedHashes = storage.clearAllMessages(threadId) Log.i("", "Cleared messages with hashes: $deletedHashes") @@ -438,6 +440,8 @@ class DefaultConversationRepository @Inject constructor( if (groupId != null) { groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) } + + deletedHashes.size } } From b375bd746fafbe8efb00e36acfb1df92e53e5d89 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 09:06:15 +1000 Subject: [PATCH 217/867] Reusing SelectContactVM for the community invite --- .../SelectContactsToInviteToGroupActivity.kt | 1 + .../conversation/v2/ConversationActivityV2.kt | 13 ---- .../conversation/v2/ConversationViewModel.kt | 4 -- .../v2/menus/ConversationMenuHelper.kt | 9 +-- .../settings/ConversationSettingsNavHost.kt | 42 ++++++++++--- .../settings/ConversationSettingsViewModel.kt | 61 ++++++++++++++----- .../groups/SelectContactsViewModel.kt | 4 ++ .../repository/ConversationRepository.kt | 4 +- .../v2/ConversationViewModelTest.kt | 9 --- 9 files changed, 88 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsToInviteToGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsToInviteToGroupActivity.kt index c39997deeb..4783038389 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsToInviteToGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsToInviteToGroupActivity.kt @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint +//todo remove this when we clean out the legacy group code @AndroidEntryPoint class SelectContactsToInviteToGroupActivity : ScreenLockActionBarActivity(), LoaderManager.LoaderCallbacks> { private lateinit var binding: ActivitySelectContactsBinding diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index db64dd16d4..ca2a733bd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -114,7 +114,6 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel -import org.thoughtcrime.securesms.contacts.SelectContactsToInviteToGroupActivity.Companion.SELECTED_CONTACTS_KEY import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener @@ -477,8 +476,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, const val TAKE_PHOTO = 7 const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 - const val INVITE_CONTACTS = 124 - const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result } // endregion @@ -2173,16 +2170,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } sendAttachments(slideDeck.asAttachments(), body) } - INVITE_CONTACTS -> { - if (viewModel.recipient?.isCommunityRecipient != true) { return } - val extras = intent?.extras ?: return - if (!intent.hasExtra(SELECTED_CONTACTS_KEY)) { return } - val selectedContacts = extras.getStringArray(SELECTED_CONTACTS_KEY)!! - val recipients = selectedContacts.map { contact -> - Recipient.from(this, fromSerialized(contact), true) - } - viewModel.inviteContacts(recipients) - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 5bb7de590b..3e9be490d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -566,10 +566,6 @@ class ConversationViewModel( return draft } - fun inviteContacts(contacts: List) { - repository.inviteContacts(threadId, contacts) - } - fun block() { // inviting admin will be non-null if this request is a closed group message request val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 24b867bbd1..e5d005327e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -196,7 +196,7 @@ object ConversationMenuHelper { /* R.id.menu_leave_group -> { return leaveGroup( context, thread, threadID, factory, storage, groupManager, deprecationManager ) }*/ - R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } +// R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) } R.id.menu_notification_settings -> { setNotifyType(context, thread) } @@ -456,13 +456,6 @@ object ConversationMenuHelper { } } - private fun inviteContacts(context: Context, thread: Recipient) { - if (!thread.isCommunityRecipient) { return } - val intent = Intent(context, SelectContactsToInviteToGroupActivity::class.java) - val activity = context as AppCompatActivity - activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) - } - private fun unmute(context: Context, thread: Recipient) { DatabaseComponent.get(context).recipientDatabase().setMuted(thread, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index af15ab3a42..6e1956b649 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -29,13 +29,7 @@ import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteAllMedia -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteConversationSettings -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteDisappearingMessages -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteFullscreenAvatar -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteGroupMembers -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToGroup -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteManageMembers +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* import org.thoughtcrime.securesms.groups.EditGroupViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel @@ -80,7 +74,9 @@ sealed interface ConversationSettingsDestination { data object RouteFullscreenAvatar: ConversationSettingsDestination @Serializable - data object RouteInviteToCommunity: ConversationSettingsDestination + data class RouteInviteToCommunity( + val communityUrl: String + ): ConversationSettingsDestination } @SuppressLint("RestrictedApi") @@ -219,7 +215,7 @@ fun ConversationSettingsNavHost( val viewModel = hiltViewModel { factory -> factory.create( - excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() + excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() //todo UCS Make sure we do not show blocked contacts ) } @@ -243,6 +239,34 @@ fun ConversationSettingsNavHost( ) } + // Invite Contacts to community + horizontalSlideComposable { backStackEntry -> + val viewModel = + hiltViewModel { factory -> + factory.create() //todo UCS Make sure we do not show blocked contacts + } + + // grab a hold of settings's VM + val parentEntry = remember(navController.currentBackStackEntry) { + navController.getBackStackEntry( + RouteConversationSettings + ) + } + val settingsViewModel: ConversationSettingsViewModel = hiltViewModel(parentEntry) + + InviteContactsScreen( + viewModel = viewModel, + onDoneClicked = { + //send invites from the manage group screen + settingsViewModel.inviteContactsToCommunity(viewModel.currentSelected) + + // clear selected contacts + viewModel.clearSelection() + }, + onBack = navController::popBackStack, + ) + } + // Disappearing Messages horizontalSlideComposable { val viewModel: DisappearingMessagesViewModel = diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 917897f4b7..7ae8d22e92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -41,6 +41,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY @@ -52,6 +53,7 @@ import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -828,21 +830,20 @@ class ConversationSettingsViewModel @AssistedInject constructor( val conversation = recipient ?: return viewModelScope.launch { showLoading() - withContext(Dispatchers.Default) { - try { + + try { + withContext(Dispatchers.Default) { groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) - hideLoading() - goBackHome() - } catch (e: Exception){ - withContext(Dispatchers.Main) { - hideLoading() - - val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) - .put(GROUP_NAME_KEY, getGroupName()) - .format().toString() - Toast.makeText(context, txt, Toast.LENGTH_LONG).show() - } } + hideLoading() + goBackHome() + } catch (e: Exception){ + hideLoading() + + val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) + .put(GROUP_NAME_KEY, getGroupName()) + .format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() } } } @@ -894,6 +895,34 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + fun inviteContactsToCommunity(contacts: Set) { + showLoading() + viewModelScope.launch { + try { + withContext(Dispatchers.Default) { + val recipients = contacts.map { contact -> + Recipient.from(context, fromSerialized(contact.hexString), true) + } + + repository.inviteContactsToCommunity(threadId, recipients) + } + + hideLoading() + + // show confirmation toast + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ), Toast.LENGTH_LONG).show() + } catch (e: Exception){ + Log.w("", "Error sending community invites", e) + hideLoading() + Toast.makeText(context, R.string.errorUnknown, Toast.LENGTH_LONG).show() + } + } + } + sealed interface Commands { data object CopyAccountId : Commands data object HideSimpleDialog : Commands @@ -1059,7 +1088,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.membersInvite), icon = R.drawable.ic_user_round_plus, qaTag = R.string.qa_conversation_settings_invite_contacts, - onClick = ::copyAccountId //todo UCS get proper method + onClick = { + navigateTo(ConversationSettingsDestination.RouteInviteToCommunity( + communityUrl = community?.joinURL ?: "" + )) + } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 4759c80b0e..7210ed5868 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -144,6 +144,10 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableSelectedContactAccountIDs.value += accountIDs } + fun clearSelection(){ + mutableSelectedContactAccountIDs.value = emptySet() + } + @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 3057faa13a..b6c05b14c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -56,7 +56,7 @@ interface ConversationRepository { fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? fun clearDrafts(threadId: Long) - fun inviteContacts(threadId: Long, contacts: List) + fun inviteContactsToCommunity(threadId: Long, contacts: List) fun setBlocked(recipient: Recipient, blocked: Boolean) fun markAsDeletedLocally(messages: Set, displayedMessage: String) fun deleteMessages(messages: Set, threadId: Long) @@ -161,7 +161,7 @@ class DefaultConversationRepository @Inject constructor( draftDb.clearDrafts(threadId) } - override fun inviteContacts(threadId: Long, contacts: List) { + override fun inviteContactsToCommunity(threadId: Long, contacts: List) { val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return for (contact in contacts) { val message = VisibleMessage() diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 1188ab8bb2..3f2832be11 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -126,15 +126,6 @@ class ConversationViewModelTest: BaseViewModelTest() { assertThat(result, equalTo(draft)) } - @Test - fun `should invite contacts`() = runBlockingTest { - val contacts = listOf() - - viewModel.inviteContacts(contacts) - - verify(repository).inviteContacts(threadId, contacts) - } - @Test fun `should unblock contact recipient`() = runBlockingTest { whenever(recipient.isContactRecipient).thenReturn(true) From e5bb4eafca1cde432e228684b2249320296aae5b Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 7 May 2025 09:48:11 +1000 Subject: [PATCH 218/867] App disguise feature tweaks (#1133) --- app/src/main/AndroidManifest.xml | 45 +++--- .../appearance/AppDisguiseSettings.kt | 131 +++++++++--------- .../AppDisguiseSettingsViewModel.kt | 15 +- .../ic_launcher_weather_foreground.xml | 40 ++++-- .../layout/activity_appearance_settings.xml | 11 +- 5 files changed, 134 insertions(+), 108 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7f852d8eb..a8c205874d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -487,11 +487,11 @@ @@ -501,9 +501,9 @@ @@ -515,11 +515,11 @@ @@ -529,11 +529,11 @@ @@ -543,9 +543,9 @@ @@ -557,11 +557,11 @@ @@ -569,7 +569,6 @@ - \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index 8a90727136..e8e5c8fa59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.preferences.appearance import android.graphics.drawable.AdaptiveIconDrawable import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -18,7 +18,6 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -26,9 +25,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -62,7 +63,6 @@ fun AppDisguiseSettingsScreen( onBack = onBack, setOn = viewModel::setOn, isOn = viewModel.isOn.collectAsState().value, - showList = viewModel.showAlternativeIconList.collectAsState().value, items = viewModel.alternativeIcons.collectAsState().value, onItemSelected = viewModel::onIconSelected ) @@ -73,7 +73,6 @@ fun AppDisguiseSettingsScreen( private fun AppDisguiseSettings( items: List, isOn: Boolean, - showList: Boolean, setOn: (Boolean) -> Unit, onItemSelected: (String) -> Unit, onBack: () -> Unit, @@ -114,66 +113,63 @@ private fun AppDisguiseSettings( } } - Crossfade(showList) { show -> - if (show) { - BoxWithConstraints { - // Calculate the number of columns based on the min width we want each column - // to be. - val minColumnWidth = LocalDimensions.current.xxsSpacing + ICON_ITEM_SIZE_DP.dp - val numColumn = - (constraints.maxWidth / LocalDensity.current.run { minColumnWidth.toPx() }).toInt() - val numRows = ceil(items.size.toFloat() / numColumn).toInt() + BoxWithConstraints { + // Calculate the number of columns based on the min width we want each column + // to be. + val minColumnWidth = LocalDimensions.current.xxsSpacing + ICON_ITEM_SIZE_DP.dp + val numColumn = + (constraints.maxWidth / LocalDensity.current.run { minColumnWidth.toPx() }).toInt() + val numRows = ceil(items.size.toFloat() / numColumn).toInt() + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + ) { + Text( + stringResource(R.string.appIconAndNameSelectionTitle), + style = LocalType.current.large, + color = LocalColors.current.textSecondary + ) + + Cell { Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + modifier = Modifier.padding(LocalDimensions.current.xsSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) ) { - Text( - stringResource(R.string.appIconAndNameSelectionTitle), - style = LocalType.current.large, - color = LocalColors.current.textSecondary - ) - - Cell { - Column( - modifier = Modifier.padding(LocalDimensions.current.xsSpacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + repeat(numRows) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, ) { - repeat(numRows) { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - for (index in row * numColumn.. get() = manager.isOn - // Whether to show the selection items - val showAlternativeIconList: StateFlow get() = isOn - // The contents of the selection items val alternativeIcons: StateFlow> = combine( manager.allAppAliases, - manager.selectedAppAliasName - ) { aliases, selected -> + manager.selectedAppAliasName, + manager.isOn + ) { aliases, selected, on -> aliases.mapNotNull { alias -> IconAndName( id = alias.activityAliasName, icon = alias.appIcon ?: return@mapNotNull null, name = alias.appName ?: return@mapNotNull null, - selected = alias.activityAliasName == selected + selected = on && alias.activityAliasName == selected ) } }.stateIn( @@ -42,11 +40,16 @@ class AppDisguiseSettingsViewModel @Inject constructor( ) fun onIconSelected(id: String) { + manager.setOn(true) manager.setSelectedAliasName(id) } fun setOn(on: Boolean) { manager.setOn(on) + + if (manager.selectedAppAliasName.value == null) { + manager.setSelectedAliasName(alternativeIcons.value.firstOrNull()?.id) + } } data class IconAndName( diff --git a/app/src/main/res/drawable/ic_launcher_weather_foreground.xml b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml index 4bc8563555..6b28dd231b 100644 --- a/app/src/main/res/drawable/ic_launcher_weather_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml @@ -1,30 +1,42 @@ - + android:viewportHeight="628"> + + android:fillAlpha="0.9"> + + + + + + + diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index 73b192ef34..79974ea14c 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -10,7 +10,8 @@ + android:layout_height="wrap_content" + android:paddingBottom="@dimen/medium_spacing"> + android:layout_height="wrap_content" + android:minHeight="50dp"> + android:layout_height="wrap_content" + android:minHeight="50dp"> From f81ec6155512c1c5a5802584e174daf5cabbc491 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 10:01:45 +1000 Subject: [PATCH 219/867] Default filtering of contact list --- .../v2/settings/ConversationSettingsNavHost.kt | 8 ++++---- .../thoughtcrime/securesms/groups/CreateGroupViewModel.kt | 2 ++ .../securesms/groups/SelectContactsViewModel.kt | 8 +++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 6e1956b649..d04eba5d23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -215,7 +215,7 @@ fun ConversationSettingsNavHost( val viewModel = hiltViewModel { factory -> factory.create( - excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() //todo UCS Make sure we do not show blocked contacts + excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() ) } @@ -243,10 +243,10 @@ fun ConversationSettingsNavHost( horizontalSlideComposable { backStackEntry -> val viewModel = hiltViewModel { factory -> - factory.create() //todo UCS Make sure we do not show blocked contacts + factory.create() } - // grab a hold of settings's VM + // grab a hold of settings' VM val parentEntry = remember(navController.currentBackStackEntry) { navController.getBackStackEntry( RouteConversationSettings @@ -257,7 +257,7 @@ fun ConversationSettingsNavHost( InviteContactsScreen( viewModel = viewModel, onDoneClicked = { - //send invites from the manage group screen + //send invites from the settings screen settingsViewModel.inviteContactsToCommunity(viewModel.currentSelected) // clear selected contacts diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt index fb02e147f4..e9133dad92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -36,9 +36,11 @@ class CreateGroupViewModel @AssistedInject constructor( @Assisted createFromLegacyGroupId: String?, ): ViewModel() { // Child view model to handle contact selection logic + //todo we should probably extend this VM instead of instantiating it here val selectContactsViewModel = SelectContactsViewModel( configFactory = configFactory, excludingAccountIDs = emptySet(), + applyDefaultFiltering = true, scope = viewModelScope, appContext = appContext, avatarUtils = avatarUtils diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 7210ed5868..d59d76609f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -40,6 +40,7 @@ open class SelectContactsViewModel @AssistedInject constructor( private val avatarUtils: AvatarUtils, @ApplicationContext private val appContext: Context, @Assisted private val excludingAccountIDs: Set, + @Assisted private val applyDefaultFiltering: Boolean, // true by default - If true will filter out blocked and unapproved contacts @Assisted private val scope: CoroutineScope, ) : ViewModel() { // Input: The search query @@ -85,7 +86,7 @@ open class SelectContactsViewModel @AssistedInject constructor( .asSequence() .map { AccountId(it.id) } + manuallyAdded) - if (excludingAccountIDs.isEmpty()) { + val recipientContacts = if (excludingAccountIDs.isEmpty()) { allContacts.toSet() } else { allContacts.filterNotTo(mutableSetOf()) { it in excludingAccountIDs } @@ -96,6 +97,10 @@ open class SelectContactsViewModel @AssistedInject constructor( false ) } + + if(applyDefaultFiltering){ + recipientContacts.filter { !it.isBlocked && it.isApproved } // filter out blocked contacts and unapproved contacts + } else recipientContacts } } } @@ -152,6 +157,7 @@ open class SelectContactsViewModel @AssistedInject constructor( interface Factory { fun create( excludingAccountIDs: Set = emptySet(), + applyDefaultFiltering: Boolean = true, scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), ): SelectContactsViewModel } From 2102bf8fa66cd6b1f29e329577268aaf2a43a566 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 10:16:16 +1000 Subject: [PATCH 220/867] Adding 'clear' button to search bar --- .../settings/ConversationSettingsNavHost.kt | 2 +- .../groups/compose/CreateGroupScreen.kt | 5 +++++ .../groups/compose/InviteContactsScreen.kt | 5 +++++ .../thoughtcrime/securesms/ui/Components.kt | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index d04eba5d23..e73f80c866 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -215,7 +215,7 @@ fun ConversationSettingsNavHost( val viewModel = hiltViewModel { factory -> factory.create( - excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() + excludingAccountIDs = data.excludingAccountIDs.map(::AccountId).toSet() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt index 54f8a406ef..1baf4eca39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt @@ -86,6 +86,7 @@ fun CreateGroupScreen( groupNameError = viewModel.groupNameError.collectAsState().value, contactSearchQuery = viewModel.selectContactsViewModel.searchQuery.collectAsState().value, onContactSearchQueryChanged = viewModel.selectContactsViewModel::onSearchQueryChanged, + onContactSearchQueryClear = { viewModel.selectContactsViewModel.onSearchQueryChanged("") }, onContactItemClicked = viewModel.selectContactsViewModel::onContactItemClicked, showLoading = viewModel.isLoading.collectAsState().value, items = viewModel.selectContactsViewModel.contacts.collectAsState().value, @@ -102,6 +103,7 @@ fun CreateGroup( groupNameError: String, contactSearchQuery: String, onContactSearchQueryChanged: (String) -> Unit, + onContactSearchQueryClear: () -> Unit, onContactItemClicked: (accountID: AccountId) -> Unit, showLoading: Boolean, items: List, @@ -147,6 +149,7 @@ fun CreateGroup( SearchBar( query = contactSearchQuery, onValueChanged = onContactSearchQueryChanged, + onClear = onContactSearchQueryClear, placeholder = stringResource(R.string.searchContacts), modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) .qaTag(R.string.AccessibilityId_groupNameSearch), @@ -237,6 +240,7 @@ private fun CreateGroupPreview( groupNameError = "", contactSearchQuery = "", onContactSearchQueryChanged = {}, + onContactSearchQueryClear = {}, onContactItemClicked = {}, showLoading = false, items = previewMembers, @@ -261,6 +265,7 @@ private fun CreateEmptyGroupPreview( groupNameError = "", contactSearchQuery = "", onContactSearchQueryChanged = {}, + onContactSearchQueryClear = {}, onContactItemClicked = {}, showLoading = false, items = previewMembers, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 6602e55534..757cf42a1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -49,6 +49,7 @@ fun InviteContactsScreen( onContactItemClicked = viewModel::onContactItemClicked, searchQuery = viewModel.searchQuery.collectAsState().value, onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, onDoneClicked = onDoneClicked, onBack = onBack, ) @@ -61,6 +62,7 @@ fun InviteContacts( onContactItemClicked: (accountId: AccountId) -> Unit, searchQuery: String, onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, onDoneClicked: () -> Unit, onBack: () -> Unit, @StringRes okButtonResId: Int = R.string.ok @@ -83,6 +85,7 @@ fun InviteContacts( SearchBar( query = searchQuery, onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, placeholder = stringResource(R.string.searchContacts), modifier = Modifier .padding(horizontal = LocalDimensions.current.smallSpacing) @@ -160,6 +163,7 @@ private fun PreviewSelectContacts() { onContactItemClicked = {}, searchQuery = "", onSearchQueryChanged = {}, + onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, ) @@ -177,6 +181,7 @@ private fun PreviewSelectEmptyContacts() { onContactItemClicked = {}, searchQuery = "", onSearchQueryChanged = {}, + onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 64d4be0e47..770ed12828 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -649,6 +649,7 @@ fun Modifier.verticalScrollbar( fun SearchBar( query: String, onValueChanged: (String) -> Unit, + onClear: () -> Unit, modifier: Modifier = Modifier, placeholder: String? = null, enabled: Boolean = true, @@ -690,6 +691,23 @@ fun SearchBar( ) } } + + Image( + painterResource(id = R.drawable.ic_x), + contentDescription = stringResource(R.string.clear), + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + .size(LocalDimensions.current.iconSmall) + .clickable { + onClear() + } + ) } }, textStyle = LocalType.current.base.copy(color = LocalColors.current.text), @@ -705,6 +723,7 @@ fun PreviewSearchBar() { SearchBar( query = "", onValueChanged = {}, + onClear = {}, placeholder = "Search" ) } From 3933c84fda8fba195b2b055ed20f3c19467dda06 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 10:43:58 +1000 Subject: [PATCH 221/867] Added search in group members --- .../groups/BaseGroupMembersViewModel.kt | 31 +++++++-- .../groups/compose/GroupMembersScreen.kt | 67 ++++++++++++++----- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 368b2ad57e..a1f0650ff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -3,12 +3,14 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.assisted.AssistedFactory import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.EnumSet import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -25,6 +27,7 @@ import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils +import java.util.EnumSet abstract class BaseGroupMembersViewModel ( private val groupId: AccountId, @@ -62,10 +65,28 @@ abstract class BaseGroupMembersViewModel ( } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val mutableSearchQuery = MutableStateFlow("") + val searchQuery: StateFlow get() = mutableSearchQuery + // Output: the list of the members and their state in the group. - val members: StateFlow> = groupInfo - .map { it?.second.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + @OptIn(FlowPreview::class) + val members: StateFlow> = combine( + groupInfo.map { it?.second.orEmpty() }, + mutableSearchQuery.debounce(100L), + ::filterContacts + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun onSearchQueryChanged(query: String) { + mutableSearchQuery.value = query + } + + private fun filterContacts( + contacts: List, + query: String, + ): List { + return if(query.isBlank()) contacts + else contacts.filter { it.name.contains(query, ignoreCase = true) } + } private suspend fun createGroupMember( member: GroupMember, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 68ea8a66b0..176f6abd6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -1,6 +1,9 @@ package org.thoughtcrime.securesms.groups.compose +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api @@ -15,8 +18,11 @@ import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.GroupMembersViewModel +import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData @@ -29,7 +35,10 @@ fun GroupMembersScreen( ) { GroupMembers( onBack = onBack, - members = viewModel.members.collectAsState().value + members = viewModel.members.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, ) } @@ -39,6 +48,9 @@ fun GroupMembersScreen( fun GroupMembers( onBack: () -> Unit, members: List, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, ) { Scaffold( topBar = { @@ -48,22 +60,40 @@ fun GroupMembers( ) } ) { paddingValues -> - // List of members - LazyColumn(modifier = Modifier.consumeWindowInsets(paddingValues), contentPadding = paddingValues) { - items(members) { member -> - // Each member's view - MemberItem( - accountId = member.accountId, - title = member.name, - subtitle = member.statusLabel, - subtitleColor = if (member.highlightStatus) { - LocalColors.current.danger - } else { - LocalColors.current.textSecondary - }, - showAsAdmin = member.showAsAdmin, - avatarUIData = member.avatarUIData - ) + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + // List of members + LazyColumn() { + items(members) { member -> + // Each member's view + MemberItem( + accountId = member.accountId, + title = member.name, + subtitle = member.statusLabel, + subtitleColor = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + showAsAdmin = member.showAsAdmin, + avatarUIData = member.avatarUIData + ) + } } } } @@ -141,6 +171,9 @@ private fun EditGroupPreview() { GroupMembers( onBack = {}, members = listOf(oneMember, twoMember, threeMember), + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, ) } } From cf7d7c9d5310898bcafbe215e98c252615c6fc30 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 10:49:55 +1000 Subject: [PATCH 222/867] Fixed contact display name logic (we were not catering for empty strings) --- .../java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt index 999682f098..c5a81da8a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt @@ -49,6 +49,6 @@ class UsernameUtilsImpl( // if the username is actually set to the user's accountId, truncate it val validatedUsername = if(userName == accountID) truncateIdForDisplay(accountID) else userName - return validatedUsername ?: truncateIdForDisplay(accountID) + return if(validatedUsername.isNullOrEmpty()) truncateIdForDisplay(accountID) else validatedUsername } } \ No newline at end of file From 0385ecf5be99c67663b53d9f5afa9e19cb104a4b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 May 2025 10:53:30 +1000 Subject: [PATCH 223/867] New sorting logic --- .../groups/BaseGroupMembersViewModel.kt | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index a1f0650ff6..138a61c60e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -159,28 +159,8 @@ abstract class BaseGroupMembersViewModel ( // Refer to notion doc for the sorting logic private fun sortMembers(members: List, currentUserId: AccountId) = members.sortedWith( - compareBy{ - when (it.status) { - GroupMember.Status.INVITE_FAILED -> 0 - GroupMember.Status.INVITE_NOT_SENT -> 1 - GroupMember.Status.INVITE_SENDING -> 2 - GroupMember.Status.INVITE_SENT -> 3 - GroupMember.Status.INVITE_UNKNOWN -> 4 - GroupMember.Status.REMOVED, - GroupMember.Status.REMOVED_UNKNOWN, - GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5 - GroupMember.Status.PROMOTION_FAILED -> 6 - GroupMember.Status.PROMOTION_NOT_SENT -> 7 - GroupMember.Status.PROMOTION_SENDING -> 8 - GroupMember.Status.PROMOTION_SENT -> 9 - GroupMember.Status.PROMOTION_UNKNOWN -> 10 - null, - GroupMember.Status.INVITE_ACCEPTED, - GroupMember.Status.PROMOTION_ACCEPTED -> 11 - } - } + compareBy{ it.accountId != currentUserId } // Current user comes first .thenBy { !it.showAsAdmin } // Admins come first - .thenBy { it.accountId != currentUserId } // Being myself comes first .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive) .thenBy { it.accountId } // Last resort: sort by account ID ) From 766ff6c37dd3ee617a440834493784e9334726d2 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 7 May 2025 13:27:20 +1000 Subject: [PATCH 224/867] Enable edge to edge for conversation screen (#1134) --- .../conversation/v2/ConversationActivityV2.kt | 46 +++++++++++++++++++ .../securesms/util/ViewUtilities.kt | 15 +++--- .../res/layout/activity_conversation_v2.xml | 1 + app/src/main/res/layout/view_input_bar.xml | 3 +- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index ee3df0805c..c63f91440d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -40,7 +40,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer @@ -190,6 +195,7 @@ import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.PaddedImageSpan import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.drawToBitmap import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut @@ -245,6 +251,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var configFactory: ConfigFactory @Inject lateinit var groupManagerV2: GroupManagerV2 + override val applyDefaultWindowInsets: Boolean + get() = false + private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { // post screenshot message @@ -752,6 +761,36 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, .lastSeenMessageId .collectLatest { adapter.lastSentMessageId = it } } + + // Apply insets on the recycler view or input bar depending on their visibility + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBarsInsets = + windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()) + + val viewToApplyPaddings: View + val viewToResetPaddings: View + + if (binding.inputBar.isGone) { + viewToApplyPaddings = binding.conversationRecyclerView + viewToResetPaddings = binding.inputBar + } else { + viewToApplyPaddings = binding.inputBar + viewToResetPaddings = binding.conversationRecyclerView + } + + // Update view padding to account for system bars + viewToApplyPaddings.updatePadding( + left = systemBarsInsets.left, + top = systemBarsInsets.top, + right = systemBarsInsets.right, + bottom = systemBarsInsets.bottom + ) + viewToResetPaddings.updatePadding(0, 0, 0, 0) + + windowInsets.inset(systemBarsInsets) + } + + binding.root.requestApplyInsets() } private fun scrollToMostRecentMessageIfWeShould() { @@ -786,6 +825,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // called from onCreate private fun setUpToolBar() { setSupportActionBar(binding.toolbar) + + binding.toolbar.applySafeInsetsPaddings(WindowInsetsCompat.Type.statusBars()) + val actionBar = supportActionBar ?: return val recipient = viewModel.recipient ?: return actionBar.title = "" @@ -1029,6 +1071,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, allowAttachMultimediaButtons = state.enableAttachMediaControls } + binding.root.requestApplyInsets() + // show or hide loading indicator binding.loader.isVisible = state.showLoader @@ -2555,6 +2599,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, binding.searchBottomBar.visibility = View.VISIBLE binding.searchBottomBar.setData(0, 0) binding.inputBar.visibility = View.INVISIBLE + binding.root.requestApplyInsets() } @@ -2562,6 +2607,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, searchViewModel.onSearchClosed() binding.searchBottomBar.visibility = View.GONE binding.inputBar.visibility = View.VISIBLE + binding.root.requestApplyInsets() adapter.onSearchQueryUpdated(null) invalidateOptionsMenu() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index adcbd154ba..8bd1f68c49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -12,7 +12,6 @@ import android.util.Size import android.util.TypedValue import android.view.View import android.view.ViewGroup.MarginLayoutParams -import android.view.Window import androidx.annotation.ColorInt import androidx.annotation.DimenRes import network.loki.messenger.R @@ -141,19 +140,17 @@ fun View.applySafeInsetsPaddings( consumeInsets: Boolean = true, ) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> - // Get system bars insets - val systemBarsInsets = windowInsets.getInsets(typeMask) + val insets = windowInsets.getInsets(typeMask) - // Update view padding to account for system bars view.updatePadding( - left = systemBarsInsets.left, - top = systemBarsInsets.top, - right = systemBarsInsets.right, - bottom = systemBarsInsets.bottom + left = insets.left, + top = insets.top, + right = insets.right, + bottom = insets.bottom ) if (consumeInsets) { - WindowInsetsCompat.CONSUMED + windowInsets.inset(insets) } else { // Return the insets unconsumed windowInsets diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index eb75a92f87..092ed7fb19 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -60,6 +60,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" tools:layout_height="60dp" + android:background="?input_bar_background" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/recreateGroupButtonContainer" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index ffc399719b..8fd86914f9 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -4,8 +4,7 @@ android:id="@+id/inputBarLinearLayout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:background="?input_bar_background"> + android:orientation="vertical"> Date: Wed, 7 May 2025 13:32:15 +1000 Subject: [PATCH 225/867] Might be due to debug mode --- .../thoughtcrime/securesms/groups/compose/EditGroupScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 2b5c1ed002..0f250fb342 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -125,7 +125,7 @@ fun EditGroup( } val maxNameWidth = 240.dp -//todo UCS this screen entry transition seem to lag when there are many members + Scaffold( topBar = { BackAppBar( From 744e1449f320cc29b6db8df84e0e5034983dd1bb Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 7 May 2025 14:25:01 +1000 Subject: [PATCH 226/867] [SES-3778] - Replace ViewPager with ViewPager2 (#1136) --- .../securesms/MediaPreviewActivity.java | 83 +++++++------------ .../securesms/giph/ui/GiphyActivity.java | 51 +++++------- .../securesms/giph/ui/GiphyFragment.java | 9 +- .../scribbles/StickerSelectActivity.java | 43 +++++----- app/src/main/res/layout/giphy_activity.xml | 2 +- .../res/layout/media_preview_activity.xml | 2 +- .../scribble_select_sticker_activity.xml | 2 +- 7 files changed, 78 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 14205789b6..795c83b43c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -38,7 +38,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.Window; -import android.widget.FrameLayout; import android.widget.Toast; import androidx.annotation.NonNull; @@ -51,8 +50,8 @@ import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; @@ -94,6 +93,7 @@ import kotlin.Unit; import network.loki.messenger.R; import network.loki.messenger.databinding.MediaPreviewActivityBinding; +import network.loki.messenger.databinding.MediaViewPageBinding; /** * Activity for displaying media attachments in-app @@ -341,7 +341,7 @@ private void initializeMedia() { if (conversationRecipient != null) { getSupportLoaderManager().restartLoader(0, null, this); } else { - adapter = new SingleItemPagerAdapter(this, Glide.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize); + adapter = new SingleItemPagerAdapter(Glide.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize); binding.mediaPager.setAdapter(adapter); if (initialCaption != null) { @@ -511,7 +511,7 @@ public static boolean isContentTypeSupported(final String contentType) { public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { if (data == null) return; - binding.mediaPager.removeOnPageChangeListener(viewPagerListener); + binding.mediaPager.unregisterOnPageChangeCallback(viewPagerListener); adapter = new CursorPagerAdapter(this, Glide.with(this), getWindow(), data.first, data.second, leftIsRecent); binding.mediaPager.setAdapter(adapter); @@ -531,10 +531,10 @@ public boolean onSingleTapConfirmed(@NonNull MotionEvent e) { viewModel.setCursor(this, data.first, leftIsRecent); - int item = restartItem >= 0 && restartItem < adapter.getCount() ? restartItem : Math.max(Math.min(data.second, adapter.getCount() - 1), 0); + int item = restartItem >= 0 && restartItem < adapter.getItemCount() ? restartItem : Math.max(Math.min(data.second, adapter.getItemCount() - 1), 0); viewPagerListener = new ViewPagerListener(); - binding.mediaPager.addOnPageChangeListener(viewPagerListener); + binding.mediaPager.registerOnPageChangeCallback(viewPagerListener); try { binding.mediaPager.setCurrentItem(item); @@ -548,7 +548,7 @@ public boolean onSingleTapConfirmed(@NonNull MotionEvent e) { @Override public void onLoaderReset(@NonNull Loader> loader) { /* Do nothing */ } - private class ViewPagerListener implements ViewPager.OnPageChangeListener { + private class ViewPagerListener extends ViewPager2.OnPageChangeCallback { private int currentPage = -1; @@ -596,9 +596,8 @@ private static class SingleItemPagerAdapter extends MediaItemAdapter { private final String mediaType; private final long size; - private final LayoutInflater inflater; - SingleItemPagerAdapter(@NonNull Context context, @NonNull RequestManager glideRequests, + SingleItemPagerAdapter(@NonNull RequestManager glideRequests, @NonNull Window window, @NonNull Uri uri, @NonNull String mediaType, long size) { @@ -607,41 +606,30 @@ private static class SingleItemPagerAdapter extends MediaItemAdapter { this.uri = uri; this.mediaType = mediaType; this.size = size; - this.inflater = LayoutInflater.from(context); } + @NonNull @Override - public int getCount() { - return 1; - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new RecyclerView.ViewHolder( + MediaViewPageBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false).getRoot() + ) {}; } @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - View itemView = inflater.inflate(R.layout.media_view_page, container, false); - MediaView mediaView = itemView.findViewById(R.id.media_view); + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + final MediaViewPageBinding binding = MediaViewPageBinding.bind(holder.itemView); try { - mediaView.set(glideRequests, window, uri, mediaType, size, true); + binding.mediaView.set(glideRequests, window, uri, mediaType, size, true); } catch (IOException e) { Log.w(TAG, e); } - - container.addView(itemView); - - return itemView; } @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); - mediaView.cleanup(); - - container.removeView((FrameLayout)object); + public int getItemCount() { + return 1; } @Override @@ -682,20 +670,16 @@ private static class CursorPagerAdapter extends MediaItemAdapter { this.leftIsRecent = leftIsRecent; } + @NonNull @Override - public int getCount() { - return cursor.getCount(); + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new RecyclerView.ViewHolder(MediaViewPageBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false).getRoot()) {}; } @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + final MediaViewPageBinding binding = MediaViewPageBinding.bind(holder.itemView); - @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - View itemView = LayoutInflater.from(context).inflate(R.layout.media_view_page, container, false); - MediaView mediaView = itemView.findViewById(R.id.media_view); boolean autoplay = position == autoPlayPosition; int cursorPosition = getCursorPosition(position); @@ -707,25 +691,18 @@ public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { try { //noinspection ConstantConditions - mediaView.set(glideRequests, window, mediaRecord.getAttachment().getDataUri(), - mediaRecord.getAttachment().getContentType(), mediaRecord.getAttachment().getSize(), autoplay); + binding.mediaView.set(glideRequests, window, mediaRecord.getAttachment().getDataUri(), + mediaRecord.getAttachment().getContentType(), mediaRecord.getAttachment().getSize(), autoplay); } catch (IOException e) { Log.w(TAG, e); } - mediaViews.put(position, mediaView); - container.addView(itemView); - - return itemView; + mediaViews.put(position, binding.mediaView); } @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); - mediaView.cleanup(); - - mediaViews.remove(position); - container.removeView((FrameLayout)object); + public int getItemCount() { + return cursor.getCount(); } public MediaItem getMediaItemFor(int position) { @@ -786,7 +763,7 @@ private MediaItem(@Nullable Recipient recipient, } } - abstract static class MediaItemAdapter extends PagerAdapter { + abstract static class MediaItemAdapter extends RecyclerView.Adapter { abstract MediaItem getMediaItemFor(int position); abstract void pause(int position); @Nullable abstract View getPlaybackControls(int position); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index 68257fb0a7..fcf5f98dc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -12,11 +12,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; import androidx.viewpager.widget.ViewPager; +import androidx.viewpager2.adapter.FragmentStateAdapter; import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; import org.session.libsession.utilities.MediaTypes; import org.session.libsession.utilities.NonTranslatableStringConstants; @@ -29,6 +32,7 @@ import java.util.concurrent.ExecutionException; import network.loki.messenger.R; +import network.loki.messenger.databinding.GiphyActivityBinding; public class GiphyActivity extends ScreenLockActionBarActivity implements GiphyActivityToolbar.OnLayoutChangedListener, @@ -46,11 +50,14 @@ public class GiphyActivity extends ScreenLockActionBarActivity private GiphyStickerFragment stickerFragment; private boolean forMms; + private GiphyActivityBinding binding; + private GiphyAdapter.GiphyViewHolder finishingImage; @Override public void onCreate(Bundle bundle, boolean ready) { - setContentView(R.layout.giphy_activity); + binding = GiphyActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); initializeToolbar(); initializeResources(); @@ -69,19 +76,15 @@ private void initializeToolbar() { } private void initializeResources() { - ViewPager viewPager = ViewUtil.findById(this, R.id.giphy_pager); - TabLayout tabLayout = ViewUtil.findById(this, R.id.tab_layout); - this.gifFragment = new GiphyGifFragment(); this.stickerFragment = new GiphyStickerFragment(); this.forMms = getIntent().getBooleanExtra(EXTRA_IS_MMS, false); - gifFragment.setClickListener(this); - stickerFragment.setClickListener(this); + binding.giphyPager.setAdapter(new GiphyFragmentPagerAdapter(this)); - viewPager.setAdapter(new GiphyFragmentPagerAdapter(this, getSupportFragmentManager(), - gifFragment, stickerFragment)); - tabLayout.setupWithViewPager(viewPager); + new TabLayoutMediator(binding.tabLayout, binding.giphyPager, (tab, position) -> { + tab.setText(position == 0 ? NonTranslatableStringConstants.GIF : getString(R.string.stickers)); + }).attach(); } @Override @@ -137,39 +140,23 @@ protected void onPostExecute(@Nullable Uri uri) { }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - private static class GiphyFragmentPagerAdapter extends FragmentPagerAdapter { - - private final Context context; - private final GiphyGifFragment gifFragment; - private final GiphyStickerFragment stickerFragment; + private class GiphyFragmentPagerAdapter extends FragmentStateAdapter { - private GiphyFragmentPagerAdapter(@NonNull Context context, - @NonNull FragmentManager fragmentManager, - @NonNull GiphyGifFragment gifFragment, - @NonNull GiphyStickerFragment stickerFragment) + private GiphyFragmentPagerAdapter(@NonNull FragmentActivity activity) { - super(fragmentManager); - this.context = context.getApplicationContext(); - this.gifFragment = gifFragment; - this.stickerFragment = stickerFragment; + super(activity); } + @NonNull @Override - public Fragment getItem(int position) { - if (position == 0) return gifFragment; - else return stickerFragment; + public Fragment createFragment(int position) { + return position == 0 ? gifFragment : stickerFragment; } @Override - public int getCount() { + public int getItemCount() { return 2; } - - @Override - public CharSequence getPageTitle(int position) { - if (position == 0) return NonTranslatableStringConstants.GIF; - else return context.getString(R.string.stickers); - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java index d4b1d642dc..6feb2641b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -36,7 +36,6 @@ public abstract class GiphyFragment extends Fragment implements LoaderManager.Lo private RecyclerView recyclerView; private View loadingProgress; private TextView noResultsView; - private GiphyAdapter.OnItemClickListener listener; protected String searchString; @@ -90,10 +89,6 @@ private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { : new LinearLayoutManager(getActivity()); } - public void setClickListener(GiphyAdapter.OnItemClickListener listener) { - this.listener = listener; - } - public void setSearchString(@Nullable String searchString) { this.searchString = searchString; this.noResultsView.setVisibility(View.GONE); @@ -102,7 +97,9 @@ public void setSearchString(@Nullable String searchString) { @Override public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { - if (listener != null) listener.onClick(viewHolder); + if (getActivity() instanceof GiphyAdapter.OnItemClickListener) { + ((GiphyAdapter.OnItemClickListener) getActivity()).onClick(viewHolder); + } } private class GiphyScrollListener extends InfiniteScrollListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java index 0b8ee5faff..8afc241a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java @@ -18,21 +18,21 @@ import android.content.Intent; import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.material.tabs.TabLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.viewpager.widget.ViewPager; -import android.view.MenuItem; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import com.google.android.material.tabs.TabLayoutMediator; import network.loki.messenger.R; +import network.loki.messenger.databinding.ScribbleSelectStickerActivityBinding; public class StickerSelectActivity extends FragmentActivity implements StickerSelectFragment.StickerSelectionListener { - private static final String TAG = StickerSelectActivity.class.getSimpleName(); - public static final String EXTRA_STICKER_FILE = "extra_sticker_file"; private static final int[] TAB_TITLES = new int[] { @@ -46,17 +46,19 @@ public class StickerSelectActivity extends FragmentActivity implements StickerSe @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.scribble_select_sticker_activity); - ViewPager viewPager = (ViewPager) findViewById(R.id.camera_sticker_pager); - viewPager.setAdapter(new StickerPagerAdapter(getSupportFragmentManager(), this)); + final ScribbleSelectStickerActivityBinding binding = ScribbleSelectStickerActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); - TabLayout tabLayout = (TabLayout) findViewById(R.id.camera_sticker_tabs); - tabLayout.setupWithViewPager(viewPager); + binding.cameraStickerPager.setAdapter(new StickerPagerAdapter(this, this)); - for (int i=0;i { + tab.setIcon(TAB_TITLES[position]); + } + ).attach(); } @Override @@ -76,12 +78,12 @@ public void onStickerSelected(String name) { finish(); } - static class StickerPagerAdapter extends FragmentStatePagerAdapter { + static class StickerPagerAdapter extends FragmentStateAdapter { private final Fragment[] fragments; - StickerPagerAdapter(FragmentManager fm, StickerSelectFragment.StickerSelectionListener listener) { - super(fm); + StickerPagerAdapter(FragmentActivity activity, StickerSelectFragment.StickerSelectionListener listener) { + super(activity); this.fragments = new Fragment[] { StickerSelectFragment.newInstance("stickers/emoticons"), @@ -96,13 +98,14 @@ static class StickerPagerAdapter extends FragmentStatePagerAdapter { } } + @NonNull @Override - public Fragment getItem(int position) { + public Fragment createFragment(int position) { return fragments[position]; } @Override - public int getCount() { + public int getItemCount() { return fragments.length; } } diff --git a/app/src/main/res/layout/giphy_activity.xml b/app/src/main/res/layout/giphy_activity.xml index 3b091c4b82..6d884f0214 100644 --- a/app/src/main/res/layout/giphy_activity.xml +++ b/app/src/main/res/layout/giphy_activity.xml @@ -33,7 +33,7 @@ - - - Date: Wed, 7 May 2025 16:34:50 +1000 Subject: [PATCH 227/867] Notifications settings base logic --- .../disappearingmessages/ui/UiState.kt | 11 +- .../v2/menus/ConversationMenuHelper.kt | 10 +- .../settings/ConversationSettingsNavHost.kt | 18 ++ .../settings/ConversationSettingsViewModel.kt | 4 +- .../NotificationSettingsScreen.kt | 152 +++++++++++++++++ .../NotificationSettingsViewModel.kt | 160 ++++++++++++++++++ .../thoughtcrime/securesms/ui/Components.kt | 25 ++- .../DisappearingMessagesViewModelTest.kt | 2 +- 8 files changed, 357 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt index 786d366fab..0d2065e627 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -1,9 +1,8 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui -import androidx.annotation.StringRes import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.OptionsCardData typealias ExpiryOptionsCardData = OptionsCardData @@ -25,11 +24,3 @@ data class UiState( subtitle, ) } - -data class OptionsCardData( - val title: GetString, - val options: List> -) { - constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) - constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index e5d005327e..9f46545f7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -197,9 +197,9 @@ object ConversationMenuHelper { context, thread, threadID, factory, storage, groupManager, deprecationManager ) }*/ // R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } - R.id.menu_unmute_notifications -> { unmute(context, thread) } - R.id.menu_mute_notifications -> { mute(context, thread) } - R.id.menu_notification_settings -> { setNotifyType(context, thread) } +// R.id.menu_unmute_notifications -> { unmute(context, thread) } +// R.id.menu_mute_notifications -> { mute(context, thread) } +// R.id.menu_notification_settings -> { setNotifyType(context, thread) } } return null @@ -456,7 +456,7 @@ object ConversationMenuHelper { } } - private fun unmute(context: Context, thread: Recipient) { +/* private fun unmute(context: Context, thread: Recipient) { DatabaseComponent.get(context).recipientDatabase().setMuted(thread, 0) } @@ -470,7 +470,7 @@ object ConversationMenuHelper { NotificationUtils.showNotifyDialog(context, thread) { notifyType -> DatabaseComponent.get(context).recipientDatabase().setNotifyType(thread, notifyType) } - } + }*/ interface ConversationMenuListener { fun copyAccountID(accountId: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index e73f80c866..633d44ee23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -30,6 +30,8 @@ import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel import org.thoughtcrime.securesms.groups.EditGroupViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel @@ -73,6 +75,9 @@ sealed interface ConversationSettingsDestination { @Serializable data object RouteFullscreenAvatar: ConversationSettingsDestination + @Serializable + data object RouteNotifications: ConversationSettingsDestination + @Serializable data class RouteInviteToCommunity( val communityUrl: String @@ -302,6 +307,19 @@ fun ConversationSettingsNavHost( ) } + // Notifications + horizontalSlideComposable { + val viewModel = + hiltViewModel { factory -> + factory.create(threadId) + } + + NotificationSettingsScreen( + viewModel = viewModel, + onBack = navController::popBackStack + ) + } + // Fullscreen Avatar composable { // grab a hold of manage convo setting's VM diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 7ae8d22e92..dadbe9fa15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -991,7 +991,9 @@ class ConversationSettingsViewModel @AssistedInject constructor( subtitle = subtitle, icon = R.drawable.ic_volume_2, qaTag = R.string.qa_conversation_settings_notifications, - onClick = ::copyAccountId //todo UCS get proper method + onClick = { + navigateTo(ConversationSettingsDestination.RouteNotifications) + } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..153daf2773 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.Callbacks +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.NoOpCallbacks +import org.thoughtcrime.securesms.ui.OptionsCard +import org.thoughtcrime.securesms.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + + +@Composable +fun NotificationSettingsScreen( + viewModel: NotificationSettingsViewModel, + onBack: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + + NotificationSettings( + state = state, + callbacks = viewModel, + onBack = onBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationSettings( + state: NotificationSettingsViewModel.UiState, + callbacks: Callbacks = NoOpCallbacks, + onBack: () -> Unit +) { //todo UCS add test tags + Scaffold( + topBar = { + BackAppBar( + title = LocalContext.current.getString(R.string.sessionNotifications), + onBack = onBack + ) + }, + ) { paddings -> + Column( + modifier = Modifier.padding(paddings).consumeWindowInsets(paddings) + ) { + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = LocalDimensions.current.spacing) + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + state.cards.forEachIndexed { index, option -> + OptionsCard(option, callbacks) + + // add spacing if not the last item + if (index != state.cards.lastIndex) { + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + } + } + + Spacer(modifier = Modifier.height(bottomContentPadding)) + } + } + + PrimaryOutlineButton( + stringResource(R.string.set), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_setButton) + .align(Alignment.CenterHorizontally) + .padding(bottom = LocalDimensions.current.spacing), + enabled = state.enableButton, + onClick = callbacks::onSetClick + ) + } + } +} + +@Preview +@Composable +fun PreviewNotificationSettings(){ + PreviewTheme { + NotificationSettings( + state = NotificationSettingsViewModel.UiState( + cards = listOf( + OptionsCardData( + title = null, + options = listOf( + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("All"), + selected = true + ), + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("Mentions Only"), + selected = false + ), + ) + ), + OptionsCardData( + title = GetString("Other Options"), + options = listOf( + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("Something"), + selected = false + ), + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("Something Else"), + selected = false + ), + ) + ) + ), + enableButton = true + ), + callbacks = NoOpCallbacks, + onBack = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt new file mode 100644 index 0000000000..2eb2626c58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + +import android.content.Context +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.LocalisedTimeUtil +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.ui.Callbacks +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.RadioOption +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel(assistedFactory = NotificationSettingsViewModel.Factory::class) +class NotificationSettingsViewModel @AssistedInject constructor( + @Assisted private val threadId: Long, + @ApplicationContext private val context: Context, + private val recipientDatabase: RecipientDatabase, + private val repository: ConversationRepository, +) : ViewModel(), Callbacks { + private var thread: Recipient? = null + + private var selectedOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + init { + // update data when we have a recipient and update when there are changes from the thread or recipient + viewModelScope.launch(Dispatchers.Default) { + repository.recipientUpdateFlow(threadId).collect { + thread = it + + updateState() + } + } + } + + private fun updateState(){ + // start with the default options + val defaultOptions = OptionsCardData( + title = null, + options = listOf( + NotificationType.All.toRadioOption(selectedOption), + NotificationType.MentionsOnly.toRadioOption(selectedOption), + NotificationType.Mute.toRadioOption(selectedOption) + ) + ) + + val options = mutableListOf(defaultOptions) + + // add the mute options if necessary + if(selectedOption is NotificationType.Mute || selectedOption is NotificationType.MuteType) { + options.add( + //todo UCS add actual mute options + //todo UCS figure out selection for this sub category when visible + OptionsCardData( + title = null, + options = listOf( + NotificationType.All.toRadioOption(selectedOption), + NotificationType.MentionsOnly.toRadioOption(selectedOption), + NotificationType.Mute.toRadioOption(selectedOption) + ) + ) + ) + } +//todo UCS need to add icons ro radio options + _uiState.update { + UiState( + cards = options, + enableButton = true //todo UCS calculate this properly + ) + } + } + + override fun setValue(value: NotificationType){ + selectedOption = value + updateState() + } + + override fun onSetClick() = viewModelScope.launch { + //todo UCS implement + } + + private fun unmute(context: Context) { + val conversation = thread ?: return + recipientDatabase.setMuted(conversation, 0) + } + + private fun mute(context: Context) { + val conversation = thread ?: return + //conversation.setMuted(thread, until) + } + + private fun setNotifyType(context: Context) { + val conversation = thread ?: return + //conversation.setNotifyType(thread, notifyType) + } + + data class UiState( + val cards: List> = emptyList(), + val enableButton: Boolean = false, + ) + + sealed interface NotificationType { + data object All: NotificationType + data object MentionsOnly: NotificationType + data object Mute: NotificationType + sealed class MuteType( + val duration: Long + ): NotificationType{ + data object Mute1H: MuteType(TimeUnit.HOURS.toMillis(1)) + data object Mute2H: MuteType(TimeUnit.HOURS.toMillis(2)) + data object Mute1Day: MuteType(TimeUnit.DAYS.toMillis(1)) + data object Mute1Week: MuteType(TimeUnit.DAYS.toMillis(7)) + data object MuteForever: MuteType(Long.MAX_VALUE) + + fun getTitleFromDuration(context: Context) = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + this.duration.milliseconds + ) + } + + fun getTitle(context: Context): String { + return when(this){ + is All -> context.getString(R.string.notificationsAllMessages) + is MentionsOnly -> context.getString(R.string.notificationsMentionsOnly) + is Mute -> context.getString(R.string.notificationsMute) + is MuteType -> getTitleFromDuration(context) + } + } + + fun toRadioOption(currentlySelectedOption: NotificationType) = RadioOption( + value = this, + title = GetString(this::getTitle), + selected = this == currentlySelectedOption + ) + } + + @AssistedFactory + interface Factory { + fun create(threadId: Long): NotificationSettingsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 770ed12828..9f045fd5cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -82,7 +82,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.TitledRadioButton @@ -112,17 +111,27 @@ data class RadioOption( val enabled: Boolean = true, ) +data class OptionsCardData( + val title: GetString?, + val options: List> +) { + constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) +} + @Composable fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { Column { - Text( - modifier = Modifier.padding(start = LocalDimensions.current.smallSpacing), - text = card.title(), - style = LocalType.current.base, - color = LocalColors.current.textSecondary - ) + if (card.title != null && card.title.string().isNotEmpty()) { + Text( + modifier = Modifier.padding(start = LocalDimensions.current.smallSpacing), + text = card.title.string(), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + } Cell { LazyColumn( diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index c5f390dc01..44cb886a22 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -25,13 +25,13 @@ import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.MainCoroutineRule import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryRadioOption -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OptionsCardData import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours From 6a59d4536c0fbb90f05971a1b34e5479ca25633c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 11:43:43 +1000 Subject: [PATCH 228/867] RadioButton updates Added the ability to have an icon Changed styling Moved qa data away form accessibility data Added a min width to our buttons --- .../disappearingmessages/ui/Adapter.kt | 4 +- .../conversation/v2/ConversationV2Dialogs.kt | 16 +-- .../v2/settings/ConversationSettingsScreen.kt | 22 ++-- .../NotificationSettingsViewModel.kt | 104 ++++++++++-------- .../groups/compose/CreateGroupScreen.kt | 1 - .../groups/compose/InviteContactsScreen.kt | 1 - .../thoughtcrime/securesms/ui/Components.kt | 3 +- .../securesms/ui/components/Button.kt | 7 +- .../securesms/ui/components/RadioButton.kt | 75 ++++++++++++- app/src/main/res/drawable/ic_at_sign.xml | 7 ++ gradle/libs.versions.toml | 6 +- 11 files changed, 156 insertions(+), 90 deletions(-) create mode 100644 app/src/main/res/drawable/ic_at_sign.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt index 6ec94a9b87..6443f9e2a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -59,7 +59,7 @@ private fun State.typeOption( value = type.defaultMode(persistedMode), title = GetString(type.title), subtitle = type.subtitle?.let(::GetString), - contentDescription = GetString(type.contentDescription), + qaTag = GetString(type.contentDescription), selected = expiryType == type, enabled = enabled ) @@ -94,7 +94,7 @@ private fun State.timeOption( value = mode, title = title, subtitle = subtitle, - contentDescription = title, + qaTag = title, selected = mode.duration == expiryMode?.duration, enabled = isTimeOptionsEnabled ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt index ea57192cff..9f931e2fa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v2 -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -16,7 +15,6 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY @@ -34,7 +32,7 @@ import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -94,11 +92,7 @@ fun ConversationV2Dialogs( ) } - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), @@ -108,11 +102,7 @@ fun ConversationV2Dialogs( deleteForEveryone = false } - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(data.deleteForEveryoneLabel), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 9be343112d..ec66ec035e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -47,17 +46,18 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupDeviceOnly +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupEveryone +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupAdminClearMessagesDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel @@ -69,7 +69,7 @@ import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape @@ -382,11 +382,7 @@ fun GroupAdminClearMessagesDialog( .put(GROUP_NAME_KEY, groupName) .format()), content = { - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.clearDeviceOnly)), @@ -396,11 +392,7 @@ fun GroupAdminClearMessagesDialog( deleteForEveryone = false } - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.clearMessagesForEveryone)), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 2eb2626c58..7178e91ef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import android.content.Context -import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted @@ -15,7 +14,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.LocalisedTimeUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.RecipientDatabase @@ -36,7 +34,13 @@ class NotificationSettingsViewModel @AssistedInject constructor( ) : ViewModel(), Callbacks { private var thread: Recipient? = null - private var selectedOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs + // the options the user is currently using + private var currentOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs + private var currentMuteDuration: Long = Long.MAX_VALUE //todo UCS this should be read from last selected choice in prefs + + // the option selected on this screen + private var selectedOption: NotificationType = currentOption + private var selectedMuteDuration: Long = currentMuteDuration private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState @@ -57,30 +61,55 @@ class NotificationSettingsViewModel @AssistedInject constructor( val defaultOptions = OptionsCardData( title = null, options = listOf( - NotificationType.All.toRadioOption(selectedOption), - NotificationType.MentionsOnly.toRadioOption(selectedOption), - NotificationType.Mute.toRadioOption(selectedOption) + // All + RadioOption( + value = NotificationType.All, + title = GetString(R.string.notificationsAllMessages), + iconRes = R.drawable.ic_volume_2, + selected = selectedOption is NotificationType.All + ), + // Mentions Only + RadioOption( + value = NotificationType.MentionsOnly, + title = GetString(R.string.notificationsMentionsOnly), + iconRes = R.drawable.ic_at_sign, + selected = selectedOption is NotificationType.MentionsOnly + ), + // Mute + RadioOption( + value = NotificationType.Mute(selectedMuteDuration), + title = GetString(R.string.notificationsMute), + iconRes = R.drawable.ic_volume_off, + selected = selectedOption is NotificationType.Mute + ), ) ) val options = mutableListOf(defaultOptions) // add the mute options if necessary - if(selectedOption is NotificationType.Mute || selectedOption is NotificationType.MuteType) { + if(selectedOption is NotificationType.Mute) { options.add( - //todo UCS add actual mute options - //todo UCS figure out selection for this sub category when visible OptionsCardData( - title = null, - options = listOf( - NotificationType.All.toRadioOption(selectedOption), - NotificationType.MentionsOnly.toRadioOption(selectedOption), - NotificationType.Mute.toRadioOption(selectedOption) - ) + title = GetString(R.string.disappearingMessagesTimer), + options = muteDurations.map { + RadioOption( + value = NotificationType.Mute(it), + title = + if(it == Long.MAX_VALUE) GetString(R.string.forever) + else GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.milliseconds + ) + ), + selected = (selectedOption as? NotificationType.Mute)?.duration == it + ) + } ) ) } -//todo UCS need to add icons ro radio options + _uiState.update { UiState( cards = options, @@ -91,6 +120,10 @@ class NotificationSettingsViewModel @AssistedInject constructor( override fun setValue(value: NotificationType){ selectedOption = value + if(value is NotificationType.Mute){ + selectedMuteDuration = value.duration + } + updateState() } @@ -113,6 +146,14 @@ class NotificationSettingsViewModel @AssistedInject constructor( //conversation.setNotifyType(thread, notifyType) } + private val muteDurations = listOf( + Long.MAX_VALUE, + TimeUnit.HOURS.toMillis(1), + TimeUnit.HOURS.toMillis(2), + TimeUnit.DAYS.toMillis(1), + TimeUnit.DAYS.toMillis(7), + ) + data class UiState( val cards: List> = emptyList(), val enableButton: Boolean = false, @@ -121,36 +162,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( sealed interface NotificationType { data object All: NotificationType data object MentionsOnly: NotificationType - data object Mute: NotificationType - sealed class MuteType( - val duration: Long - ): NotificationType{ - data object Mute1H: MuteType(TimeUnit.HOURS.toMillis(1)) - data object Mute2H: MuteType(TimeUnit.HOURS.toMillis(2)) - data object Mute1Day: MuteType(TimeUnit.DAYS.toMillis(1)) - data object Mute1Week: MuteType(TimeUnit.DAYS.toMillis(7)) - data object MuteForever: MuteType(Long.MAX_VALUE) - - fun getTitleFromDuration(context: Context) = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - this.duration.milliseconds - ) - } - - fun getTitle(context: Context): String { - return when(this){ - is All -> context.getString(R.string.notificationsAllMessages) - is MentionsOnly -> context.getString(R.string.notificationsMentionsOnly) - is Mute -> context.getString(R.string.notificationsMute) - is MuteType -> getTitleFromDuration(context) - } - } - - fun toRadioOption(currentlySelectedOption: NotificationType) = RadioOption( - value = this, - title = GetString(this::getTitle), - selected = this == currentlySelectedOption - ) + data class Mute(val duration: Long): NotificationType } @AssistedFactory diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt index 1baf4eca39..5439ec314e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt @@ -190,7 +190,6 @@ fun CreateGroup( onClick = onCreateClicked, modifier = Modifier .padding(horizontal = LocalDimensions.current.spacing) - .widthIn(min = LocalDimensions.current.minButtonWidth) .qaTag(R.string.AccessibilityId_groupCreate) ) { LoadingArcOr(loading = showLoading) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 757cf42a1b..ca32f6c61f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -124,7 +124,6 @@ fun InviteContacts( onClick = onDoneClicked, modifier = Modifier .padding(vertical = LocalDimensions.current.spacing) - .defaultMinSize(minWidth = LocalDimensions.current.minButtonWidth) .qaTag(R.string.AccessibilityId_selectContactConfirm), ) { Text( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 9f045fd5cd..cd4ae186df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -106,7 +106,8 @@ data class RadioOption( val value: T, val title: GetString, val subtitle: GetString? = null, - val contentDescription: GetString = title, + @DrawableRes val iconRes: Int? = null, + val qaTag: GetString? = null, val selected: Boolean = false, val enabled: Boolean = true, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index d06c5a94d9..46ec828d0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonColors @@ -31,6 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -39,6 +41,7 @@ import network.loki.messenger.R import org.thoughtcrime.securesms.ui.LaunchedEffectAsync import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -59,6 +62,7 @@ fun Button( enabled: Boolean = true, style: ButtonStyle = ButtonStyle.Large, shape: Shape = buttonShape, + minWidth: Dp = LocalDimensions.current.minButtonWidth, border: BorderStroke? = type.border(enabled), colors: ButtonColors = type.buttonColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -68,7 +72,8 @@ fun Button( style.applyButtonConstraints { androidx.compose.material3.Button( onClick = onClick, - modifier = modifier.heightIn(min = style.minHeight), + modifier = modifier.heightIn(min = style.minHeight) + .defaultMinSize(minWidth = minWidth), enabled = enabled, interactionSource = interactionSource, elevation = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt index 1855925840..fdbaef8b02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.ui.components +import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,15 +29,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -49,6 +56,7 @@ fun RadioButton( modifier: Modifier = Modifier, selected: Boolean = false, enabled: Boolean = true, + @DrawableRes iconRes: Int? = null, contentPadding: PaddingValues = PaddingValues(), content: @Composable RowScope.() -> Unit = {} ) { @@ -67,9 +75,20 @@ fun RadioButton( shape = RectangleShape, contentPadding = contentPadding ) { + if(iconRes != null){ + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier + .size(LocalDimensions.current.iconMedium), + ) + + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + } + content() - Spacer(modifier = Modifier.width(20.dp)) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) RadioButtonIndicator( selected = selected, enabled = enabled, @@ -84,7 +103,7 @@ fun RadioButtonIndicator( selected: Boolean, enabled: Boolean, modifier: Modifier = Modifier, - size: Dp = 22.dp + size: Dp = LocalDimensions.current.iconMedium ) { Box(modifier = modifier) { AnimatedVisibility( @@ -118,6 +137,27 @@ fun RadioButtonIndicator( } } +/** + * Convenience access for a TitledRadiobutton used in dialogs + */ +@Composable +fun DialogTitledRadioButton( + modifier: Modifier = Modifier, + option: RadioOption, + onClick: () -> Unit +) { + TitledRadioButton( + modifier = modifier, + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + titleStyle = LocalType.current.large, + option = option, + onClick = onClick + ) +} + @Composable fun TitledRadioButton( modifier: Modifier = Modifier, @@ -125,15 +165,21 @@ fun TitledRadioButton( horizontal = LocalDimensions.current.spacing, vertical = LocalDimensions.current.smallSpacing ), + titleStyle: TextStyle = LocalType.current.h8, + subtitleStyle: TextStyle = LocalType.current.extraSmall, option: RadioOption, onClick: () -> Unit ) { RadioButton( - modifier = modifier - .contentDescription(option.contentDescription), + modifier = modifier.then ( + if(option.qaTag != null) + Modifier.qaTag(option.qaTag.string()) + else Modifier + ), onClick = onClick, selected = option.selected, enabled = option.enabled, + iconRes = option.iconRes, contentPadding = contentPadding, content = { Column( @@ -143,12 +189,12 @@ fun TitledRadioButton( ) { Text( text = option.title(), - style = LocalType.current.large + style = titleStyle ) option.subtitle?.let { Text( text = it(), - style = LocalType.current.extraSmall + style = subtitleStyle ) } } @@ -172,6 +218,23 @@ fun PreviewTextRadioButton() { } } +@Preview +@Composable +fun PreviewTextIconRadioButton() { + PreviewTheme { + TitledRadioButton( + option = RadioOption( + value = ExpiryType.AFTER_SEND.mode(7.days), + title = GetString(7.days), + subtitle = GetString("This is a subtitle"), + iconRes = R.drawable.ic_users_group_custom, + enabled = true, + selected = true + ) + ) {} + } +} + @Preview @Composable fun PreviewDisabledTextRadioButton() { diff --git a/app/src/main/res/drawable/ic_at_sign.xml b/app/src/main/res/drawable/ic_at_sign.xml new file mode 100644 index 0000000000..751ebf96ef --- /dev/null +++ b/app/src/main/res/drawable/ic_at_sign.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de8aeb7627..373df71be7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ assertjCoreVersion = "3.11.1" biometricVersion = "1.1.0" cameraCamera2Version = "1.3.2" cardviewVersion = "1.0.0" -composeBomVersion = "2025.04.01" +composeBomVersion = "2025.05.00" composeVersion = "1.0.0-beta01" conscryptAndroidVersion = "2.5.2" constraintlayoutVersion = "2.2.1" @@ -39,12 +39,10 @@ libsessionUtilAndroidVersion = "1.0.4" lifecycleExtensionsVersion = "2.2.0" media3ExoplayerVersion = "1.4.0" mockitoCoreVersion = "5.17.0" -navVersion = "2.8.9" +navVersion = "2.9.0" appcompatVersion = "1.7.0" coreVersion = "1.16.0-rc01" coroutinesVersion = "1.9.0" -curve25519Version = "0.6.0" -jetpackHiltVersion = "1.2.0" daggerHiltVersion = "2.55" androidxHiltVersion = "1.2.0" glideVersion = "4.16.0" From 1b5746cd8070c883995c7dfdc7a59458ffb65621 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 11:48:04 +1000 Subject: [PATCH 229/867] Enable/Disable set button if choice is the currently set one --- .../notification/NotificationSettingsViewModel.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 7178e91ef8..e74a3929b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -36,11 +36,11 @@ class NotificationSettingsViewModel @AssistedInject constructor( // the options the user is currently using private var currentOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs - private var currentMuteDuration: Long = Long.MAX_VALUE //todo UCS this should be read from last selected choice in prefs + private var currentMuteDuration: Long? = null //todo UCS this should be read from last selected choice in prefs // the option selected on this screen private var selectedOption: NotificationType = currentOption - private var selectedMuteDuration: Long = currentMuteDuration + private var selectedMuteDuration: Long? = currentMuteDuration private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState @@ -77,7 +77,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( ), // Mute RadioOption( - value = NotificationType.Mute(selectedMuteDuration), + value = NotificationType.Mute(selectedMuteDuration ?: Long.MAX_VALUE), title = GetString(R.string.notificationsMute), iconRes = R.drawable.ic_volume_off, selected = selectedOption is NotificationType.Mute @@ -113,11 +113,18 @@ class NotificationSettingsViewModel @AssistedInject constructor( _uiState.update { UiState( cards = options, - enableButton = true //todo UCS calculate this properly + enableButton = shouldEnableSetButton() ) } } + private fun shouldEnableSetButton(): Boolean { + return when{ + selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMuteDuration + else -> selectedOption != currentOption + } + } + override fun setValue(value: NotificationType){ selectedOption = value if(value is NotificationType.Mute){ From 0fb89127d626023504cded9031fc418d4d89150c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 15:34:48 +1000 Subject: [PATCH 230/867] Reworking notification settings setup --- .../NotificationSettingsScreen.kt | 26 +++--- .../NotificationSettingsViewModel.kt | 91 ++++++++++++------- 2 files changed, 70 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt index 153daf2773..8a40fe4884 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -57,7 +57,7 @@ fun NotificationSettingsScreen( @Composable fun NotificationSettings( state: NotificationSettingsViewModel.UiState, - callbacks: Callbacks = NoOpCallbacks, + callbacks: Callbacks = NoOpCallbacks, onBack: () -> Unit ) { //todo UCS add test tags Scaffold( @@ -79,13 +79,15 @@ fun NotificationSettings( ) { Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - state.cards.forEachIndexed { index, option -> - OptionsCard(option, callbacks) + // notification options + if(state.notificationTypes != null) { + OptionsCard(state.notificationTypes, callbacks) + } - // add spacing if not the last item - if (index != state.cards.lastIndex) { - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - } + // mute types + if(state.muteTypes != null) { + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + OptionsCard(state.muteTypes, callbacks) } Spacer(modifier = Modifier.height(bottomContentPadding)) @@ -111,8 +113,7 @@ fun PreviewNotificationSettings(){ PreviewTheme { NotificationSettings( state = NotificationSettingsViewModel.UiState( - cards = listOf( - OptionsCardData( + notificationTypes = OptionsCardData( title = null, options = listOf( RadioOption( @@ -127,21 +128,20 @@ fun PreviewNotificationSettings(){ ), ) ), - OptionsCardData( + muteTypes = OptionsCardData( title = GetString("Other Options"), options = listOf( RadioOption( - value = NotificationSettingsViewModel.NotificationType.All, + value = Long.MAX_VALUE, title = GetString("Something"), selected = false ), RadioOption( - value = NotificationSettingsViewModel.NotificationType.All, + value = Long.MAX_VALUE, title = GetString("Something Else"), selected = false ), ) - ) ), enableButton = true ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index e74a3929b8..32df064b2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -17,6 +17,8 @@ import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_MENTIONS +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString @@ -31,16 +33,18 @@ class NotificationSettingsViewModel @AssistedInject constructor( @ApplicationContext private val context: Context, private val recipientDatabase: RecipientDatabase, private val repository: ConversationRepository, -) : ViewModel(), Callbacks { +) : ViewModel(), Callbacks { private var thread: Recipient? = null + private val durationForever: Long = Long.MAX_VALUE + // the options the user is currently using private var currentOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs - private var currentMuteDuration: Long? = null //todo UCS this should be read from last selected choice in prefs + private var currentMutedUntil: Long? = null //todo UCS this should be read from last selected choice in prefs // the option selected on this screen - private var selectedOption: NotificationType = currentOption - private var selectedMuteDuration: Long? = currentMuteDuration + private var selectedOption: NotificationType = NotificationType.All + private var selectedMuteDuration: Long? = durationForever private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState @@ -51,6 +55,15 @@ class NotificationSettingsViewModel @AssistedInject constructor( repository.recipientUpdateFlow(threadId).collect { thread = it + // update the user's current choice of notification + currentMutedUntil = it?.mutedUntil + + currentOption = when{ + currentMutedUntil != null -> NotificationType.Mute + it?.notifyType == NOTIFY_TYPE_MENTIONS -> NotificationType.MentionsOnly + else -> NotificationType.All + } + updateState() } } @@ -77,7 +90,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( ), // Mute RadioOption( - value = NotificationType.Mute(selectedMuteDuration ?: Long.MAX_VALUE), + value = NotificationType.Mute, title = GetString(R.string.notificationsMute), iconRes = R.drawable.ic_volume_off, selected = selectedOption is NotificationType.Mute @@ -85,62 +98,63 @@ class NotificationSettingsViewModel @AssistedInject constructor( ) ) - val options = mutableListOf(defaultOptions) + var muteOptions: OptionsCardData? = null // add the mute options if necessary if(selectedOption is NotificationType.Mute) { - options.add( - OptionsCardData( + muteOptions = OptionsCardData( title = GetString(R.string.disappearingMessagesTimer), options = muteDurations.map { RadioOption( - value = NotificationType.Mute(it), + value = it, title = - if(it == Long.MAX_VALUE) GetString(R.string.forever) + if(it == durationForever) GetString(R.string.forever) else GetString( LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( context, it.milliseconds ) ), - selected = (selectedOption as? NotificationType.Mute)?.duration == it + selected = false //todo UCS calculate this properly ) } ) - ) } _uiState.update { UiState( - cards = options, + notificationTypes = defaultOptions, + muteTypes = muteOptions, enableButton = shouldEnableSetButton() ) } } private fun shouldEnableSetButton(): Boolean { - return when{ + return true //todo UCS implement this properly + /*return when{ selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMuteDuration else -> selectedOption != currentOption - } + }*/ } - override fun setValue(value: NotificationType){ - selectedOption = value - if(value is NotificationType.Mute){ - selectedMuteDuration = value.duration + override fun onSetClick() = viewModelScope.launch { + //todo UCS implement + } + + override fun setValue(value: Any) { + when(value){ + is Long -> selectedMuteDuration = value + + is NotificationType -> selectedOption = value } updateState() } - override fun onSetClick() = viewModelScope.launch { - //todo UCS implement - } - private fun unmute(context: Context) { val conversation = thread ?: return - recipientDatabase.setMuted(conversation, 0) + // recipientDatabase.setMuted(conversation, 0) } private fun mute(context: Context) { @@ -153,25 +167,34 @@ class NotificationSettingsViewModel @AssistedInject constructor( //conversation.setNotifyType(thread, notifyType) } - private val muteDurations = listOf( - Long.MAX_VALUE, - TimeUnit.HOURS.toMillis(1), - TimeUnit.HOURS.toMillis(2), - TimeUnit.DAYS.toMillis(1), - TimeUnit.DAYS.toMillis(7), - ) - data class UiState( - val cards: List> = emptyList(), + val notificationTypes: OptionsCardData? = null, + val muteTypes: OptionsCardData? = null, val enableButton: Boolean = false, ) sealed interface NotificationType { data object All: NotificationType data object MentionsOnly: NotificationType - data class Mute(val duration: Long): NotificationType + data object Mute: NotificationType } + private val muteDurations = listOf( + durationForever, + TimeUnit.HOURS.toMillis(1), + TimeUnit.HOURS.toMillis(2), + TimeUnit.DAYS.toMillis(1), + TimeUnit.DAYS.toMillis(7), + ) + +/* sealed class MuteDuration(val duration: Long) { + data object Forever: MuteDuration(Long.MAX_VALUE) + data object OneHour: MuteDuration(TimeUnit.HOURS.toMillis(1)) + data object TwoHours: MuteDuration(TimeUnit.HOURS.toMillis(2)) + data object OneDay: MuteDuration(TimeUnit.DAYS.toMillis(1)) + data object OneWeek: MuteDuration(TimeUnit.DAYS.toMillis(7)) + }*/ + @AssistedFactory interface Factory { fun create(threadId: Long): NotificationSettingsViewModel From 9082136162dcf3163d278d036740c7db259087df Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 8 May 2025 15:42:51 +1000 Subject: [PATCH 231/867] Add a bottom spacer view for convo screen (#1141) --- .../conversation/v2/ConversationActivityV2.kt | 44 ++++++------------- .../res/layout/activity_conversation_v2.xml | 14 ++++-- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index c63f91440d..75924e826f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -610,6 +610,20 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, setupMentionView() setupUiEventsObserver() + setupWindowInsets() + } + + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> + val systemBarsInsets = + windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()) + + binding.bottomSpacer.updateLayoutParams { + height = systemBarsInsets.bottom + } + + windowInsets.inset(systemBarsInsets) + } } private fun setupUiEventsObserver() { @@ -761,36 +775,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, .lastSeenMessageId .collectLatest { adapter.lastSentMessageId = it } } - - // Apply insets on the recycler view or input bar depending on their visibility - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBarsInsets = - windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()) - - val viewToApplyPaddings: View - val viewToResetPaddings: View - - if (binding.inputBar.isGone) { - viewToApplyPaddings = binding.conversationRecyclerView - viewToResetPaddings = binding.inputBar - } else { - viewToApplyPaddings = binding.inputBar - viewToResetPaddings = binding.conversationRecyclerView - } - - // Update view padding to account for system bars - viewToApplyPaddings.updatePadding( - left = systemBarsInsets.left, - top = systemBarsInsets.top, - right = systemBarsInsets.right, - bottom = systemBarsInsets.bottom - ) - viewToResetPaddings.updatePadding(0, 0, 0, 0) - - windowInsets.inset(systemBarsInsets) - } - - binding.root.requestApplyInsets() } private fun scrollToMostRecentMessageIfWeShould() { diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 092ed7fb19..44947edc9a 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -63,7 +63,7 @@ android:background="?input_bar_background" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/recreateGroupButtonContainer" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" app:layout_constraintVertical_bias="1" /> @@ -71,7 +71,7 @@ android:id="@+id/searchBottomBar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" android:visibility="gone"/> + app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" /> + + Date: Thu, 8 May 2025 16:10:29 +1000 Subject: [PATCH 232/867] Use lazy on ApplicationContext (#1142) --- .../securesms/ApplicationContext.kt | 176 +++++++++++------- .../securesms/BaseActionBarActivity.kt | 2 +- .../securesms/ScreenLockActionBarActivity.kt | 2 +- .../conversation/v2/ConversationActivityV2.kt | 12 +- .../notifications/MarkReadReceiver.kt | 2 +- .../PrivacySettingsPreferenceFragment.kt | 7 +- .../securesms/service/ExpirationListener.java | 2 +- .../thoughtcrime/securesms/ui/theme/Themes.kt | 2 +- 8 files changed, 126 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 9d5e938da3..b670620cec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -144,72 +144,72 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, private var conversationListHandler: Handler? = null lateinit var persistentLogger: PersistentLogger - @Inject lateinit var workerFactory: HiltWorkerFactory - @Inject lateinit var lokiAPIDatabase: LokiAPIDatabase - @Inject lateinit var storage: Storage - @Inject lateinit var device: Device - @Inject lateinit var messageDataProvider: MessageDataProvider - @Inject lateinit var textSecurePreferences: TextSecurePreferences - @Inject lateinit var configFactory: ConfigFactory - @Inject lateinit var versionDataFetcher: VersionDataFetcher - @Inject lateinit var pushRegistrationHandler: PushRegistrationHandler - @Inject lateinit var tokenFetcher: TokenFetcher - @Inject lateinit var groupManagerV2: GroupManagerV2 - @Inject lateinit var profileManager: ProfileManagerProtocol - @Inject lateinit var callMessageProcessor: CallMessageProcessor + @Inject lateinit var workerFactory: Lazy + @Inject lateinit var lokiAPIDatabase: Lazy + @Inject lateinit var storage: Lazy + @Inject lateinit var device: Lazy + @Inject lateinit var messageDataProvider: Lazy + @Inject lateinit var textSecurePreferences: Lazy + @Inject lateinit var configFactory: Lazy + @Inject lateinit var versionDataFetcher: Lazy + @Inject lateinit var pushRegistrationHandler: Lazy + @Inject lateinit var tokenFetcher: Lazy + @Inject lateinit var groupManagerV2: Lazy + @Inject lateinit var profileManager: Lazy + @Inject lateinit var callMessageProcessor: Lazy private var messagingModuleConfiguration: MessagingModuleConfiguration? = null - @Inject lateinit var configUploader: ConfigUploader - @Inject lateinit var adminStateSync: AdminStateSync - @Inject lateinit var destroyedGroupSync: DestroyedGroupSync - @Inject lateinit var removeGroupMemberHandler: RemoveGroupMemberHandler // Exists here only to start upon app starts - @Inject lateinit var snodeClock: SnodeClock - @Inject lateinit var migrationManager: DatabaseMigrationManager - @Inject lateinit var appDisguiseManager: AppDisguiseManager + @Inject lateinit var configUploader: Lazy + @Inject lateinit var adminStateSync: Lazy + @Inject lateinit var destroyedGroupSync: Lazy + @Inject lateinit var removeGroupMemberHandler: Lazy // Exists here only to start upon app starts + @Inject lateinit var snodeClock: Lazy + @Inject lateinit var migrationManager: Lazy + @Inject lateinit var appDisguiseManager: Lazy @get:Deprecated(message = "Use proper DI to inject this component") @Inject - lateinit var expiringMessageManager: ExpiringMessageManager + lateinit var expiringMessageManager: Lazy @get:Deprecated(message = "Use proper DI to inject this component") @Inject - lateinit var typingStatusRepository: TypingStatusRepository + lateinit var typingStatusRepository: Lazy @get:Deprecated(message = "Use proper DI to inject this component") @Inject - lateinit var typingStatusSender: TypingStatusSender + lateinit var typingStatusSender: Lazy @get:Deprecated(message = "Use proper DI to inject this component") @Inject - lateinit var readReceiptManager: ReadReceiptManager + lateinit var readReceiptManager: Lazy @Inject lateinit var messageNotifierLazy: Lazy - @Inject lateinit var apiDB: LokiAPIDatabase - @Inject lateinit var emojiSearchDb: EmojiSearchDatabase - @Inject lateinit var webRtcCallBridge: WebRtcCallBridge - @Inject lateinit var legacyClosedGroupPollerV2: LegacyClosedGroupPollerV2 - @Inject lateinit var legacyGroupDeprecationManager: LegacyGroupDeprecationManager - @Inject lateinit var cleanupInvitationHandler: CleanupInvitationHandler - @Inject lateinit var usernameUtils: UsernameUtils + @Inject lateinit var apiDB: Lazy + @Inject lateinit var emojiSearchDb: Lazy + @Inject lateinit var webRtcCallBridge: Lazy + @Inject lateinit var legacyClosedGroupPollerV2: Lazy + @Inject lateinit var legacyGroupDeprecationManager: Lazy + @Inject lateinit var cleanupInvitationHandler: Lazy + @Inject lateinit var usernameUtils: Lazy @Inject - lateinit var backgroundPollManager: BackgroundPollManager // Exists here only to start upon app starts + lateinit var backgroundPollManager: Lazy // Exists here only to start upon app starts @Inject - lateinit var appVisibilityManager: AppVisibilityManager // Exists here only to start upon app starts + lateinit var appVisibilityManager: Lazy // Exists here only to start upon app starts @Inject - lateinit var groupPollerManager: GroupPollerManager // Exists here only to start upon app starts + lateinit var groupPollerManager: Lazy // Exists here only to start upon app starts @Inject - lateinit var expiredGroupManager: ExpiredGroupManager // Exists here only to start upon app starts + lateinit var expiredGroupManager: Lazy // Exists here only to start upon app starts @Volatile var isAppVisible: Boolean = false override val workManagerConfiguration: Configuration get() = Configuration.Builder() - .setWorkerFactory(workerFactory) + .setWorkerFactory(workerFactory.get()) .build() override fun getSystemService(name: String): Any? { @@ -275,18 +275,18 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, messagingModuleConfiguration = MessagingModuleConfiguration( this, - storage, - device, - messageDataProvider, - configFactory, + storage.get(), + device.get(), + messageDataProvider.get(), + configFactory.get(), this, - tokenFetcher, - groupManagerV2, - snodeClock, - textSecurePreferences, - legacyClosedGroupPollerV2, - legacyGroupDeprecationManager, - usernameUtils + tokenFetcher.get(), + groupManagerV2.get(), + snodeClock.get(), + textSecurePreferences.get(), + legacyClosedGroupPollerV2.get(), + legacyGroupDeprecationManager.get(), + usernameUtils.get() ) startKovenant() @@ -297,11 +297,11 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, ProcessLifecycleOwner.get().lifecycle.addObserver(this) configureKovenant() broadcaster = Broadcaster(this) - val useTestNet = textSecurePreferences.getEnvironment() == Environment.TEST_NET - configure(apiDB, broadcaster!!, useTestNet) + val useTestNet = textSecurePreferences.get().getEnvironment() == Environment.TEST_NET + configure(apiDB.get(), broadcaster!!, useTestNet) configure( - typingStatusRepository, readReceiptManager, profileManager, - messageNotifier, expiringMessageManager + typingStatusRepository.get(), readReceiptManager.get(), profileManager.get(), + messageNotifier, expiringMessageManager.get() ) initializeWebRtc() initializeBlobProvider() @@ -312,15 +312,15 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, val networkConstraint = NetworkConstraint.Factory(this).create() isConnectedToNetwork = { networkConstraint.isMet } - snodeClock.start() - pushRegistrationHandler.run() - configUploader.start() - destroyedGroupSync.start() - adminStateSync.start() - cleanupInvitationHandler.start() + snodeClock.get().start() + pushRegistrationHandler.get().run() + configUploader.get().start() + destroyedGroupSync.get().start() + adminStateSync.get().start() + cleanupInvitationHandler.get().start() // Start our migration process as early as possible so we can show the user a progress UI - migrationManager.requestMigration(fromRetry = false) + migrationManager.get().requestMigration(fromRetry = false) // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { @@ -337,6 +337,46 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, ShortcutManagerCompat.pushDynamicShortcut(this, shortcut) } + + + // Once we have done initialisation, access the lazy dependencies so we make sure + // they are initialised. + workerFactory.get() + lokiAPIDatabase.get() + storage.get() + device.get() + messageDataProvider.get() + textSecurePreferences.get() + configFactory.get() + versionDataFetcher.get() + pushRegistrationHandler.get() + tokenFetcher.get() + groupManagerV2.get() + profileManager.get() + callMessageProcessor.get() + configUploader.get() + adminStateSync.get() + destroyedGroupSync.get() + removeGroupMemberHandler.get() + snodeClock.get() + migrationManager.get() + appDisguiseManager.get() + expiringMessageManager.get() + typingStatusRepository.get() + typingStatusSender.get() + readReceiptManager.get() + messageNotifierLazy.get() + apiDB.get() + emojiSearchDb.get() + webRtcCallBridge.get() + legacyClosedGroupPollerV2.get() + legacyGroupDeprecationManager.get() + cleanupInvitationHandler.get() + usernameUtils.get() + backgroundPollManager.get() + appVisibilityManager.get() + groupPollerManager.get() + expiredGroupManager.get() } override fun onStart(owner: LifecycleOwner) { @@ -346,7 +386,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, // If the user account hasn't been created or onboarding wasn't finished then don't start // the pollers - if (textSecurePreferences.getLocalNumber() == null) { + if (textSecurePreferences.get().getLocalNumber() == null) { return } @@ -358,7 +398,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, } // fetch last version data - versionDataFetcher.startTimedVersionCheck() + versionDataFetcher.get().startTimedVersionCheck() } override fun onStop(owner: LifecycleOwner) { @@ -369,14 +409,14 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, if (poller != null) { poller!!.stopIfNeeded() } - legacyClosedGroupPollerV2.stopAll() - versionDataFetcher.stopTimedVersionCheck() + legacyClosedGroupPollerV2.get().stopAll() + versionDataFetcher.get().stopTimedVersionCheck() } override fun onTerminate() { stopKovenant() // Loki stopPolling() - versionDataFetcher.stopTimedVersionCheck() + versionDataFetcher.get().stopTimedVersionCheck() super.onTerminate() } @@ -442,9 +482,9 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, private class ProviderInitializationException : RuntimeException() private fun setUpPollingIfNeeded() { - val userPublicKey = textSecurePreferences.getLocalNumber() ?: return + val userPublicKey = textSecurePreferences.get().getLocalNumber() ?: return if (poller == null) { - poller = Poller(configFactory, storage, lokiAPIDatabase, prefs) + poller = Poller(configFactory.get(), storage.get(), lokiAPIDatabase.get(), prefs) } } @@ -453,7 +493,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, if (poller != null) { poller!!.startIfNeeded() } - legacyClosedGroupPollerV2.start() + legacyClosedGroupPollerV2.get().start() } fun retrieveUserProfile() { @@ -469,7 +509,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, private fun loadEmojiSearchIndexIfNeeded() { Executors.newSingleThreadExecutor().execute { - if (emojiSearchDb.query("face", 1).isEmpty()) { + if (emojiSearchDb.get().query("face", 1).isEmpty()) { try { assets.open("emoji/emoji_search_index.json").use { inputStream -> val searchIndex = Arrays.asList( @@ -478,7 +518,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Array::class.java ) ) - emojiSearchDb.setSearchIndex(searchIndex) + emojiSearchDb.get().setSearchIndex(searchIndex) } } catch (e: IOException) { Log.e( diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt index f3ed175f08..6afb6d8eef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt @@ -30,7 +30,7 @@ abstract class BaseActionBarActivity : AppCompatActivity() { // This can not be dep injected as it is required very early during activity creation private val preferences: TextSecurePreferences - get() = (applicationContext as ApplicationContext).textSecurePreferences + get() = (applicationContext as ApplicationContext).textSecurePreferences.get() // Whether to apply default window insets to the decor view open val applyDefaultWindowInsets: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt index a8d66d3b3c..deb73ddf9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt @@ -35,7 +35,7 @@ import javax.inject.Inject abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { private val migrationManager: DatabaseMigrationManager - get() = (applicationContext as ApplicationContext).migrationManager + get() = (applicationContext as ApplicationContext).migrationManager.get() companion object { private val TAG = ScreenLockActionBarActivity::class.java.simpleName diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 75924e826f..4cc735f8d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -118,6 +118,7 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.contacts.SelectContactsToInviteToGroupActivity.Companion.SELECTED_CONTACTS_KEY import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate @@ -189,6 +190,7 @@ import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.FilenameUtils @@ -250,6 +252,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory @Inject lateinit var configFactory: ConfigFactory @Inject lateinit var groupManagerV2: GroupManagerV2 + @Inject lateinit var typingStatusRepository: TypingStatusRepository + @Inject lateinit var typingStatusSender: TypingStatusSender override val applyDefaultWindowInsets: Boolean get() = false @@ -887,7 +891,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // called from onCreate private fun setUpTypingObserver() { - ApplicationContext.getInstance(this).typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> + typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> val recipients = if (state != null) state.typists else listOf() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up @@ -897,7 +901,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } if (textSecurePreferences.isTypingIndicatorsEnabled()) { binding.inputBar.addTextChangedListener { - ApplicationContext.getInstance(this).typingStatusSender.onTypingStarted(viewModel.threadId) + typingStatusSender.onTypingStarted(viewModel.threadId) } } } @@ -2001,7 +2005,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, MessageSender.send(message, recipient.address) } // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } @@ -2070,7 +2074,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index bcba2bb763..f6b2478a1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -160,7 +160,7 @@ class MarkReadReceiver : BroadcastReceiver() { db.markExpireStarted(expirationInfo.id, now) } - ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion( + ApplicationContext.getInstance(context).expiringMessageManager.get().scheduleDeletion( expirationInfo.id, expirationInfo.isMms, now, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index 6c6826166f..a4e255408b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -18,12 +18,12 @@ import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswordDisabled import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.areNotificationsEnabled import org.thoughtcrime.securesms.util.IntentUtils @@ -36,6 +36,9 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var textSecurePreferences: TextSecurePreferences + @Inject + lateinit var typingStatusRepository: TypingStatusRepository + override fun onCreate(paramBundle: Bundle?) { super.onCreate(paramBundle) findPreference(TextSecurePreferences.SCREEN_LOCK)!! @@ -168,7 +171,7 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { val enabled = newValue as Boolean if (!enabled) { - ApplicationContext.getInstance(requireContext()).typingStatusRepository.clear() + typingStatusRepository.clear() } return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java index e4d3b12a12..2556e4b990 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java @@ -12,7 +12,7 @@ public class ExpirationListener extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule(); + ApplicationContext.getInstance(context).expiringMessageManager.get().checkSchedule(); } public static void setAlarm(Context context, long waitTimeMillis) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 177512b6e7..1726953784 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -35,7 +35,7 @@ fun invalidateComposeThemeColors() { @Composable fun SessionMaterialTheme( preferences: TextSecurePreferences = - (LocalContext.current.applicationContext as ApplicationContext).textSecurePreferences, + (LocalContext.current.applicationContext as ApplicationContext).textSecurePreferences.get(), content: @Composable () -> Unit ) { val cachedColors = cachedColorsProvider ?: preferences.getColorsProvider().also { cachedColorsProvider = it } From 945733d5a77e94a90649ccde72a024e5fd4c7d17 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 16:11:49 +1000 Subject: [PATCH 233/867] More notification settings logic --- .../NotificationSettingsViewModel.kt | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 32df064b2f..b56ca76b25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -24,6 +24,8 @@ import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds @@ -44,7 +46,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( // the option selected on this screen private var selectedOption: NotificationType = NotificationType.All - private var selectedMuteDuration: Long? = durationForever + private var selectedMuteDuration: Long? = null private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState @@ -57,13 +59,19 @@ class NotificationSettingsViewModel @AssistedInject constructor( // update the user's current choice of notification currentMutedUntil = it?.mutedUntil + val hasMutedUntil = currentMutedUntil != null && currentMutedUntil != 0L currentOption = when{ - currentMutedUntil != null -> NotificationType.Mute + hasMutedUntil -> NotificationType.Mute it?.notifyType == NOTIFY_TYPE_MENTIONS -> NotificationType.MentionsOnly else -> NotificationType.All } + // set our default selection to those + selectedOption = currentOption + // default selection for mute is either our custom "Muted Until" or "Forever" if nothing is pre picked + selectedMuteDuration = if(hasMutedUntil) currentMutedUntil else durationForever + updateState() } } @@ -102,22 +110,42 @@ class NotificationSettingsViewModel @AssistedInject constructor( // add the mute options if necessary if(selectedOption is NotificationType.Mute) { + val muteRadioOptions = mutableListOf>() + + // if the user is currently "muting until", and that muting is not forever, + // then add a new option that specifies how much longer the mute is on for + if(currentMutedUntil != null && currentMutedUntil!! > 0L && + currentMutedUntil!! < System.currentTimeMillis() + TimeUnit.DAYS.toMillis(14)){ // more than two weeks from now means forever + muteRadioOptions.add( + RadioOption( + value = currentMutedUntil!!, + title = GetString("Muted Until: ${DateUtils.getFormattedDateTime(currentMutedUntil!!, "HH:mm dd/MM/yy", Locale.getDefault())}"), //todo UCS need the crowdin string + selected = selectedMuteDuration == currentMutedUntil + ) + ) + } + + // add the regular options + muteRadioOptions.addAll( + muteDurations.map { + RadioOption( + value = it, + title = + if(it == durationForever) GetString(R.string.forever) + else GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.milliseconds + ) + ), + selected = selectedMuteDuration == it + ) + } + ) + muteOptions = OptionsCardData( title = GetString(R.string.disappearingMessagesTimer), - options = muteDurations.map { - RadioOption( - value = it, - title = - if(it == durationForever) GetString(R.string.forever) - else GetString( - LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - it.milliseconds - ) - ), - selected = false //todo UCS calculate this properly - ) - } + options = muteRadioOptions ) } @@ -131,11 +159,10 @@ class NotificationSettingsViewModel @AssistedInject constructor( } private fun shouldEnableSetButton(): Boolean { - return true //todo UCS implement this properly - /*return when{ - selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMuteDuration + return when{ + selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMutedUntil else -> selectedOption != currentOption - }*/ + } } override fun onSetClick() = viewModelScope.launch { @@ -187,14 +214,6 @@ class NotificationSettingsViewModel @AssistedInject constructor( TimeUnit.DAYS.toMillis(7), ) -/* sealed class MuteDuration(val duration: Long) { - data object Forever: MuteDuration(Long.MAX_VALUE) - data object OneHour: MuteDuration(TimeUnit.HOURS.toMillis(1)) - data object TwoHours: MuteDuration(TimeUnit.HOURS.toMillis(2)) - data object OneDay: MuteDuration(TimeUnit.DAYS.toMillis(1)) - data object OneWeek: MuteDuration(TimeUnit.DAYS.toMillis(7)) - }*/ - @AssistedFactory interface Factory { fun create(threadId: Long): NotificationSettingsViewModel From 845dbd661b0887eeada65dd9d52d7d3fe813337a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 16:45:36 +1000 Subject: [PATCH 234/867] More notification settings --- .../NotificationSettingsViewModel.kt | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index b56ca76b25..143ba4afa4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import android.content.Context +import android.content.Intent +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -15,15 +18,22 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_MENTIONS import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE +import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.DateUtils import java.util.Locale import java.util.concurrent.TimeUnit @@ -35,14 +45,15 @@ class NotificationSettingsViewModel @AssistedInject constructor( @ApplicationContext private val context: Context, private val recipientDatabase: RecipientDatabase, private val repository: ConversationRepository, + private val navigator: ConversationSettingsNavigator, ) : ViewModel(), Callbacks { private var thread: Recipient? = null private val durationForever: Long = Long.MAX_VALUE // the options the user is currently using - private var currentOption: NotificationType = NotificationType.All //todo UCS this should be read from last selected choice in prefs - private var currentMutedUntil: Long? = null //todo UCS this should be read from last selected choice in prefs + private var currentOption: NotificationType = NotificationType.All + private var currentMutedUntil: Long? = null // the option selected on this screen private var selectedOption: NotificationType = NotificationType.All @@ -166,7 +177,36 @@ class NotificationSettingsViewModel @AssistedInject constructor( } override fun onSetClick() = viewModelScope.launch { - //todo UCS implement + when(selectedOption){ + is NotificationType.All, is NotificationType.MentionsOnly -> { + unmute() + setNotifyType(selectedOption.notifyType) + + } + else -> { + if(selectedMuteDuration != null) { + mute(System.currentTimeMillis() + selectedMuteDuration!!) + + // also show a toast in this case + val toastString = if(selectedMuteDuration == durationForever) { + context.getString(R.string.notificationsMuted) + } else { + context.getSubbedString( + R.string.notificationsMutedFor, + TIME_LARGE_KEY to LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + selectedMuteDuration!!.milliseconds + ) + ) + } + + Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() + } + } + } + + // navigate back to the conversation settings + navigator.navigateUp() } override fun setValue(value: Any) { @@ -179,19 +219,19 @@ class NotificationSettingsViewModel @AssistedInject constructor( updateState() } - private fun unmute(context: Context) { + private fun unmute() { val conversation = thread ?: return - // recipientDatabase.setMuted(conversation, 0) + recipientDatabase.setMuted(conversation, 0) } - private fun mute(context: Context) { + private fun mute(until: Long) { val conversation = thread ?: return - //conversation.setMuted(thread, until) + recipientDatabase.setMuted(conversation, until) } - private fun setNotifyType(context: Context) { + private fun setNotifyType(notifyType: Int) { val conversation = thread ?: return - //conversation.setNotifyType(thread, notifyType) + recipientDatabase.setNotifyType(conversation, notifyType) } data class UiState( @@ -200,10 +240,10 @@ class NotificationSettingsViewModel @AssistedInject constructor( val enableButton: Boolean = false, ) - sealed interface NotificationType { - data object All: NotificationType - data object MentionsOnly: NotificationType - data object Mute: NotificationType + sealed class NotificationType(val notifyType: Int) { + data object All: NotificationType(NOTIFY_TYPE_ALL) + data object MentionsOnly: NotificationType(NOTIFY_TYPE_MENTIONS) + data object Mute: NotificationType(NOTIFY_TYPE_NONE) } private val muteDurations = listOf( From 689e911c228ceb285350190d6abf052eea84675d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 16:59:24 +1000 Subject: [PATCH 235/867] Fixed logic --- .../settings/ConversationSettingsViewModel.kt | 2 +- .../NotificationSettingsViewModel.kt | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index dadbe9fa15..1a10b466c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -249,7 +249,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) ) } - +//todo UCS need qa tags for subtitles apparently... (disappearing messages and notifications) conversation.is1on1 -> { val mainOptions = mutableListOf() val dangerOptions = mutableListOf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 143ba4afa4..285236aa0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -70,7 +70,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( // update the user's current choice of notification currentMutedUntil = it?.mutedUntil - val hasMutedUntil = currentMutedUntil != null && currentMutedUntil != 0L + val hasMutedUntil = currentMutedUntil != null && currentMutedUntil!! > 0L currentOption = when{ hasMutedUntil -> NotificationType.Mute @@ -184,24 +184,24 @@ class NotificationSettingsViewModel @AssistedInject constructor( } else -> { - if(selectedMuteDuration != null) { - mute(System.currentTimeMillis() + selectedMuteDuration!!) - - // also show a toast in this case - val toastString = if(selectedMuteDuration == durationForever) { - context.getString(R.string.notificationsMuted) - } else { - context.getSubbedString( - R.string.notificationsMutedFor, - TIME_LARGE_KEY to LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - selectedMuteDuration!!.milliseconds - ) + val muteDuration = selectedMuteDuration ?: return@launch + + mute(if(muteDuration == durationForever) muteDuration else System.currentTimeMillis() + muteDuration) + + // also show a toast in this case + val toastString = if(muteDuration == durationForever) { + context.getString(R.string.notificationsMuted) + } else { + context.getSubbedString( + R.string.notificationsMutedFor, + TIME_LARGE_KEY to LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + muteDuration.milliseconds ) - } - - Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() + ) } + + Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() } } From cea5eff6bd303231b917ad9a03ad56d0be3df462 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 May 2025 17:19:20 +1000 Subject: [PATCH 236/867] Notification settings styling --- .../conversation/v2/ConversationViewModel.kt | 12 ++++++------ .../settings/ConversationSettingsViewModel.kt | 18 +++++++++++++----- .../securesms/home/ConversationView.kt | 2 +- .../ui/components/ConversationAppBar.kt | 1 + .../res/drawable/ic_notifications_mentions.xml | 15 --------------- 5 files changed, 21 insertions(+), 27 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_notifications_mentions.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 3e9be490d2..8065fb3ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.ReactionDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.GroupThreadStatus import org.thoughtcrime.securesms.database.model.MessageId @@ -385,15 +386,14 @@ class ConversationViewModel( ) } - if (conversation.isMuted) { + //todo UCS check with team if we want this to have a value for all notifications + if (conversation.isMuted || conversation.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { pagerData += ConversationAppBarPagerData( - title = conversation.mutedUntil.takeUnless { it == Long.MAX_VALUE } - ?.let { application.getString(R.string.notificationsMuted) } - ?: application.getString(R.string.notificationsMuted), + title = if(conversation.isMuted) application.getString(R.string.notificationsHeaderMute) + else application.getString(R.string.notificationsHeaderMentionsOnly), action = { //todo UCS take user to new mute screen (old code had no click action for this) - }, - R.drawable.ic_volume_off + } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 1a10b466c4..236830f2ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -56,6 +56,7 @@ import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity @@ -214,6 +215,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( val pinned = threadDb.isPinned(threadId) + val (notificationIconRes, notificationSubtitle) = when{ + conversation.isMuted -> R.drawable.ic_volume_off to context.getString(R.string.notificationsMuted) + conversation.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS -> + R.drawable.ic_at_sign to context.getString(R.string.notificationsMentionsOnly) + else -> R.drawable.ic_volume_2 to context.getString(R.string.notificationsAllMessages) + } + // organise the setting options val optionData = options@when { conversation.isLocalNumber -> { @@ -259,7 +267,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( optionSearch, optionDisappearingMessage(disappearingSubtitle), if(pinned) optionUnpin else optionPin, - optionNotifications(null), //todo UCS notifications logic + optionNotifications(notificationIconRes, notificationSubtitle), optionAttachments, )) @@ -311,7 +319,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( mainOptions.addAll( listOf( if (pinned) optionUnpin else optionPin, - optionNotifications(null), //todo UCS notifications logic + optionNotifications(notificationIconRes, notificationSubtitle), optionGroupMembers, optionAttachments, ) @@ -384,7 +392,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( optionCopyCommunityURL, optionSearch, if(pinned) optionUnpin else optionPin, - optionNotifications(null), //todo UCS notifications logic + optionNotifications(notificationIconRes, notificationSubtitle), optionInviteMembers, optionAttachments, )) @@ -985,11 +993,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } - private fun optionNotifications(subtitle: String?): OptionsItem { + private fun optionNotifications(iconRes: Int, subtitle: String?): OptionsItem { return OptionsItem( name = context.getString(R.string.sessionNotifications), subtitle = subtitle, - icon = R.drawable.ic_volume_2, + icon = iconRes, qaTag = R.string.qa_conversation_settings_notifications, onClick = { navigateTo(ConversationSettingsDestination.RouteNotifications) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index ab05ab5a49..2c2b36b20e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -93,7 +93,7 @@ class ConversationView : LinearLayout { val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { R.drawable.ic_volume_off } else { - R.drawable.ic_notifications_mentions + R.drawable.ic_at_sign } binding.muteIndicatorImageView.setImageResource(drawableRes) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index c04dc7882f..13a573bb40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -61,6 +61,7 @@ fun ConversationAppBar( onCallPressed: () -> Unit, onAvatarPressed: () -> Unit ) { + //todo UCS need to sort out the centering when multiple actions val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) CenterAlignedTopAppBar( diff --git a/app/src/main/res/drawable/ic_notifications_mentions.xml b/app/src/main/res/drawable/ic_notifications_mentions.xml deleted file mode 100644 index c078377a5a..0000000000 --- a/app/src/main/res/drawable/ic_notifications_mentions.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - From 9210fac4061c4f2d23faa0c478184269c8defea2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 09:42:58 +1000 Subject: [PATCH 237/867] comment --- .../securesms/conversation/v2/ConversationViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 8065fb3ba1..3a8d38170a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -386,7 +386,6 @@ class ConversationViewModel( ) } - //todo UCS check with team if we want this to have a value for all notifications if (conversation.isMuted || conversation.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { pagerData += ConversationAppBarPagerData( title = if(conversation.isMuted) application.getString(R.string.notificationsHeaderMute) From 93fa747f68b75d2f21705354376d6eeca02ae385 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 11:30:58 +1000 Subject: [PATCH 238/867] Using qaTag instead of contentDescription in Compose --- .../ui/DisappearingMessages.kt | 4 ++-- .../start/home/StartConversation.kt | 12 ++++++------ .../start/invitefriend/InviteFriend.kt | 6 +++--- .../conversation/start/newmessage/NewMessage.kt | 5 ++--- .../v2/settings/ConversationSettingsScreen.kt | 1 + .../notification/NotificationSettingsScreen.kt | 10 ++-------- .../thoughtcrime/securesms/home/SeedReminder.kt | 4 ++-- .../securesms/onboarding/landing/Landing.kt | 8 ++++---- .../securesms/onboarding/loading/Loading.kt | 6 +++--- .../MessageNotifications.kt | 14 +++++++------- .../onboarding/pickname/PickDisplayName.kt | 5 ++--- .../securesms/onboarding/ui/ContinueButton.kt | 6 +++--- .../securesms/preferences/QRCodeActivity.kt | 9 ++++----- .../securesms/preferences/SettingsActivity.kt | 17 ++++++++--------- .../recoverypassword/RecoveryPassword.kt | 10 +++++----- .../java/org/thoughtcrime/securesms/ui/Util.kt | 5 ++++- .../securesms/ui/components/Button.kt | 4 ++-- .../securesms/ui/components/Text.kt | 4 ++-- 18 files changed, 62 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index 1f70297c1a..2d94f419ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.ui.components.AppBarBackIcon import org.thoughtcrime.securesms.ui.components.AppBarText import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.appBarColors -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -115,7 +115,7 @@ fun DisappearingMessages( PrimaryOutlineButton( stringResource(R.string.set), modifier = Modifier - .contentDescription(R.string.AccessibilityId_setButton) + .qaTag(R.string.AccessibilityId_setButton) .align(Alignment.CenterHorizontally) .padding(bottom = LocalDimensions.current.spacing), onClick = callbacks::onSetClick diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt index 42a6f0b6d5..334ba60f54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BasicAppBar import org.thoughtcrime.securesms.ui.components.QrImage -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -66,27 +66,27 @@ internal fun StartConversationScreen( ItemButton( text = newMessageTitleTxt, icon = R.drawable.ic_message_square, - modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew), + modifier = Modifier.qaTag(R.string.AccessibilityId_messageNew), onClick = delegate::onNewMessageSelected) Divider(startIndent = LocalDimensions.current.minItemButtonHeight) ItemButton( textId = R.string.groupCreate, icon = R.drawable.ic_users_group_custom, - modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate), + modifier = Modifier.qaTag(R.string.AccessibilityId_groupCreate), onClick = delegate::onCreateGroupSelected ) Divider(startIndent = LocalDimensions.current.minItemButtonHeight) ItemButton( textId = R.string.communityJoin, icon = R.drawable.ic_globe, - modifier = Modifier.contentDescription(R.string.AccessibilityId_communityJoin), + modifier = Modifier.qaTag(R.string.AccessibilityId_communityJoin), onClick = delegate::onJoinCommunitySelected ) Divider(startIndent = LocalDimensions.current.minItemButtonHeight) ItemButton( textId = R.string.sessionInviteAFriend, icon = R.drawable.ic_user_round_plus, - Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriendButton), + Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriendButton), onClick = delegate::onInviteFriend ) Column( @@ -105,7 +105,7 @@ internal fun StartConversationScreen( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) QrImage( string = accountId, - Modifier.contentDescription(R.string.AccessibilityId_qrCode), + Modifier.qaTag(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt index bc298c5bd3..d8c4db07b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -26,7 +26,7 @@ import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.border -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -58,7 +58,7 @@ internal fun InviteFriend( Text( accountId, modifier = Modifier - .contentDescription(R.string.AccessibilityId_shareAccountId) + .qaTag(R.string.AccessibilityId_shareAccountId) .fillMaxWidth() .border() .padding(LocalDimensions.current.spacing), @@ -86,7 +86,7 @@ internal fun InviteFriend( stringResource(R.string.share), modifier = Modifier .weight(1f) - .contentDescription("Share button"), + .qaTag("Share button"), onClick = sendInvitation ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index 3312627a14..3fdaa2c3d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -118,7 +117,7 @@ private fun EnterAccountId( BorderlessButtonWithIcon( text = stringResource(R.string.messageNewDescriptionMobile), modifier = Modifier - .contentDescription(R.string.AccessibilityId_messageNewDescriptionMobile) + .qaTag(R.string.AccessibilityId_messageNewDescriptionMobile) .padding(horizontal = LocalDimensions.current.mediumSpacing) .fillMaxWidth(), style = LocalType.current.small, @@ -136,7 +135,7 @@ private fun EnterAccountId( .padding(horizontal = LocalDimensions.current.xlargeSpacing) .padding(bottom = LocalDimensions.current.smallSpacing) .fillMaxWidth() - .contentDescription(R.string.next), + .qaTag(R.string.next), enabled = state.isNextButtonEnabled, onClick = callbacks::onContinue ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index ec66ec035e..f63f8929f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -373,6 +373,7 @@ fun GroupAdminClearMessagesDialog( val context = LocalContext.current AlertDialog( + modifier = modifier, onDismissRequest = { // hide dialog sendCommand(HideGroupAdminClearMessagesDialog) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt index 8a40fe4884..3b4d45fe45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -4,14 +4,12 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -19,8 +17,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox @@ -32,10 +28,8 @@ import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme @@ -97,7 +91,7 @@ fun NotificationSettings( PrimaryOutlineButton( stringResource(R.string.set), modifier = Modifier - .contentDescription(R.string.AccessibilityId_setButton) + .qaTag(R.string.AccessibilityId_setButton) .align(Alignment.CenterHorizontally) .padding(bottom = LocalDimensions.current.spacing), enabled = state.enableButton, diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt index 5e9f59fa2d..c484f70820 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import org.thoughtcrime.securesms.ui.SessionShieldIcon import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -65,7 +65,7 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { text = stringResource(R.string.theContinue), modifier = Modifier .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_recoveryPasswordBanner), + .qaTag(R.string.AccessibilityId_recoveryPasswordBanner), onClick = startRecoveryPasswordActivity ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index 14eb2dbe06..875bd042b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -45,7 +45,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.components.PrimaryFillButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -169,7 +169,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_onboardingAccountCreate), + .qaTag(R.string.AccessibilityId_onboardingAccountCreate), onClick = createAccount ) Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -178,7 +178,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_onboardingAccountExists), + .qaTag(R.string.AccessibilityId_onboardingAccountExists), onClick = loadAccount ) BorderlessHtmlButton( @@ -186,7 +186,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_urlOpenBrowser), + .qaTag(R.string.AccessibilityId_urlOpenBrowser), onClick = { isUrlDialogVisible = true } ) Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt index 001562c7ff..0a0ae1355f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt @@ -9,9 +9,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.ProgressArc -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @Composable @@ -20,7 +20,7 @@ internal fun LoadingScreen(progress: Float) { Spacer(modifier = Modifier.weight(1f)) ProgressArc( progress, - modifier = Modifier.contentDescription(R.string.AccessibilityId_loadAccountProgressMessage) + modifier = Modifier.qaTag(R.string.AccessibilityId_loadAccountProgressMessage) ) Text( stringResource(R.string.waitOneMoment), diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 2ce667cc57..6b19dc131d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -25,15 +25,15 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel.UiState import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.RadioButton +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator -import org.thoughtcrime.securesms.ui.components.RadioButton -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalType @Composable internal fun MessageNotificationsScreen( @@ -79,7 +79,7 @@ internal fun MessageNotificationsScreen( R.string.notificationsFastMode, if(BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei else R.string.notificationsFastModeDescription, - modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsFastMode), + modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsFastMode), tag = R.string.recommended, checked = state.pushEnabled, onClick = { setEnabled(true) } @@ -94,7 +94,7 @@ internal fun MessageNotificationsScreen( NotificationRadioButton( stringResource(R.string.notificationsSlowMode), explanationTxt, - modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsSlowMode), + modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsSlowMode), checked = state.pushDisabled, onClick = { setEnabled(false) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt index b265ff5c03..2b0f4e89df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -17,12 +17,11 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme @Preview @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt index 51a5d0f35b..aaf67f75b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt @@ -6,16 +6,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions @Composable fun ContinuePrimaryOutlineButton(modifier: Modifier, onContinue: () -> Unit) { PrimaryOutlineButton( stringResource(R.string.theContinue), modifier = modifier - .contentDescription(R.string.AccessibilityId_theContinue) + .qaTag(R.string.AccessibilityId_theContinue) .fillMaxWidth() .padding(horizontal = LocalDimensions.current.xlargeSpacing) .padding(bottom = LocalDimensions.current.smallSpacing), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index db300a6bd7..b7025ff526 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -26,13 +25,13 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.threadDatabase import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.util.start @@ -106,7 +105,7 @@ fun QrPage(string: String) { string = string, modifier = Modifier .padding(top = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.xsSpacing) - .contentDescription(R.string.AccessibilityId_qrCode), + .qaTag(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 85003b9ae2..e09097b14a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -98,7 +98,6 @@ import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape import org.thoughtcrime.securesms.ui.qaTag @@ -508,22 +507,22 @@ class SettingsActivity : ScreenLockActionBarActivity() { LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole) { push() } Divider() - LargeItemButton(R.string.sessionNotifications, R.drawable.ic_volume_2, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { push() } + LargeItemButton(R.string.sessionNotifications, R.drawable.ic_volume_2, Modifier.qaTag(R.string.AccessibilityId_notifications)) { push() } Divider() - LargeItemButton(R.string.sessionConversations, R.drawable.ic_message_square, Modifier.contentDescription(R.string.AccessibilityId_sessionConversations)) { push() } + LargeItemButton(R.string.sessionConversations, R.drawable.ic_message_square, Modifier.qaTag(R.string.AccessibilityId_sessionConversations)) { push() } Divider() - LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_square_warning, Modifier.contentDescription(R.string.AccessibilityId_sessionMessageRequests)) { push() } + LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_square_warning, Modifier.qaTag(R.string.AccessibilityId_sessionMessageRequests)) { push() } Divider() - LargeItemButton(R.string.sessionAppearance, R.drawable.ic_paintbrush_vertical, Modifier.contentDescription(R.string.AccessibilityId_sessionAppearance)) { push() } + LargeItemButton(R.string.sessionAppearance, R.drawable.ic_paintbrush_vertical, Modifier.qaTag(R.string.AccessibilityId_sessionAppearance)) { push() } Divider() LargeItemButton( R.string.sessionInviteAFriend, R.drawable.ic_user_round_plus, - Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriend) + Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriend) ) { sendInvitationToUseSession() } Divider() @@ -532,7 +531,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { LargeItemButton( R.string.sessionRecoveryPassword, R.drawable.ic_recovery_password_custom, - Modifier.contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) + Modifier.qaTag(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) ) { hideRecoveryLauncher.launch(Intent(baseContext, RecoveryPasswordActivity::class.java)) overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) @@ -540,13 +539,13 @@ class SettingsActivity : ScreenLockActionBarActivity() { Divider() } - LargeItemButton(R.string.sessionHelp, R.drawable.ic_question_custom, Modifier.contentDescription(R.string.AccessibilityId_help)) { push() } + LargeItemButton(R.string.sessionHelp, R.drawable.ic_question_custom, Modifier.qaTag(R.string.AccessibilityId_help)) { push() } Divider() LargeItemButton( textId = R.string.sessionClearData, icon = R.drawable.ic_trash_2, - modifier = Modifier.contentDescription(R.string.AccessibilityId_sessionClearData), + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionClearData), colors = dangerButtonColors(), shape = getCellBottomShape() ) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt index fef1bb62d5..c91a04f554 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.recoverypassword import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -36,6 +35,7 @@ import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.border import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -54,7 +54,7 @@ internal fun RecoveryPasswordScreen( Column( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), modifier = Modifier - .contentDescription(R.string.AccessibilityId_sessionRecoveryPassword) + .qaTag(R.string.AccessibilityId_sessionRecoveryPassword) .verticalScroll(rememberScrollState()) .padding(bottom = LocalDimensions.current.smallSpacing) .padding(horizontal = LocalDimensions.current.spacing) @@ -106,7 +106,7 @@ private fun RecoveryPasswordCell( seed, modifier = Modifier .padding(vertical = LocalDimensions.current.spacing) - .contentDescription(R.string.AccessibilityId_qrCode), + .qaTag(R.string.AccessibilityId_qrCode), contentPadding = 10.dp, icon = R.drawable.ic_recovery_password_custom ) @@ -143,7 +143,7 @@ private fun RecoveryPassword(mnemonic: String) { Text( mnemonic, modifier = Modifier - .contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordContainer) + .qaTag(R.string.AccessibilityId_sessionRecoveryPasswordContainer) .padding(vertical = LocalDimensions.current.spacing) .border() .padding(LocalDimensions.current.spacing), @@ -182,7 +182,7 @@ private fun HideRecoveryPasswordCell( modifier = Modifier .wrapContentWidth() .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_recoveryPasswordHideRecoveryPassword), + .qaTag(R.string.AccessibilityId_recoveryPasswordHideRecoveryPassword), color = LocalColors.current.danger, onClick = { showHideRecoveryDialog = true } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index 0f942e5cd9..36c2fa222b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -101,7 +101,10 @@ fun Modifier.qaTag(tag: String?): Modifier { } @Composable -fun Modifier.qaTag(@StringRes tagResId: Int) = semantics { testTagsAsResourceId = true }.testTag(stringResource(tagResId)) +fun Modifier.qaTag(@StringRes tagResId: Int?): Modifier { + if (tagResId == null) return this + return this.semantics { testTagsAsResourceId = true }.testTag(stringResource(tagResId)) +} /** * helper function to observe flows as events properly diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index 46ec828d0b..9d8a0975d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import network.loki.messenger.R import org.thoughtcrime.securesms.ui.LaunchedEffectAsync -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -182,7 +182,7 @@ fun OutlineCopyButton( val interactionSource = remember { MutableInteractionSource() } Button( - modifier = modifier.contentDescription(R.string.AccessibilityId_copy), + modifier = modifier.qaTag(R.string.AccessibilityId_copy), interactionSource = interactionSource, style = style, type = ButtonType.Outline(color), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 9f3994ce75..69fc986da0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -137,7 +137,7 @@ fun SessionOutlinedTextField( it, modifier = Modifier .fillMaxWidth() - .contentDescription(R.string.AccessibilityId_theError), + .qaTag(R.string.AccessibilityId_theError), textAlign = TextAlign.Center, style = LocalType.current.base.bold(), color = LocalColors.current.danger From 2baefaad92cbc43a8e2b9037d82a678f7198a935 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 11:41:58 +1000 Subject: [PATCH 239/867] QA tags for settings buttons --- .../v2/settings/ConversationSettingsScreen.kt | 2 ++ .../settings/ConversationSettingsViewModel.kt | 5 ++++- .../org/thoughtcrime/securesms/ui/Components.kt | 17 ++++++++++++++++- .../src/main/res/values/strings.xml | 1 + 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index f63f8929f8..cfb9377af8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -343,8 +343,10 @@ fun ConversationSettingsSubCategory( Column { data.items.forEachIndexed { index, option -> LargeItemButton( + modifier = Modifier.qaTag(option.qaTag), text = option.name, subtitle = option.subtitle, + subtitleQaTag = option.subtitleQaTag, icon = option.icon, shape = when (index) { 0 -> getCellTopShape() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 236830f2ea..ce20f5ad5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -257,7 +257,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) ) } -//todo UCS need qa tags for subtitles apparently... (disappearing messages and notifications) +//todo UCS make group members items tappable conversation.is1on1 -> { val mainOptions = mutableListOf() val dangerOptions = mutableListOf() @@ -969,6 +969,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( subtitle = subtitle, icon = R.drawable.ic_timer, qaTag = R.string.qa_conversation_settings_disappearing, + subtitleQaTag = R.string.qa_conversation_settings_disappearing_sub, onClick = { navigateTo(ConversationSettingsDestination.RouteDisappearingMessages) } @@ -999,6 +1000,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( subtitle = subtitle, icon = iconRes, qaTag = R.string.qa_conversation_settings_notifications, + subtitleQaTag = R.string.qa_conversation_settings_notifications_sub, onClick = { navigateTo(ConversationSettingsDestination.RouteNotifications) } @@ -1200,6 +1202,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( @DrawableRes val icon: Int, @StringRes val qaTag: Int? = null, val subtitle: String? = null, + @StringRes val subtitleQaTag: Int? = null, val onClick: () -> Unit ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index cd4ae186df..863b66ea4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -153,6 +153,7 @@ fun LargeItemButtonWithDrawable( @DrawableRes icon: Int, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -160,6 +161,7 @@ fun LargeItemButtonWithDrawable( ItemButtonWithDrawable( textId, icon, modifier, subtitle = subtitle, + subtitleQaTag = subtitleQaTag, textStyle = LocalType.current.h8, colors = colors, shape = shape, @@ -173,6 +175,7 @@ fun ItemButtonWithDrawable( @DrawableRes icon: Int, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, @@ -191,6 +194,8 @@ fun ItemButtonWithDrawable( ) }, textStyle = textStyle, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, colors = colors, shape = shape, onClick = onClick @@ -203,6 +208,7 @@ fun LargeItemButton( @DrawableRes icon: Int, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -212,6 +218,7 @@ fun LargeItemButton( icon = icon, modifier = modifier, subtitle = subtitle, + subtitleQaTag = subtitleQaTag, minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, @@ -226,6 +233,7 @@ fun LargeItemButton( @DrawableRes icon: Int, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -235,6 +243,7 @@ fun LargeItemButton( icon = icon, modifier = modifier, subtitle = subtitle, + subtitleQaTag = subtitleQaTag, minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, @@ -249,6 +258,7 @@ fun ItemButton( icon: Int, modifier: Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, subtitleStyle: TextStyle = LocalType.current.small, @@ -260,6 +270,7 @@ fun ItemButton( text = text, modifier = modifier, subtitle = subtitle, + subtitleQaTag = subtitleQaTag, icon = { Icon( painter = painterResource(id = icon), @@ -285,6 +296,7 @@ fun ItemButton( @DrawableRes icon: Int, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, subtitleStyle: TextStyle = LocalType.current.small, @@ -296,6 +308,7 @@ fun ItemButton( text = stringResource(textId), modifier = modifier, subtitle = subtitle, + subtitleQaTag = subtitleQaTag, icon = { Icon( painter = painterResource(id = icon), @@ -323,6 +336,7 @@ fun ItemButton( icon: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, subtitleStyle: TextStyle = LocalType.current.small, @@ -359,7 +373,8 @@ fun ItemButton( subtitle?.let { Text( text = it, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() + .qaTag(subtitleQaTag), style = subtitleStyle, ) } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 61b32b83d3..16b1f91d56 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -97,6 +97,7 @@ disappearing-messages-timer-details-menu-option pin-conversation-menu-option notifications-menu-option + notifications-details-menu-option attachments-menu-option block-user-menu-option clear-all-messages-menu-option From df48c216b60b96d88aa789399f872020d0d417ec Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 12:02:20 +1000 Subject: [PATCH 240/867] Using a local version of formatting until we get network page code --- .../notification/NotificationSettingsViewModel.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 285236aa0a..641a11ae7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -35,6 +35,9 @@ import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.DateUtils +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds @@ -130,7 +133,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( muteRadioOptions.add( RadioOption( value = currentMutedUntil!!, - title = GetString("Muted Until: ${DateUtils.getFormattedDateTime(currentMutedUntil!!, "HH:mm dd/MM/yy", Locale.getDefault())}"), //todo UCS need the crowdin string + title = GetString("Muted Until: ${formatTime(currentMutedUntil!!)}"), //todo UCS need the crowdin string selected = selectedMuteDuration == currentMutedUntil ) ) @@ -169,6 +172,15 @@ class NotificationSettingsViewModel @AssistedInject constructor( } } + //todo UCS update with date utils functions once we have the code from the network page + private fun formatTime(timestamp: Long): String{ + val formatter = DateTimeFormatter.ofPattern("HH:mm dd/MM/yy") + + return Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .format(formatter) + } + private fun shouldEnableSetButton(): Boolean { return when{ selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMutedUntil From 404dbbc7fe17b5d54f2cb480ae6d6a40faef71be Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Fri, 9 May 2025 02:04:23 +0000 Subject: [PATCH 241/867] [Automated] Update translations from Crowdin --- libsession/src/main/res/values/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index b9642e6662..c303f2090e 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -50,6 +50,8 @@ +{count} Anonymous App Icon + Change App Icon and Name + Changing the app icon and name requires Session to be closed. Notifications will continue to use the default Session icon and name. Alternate app icon and name is displayed on home screen and app drawer. Icon and name Alternate app icon is displayed on home screen and app library. App name will still appear as \'{app_name}\'. @@ -225,6 +227,7 @@ Are you sure you want to clear all Note to Self messages on this device? Clear on this device Close + Close App Close Window Commit Hash: {hash} This will ban the selected user from this Community and delete all their messages. Are you sure you want to continue? @@ -712,6 +715,7 @@ Unmute Muted Muted for {time_large} + Muted until {date_time} Slow Mode {app_name} will occasionally check for new messages in the background. Sound From 6379413f3312ca770fb774b188eeb8e8bfd71c05 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 12:15:31 +1000 Subject: [PATCH 242/867] test tags for notification settings items --- .../NotificationSettingsScreen.kt | 2 +- .../NotificationSettingsViewModel.kt | 30 +++++++++---------- .../securesms/ui/components/RadioButton.kt | 6 +--- .../src/main/res/values/strings.xml | 9 ++++++ 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt index 3b4d45fe45..445542a468 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -53,7 +53,7 @@ fun NotificationSettings( state: NotificationSettingsViewModel.UiState, callbacks: Callbacks = NoOpCallbacks, onBack: () -> Unit -) { //todo UCS add test tags +) { Scaffold( topBar = { BackAppBar( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 641a11ae7e..9c7f65451a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -1,11 +1,9 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import android.content.Context -import android.content.Intent import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,27 +16,22 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_MENTIONS import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE -import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.util.DateUtils import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds @@ -101,6 +94,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( value = NotificationType.All, title = GetString(R.string.notificationsAllMessages), iconRes = R.drawable.ic_volume_2, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_all), selected = selectedOption is NotificationType.All ), // Mentions Only @@ -108,6 +102,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( value = NotificationType.MentionsOnly, title = GetString(R.string.notificationsMentionsOnly), iconRes = R.drawable.ic_at_sign, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_mentions), selected = selectedOption is NotificationType.MentionsOnly ), // Mute @@ -115,6 +110,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( value = NotificationType.Mute, title = GetString(R.string.notificationsMute), iconRes = R.drawable.ic_volume_off, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_mute), selected = selectedOption is NotificationType.Mute ), ) @@ -134,6 +130,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( RadioOption( value = currentMutedUntil!!, title = GetString("Muted Until: ${formatTime(currentMutedUntil!!)}"), //todo UCS need the crowdin string + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_muted_until), selected = selectedMuteDuration == currentMutedUntil ) ) @@ -143,16 +140,17 @@ class NotificationSettingsViewModel @AssistedInject constructor( muteRadioOptions.addAll( muteDurations.map { RadioOption( - value = it, + value = it.first, title = - if(it == durationForever) GetString(R.string.forever) + if(it.first == durationForever) GetString(R.string.forever) else GetString( LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( context, - it.milliseconds + it.first.milliseconds ) ), - selected = selectedMuteDuration == it + qaTag = GetString(it.second), + selected = selectedMuteDuration == it.first ) } ) @@ -259,11 +257,11 @@ class NotificationSettingsViewModel @AssistedInject constructor( } private val muteDurations = listOf( - durationForever, - TimeUnit.HOURS.toMillis(1), - TimeUnit.HOURS.toMillis(2), - TimeUnit.DAYS.toMillis(1), - TimeUnit.DAYS.toMillis(7), + durationForever to R.string.qa_conversation_settings_notifications_radio_forever, + TimeUnit.HOURS.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1h, + TimeUnit.HOURS.toMillis(2) to R.string.qa_conversation_settings_notifications_radio_2h, + TimeUnit.DAYS.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1d, + TimeUnit.DAYS.toMillis(7) to R.string.qa_conversation_settings_notifications_radio_1w, ) @AssistedFactory diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt index fdbaef8b02..99b8b98aa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -171,11 +171,7 @@ fun TitledRadioButton( onClick: () -> Unit ) { RadioButton( - modifier = modifier.then ( - if(option.qaTag != null) - Modifier.qaTag(option.qaTag.string()) - else Modifier - ), + modifier = modifier.qaTag(option.qaTag?.string()), onClick = onClick, selected = option.selected, enabled = option.enabled, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 16b1f91d56..b7de3488f8 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -98,6 +98,15 @@ pin-conversation-menu-option notifications-menu-option notifications-details-menu-option + notifications-all-messages-radio-button + notifications-mentions-only-radio-button + notifications-mute-radio-button + notifications-muted-until-radio-button + notifications-forever-time-option + notifications-one-hour-time-option + notifications-two-hours-time-option + notifications-one-day-time-option + notifications-one-week-time-option attachments-menu-option block-user-menu-option clear-all-messages-menu-option From 6822e206b107cf3fd85c2e92fcfa0e9f1df8a79c Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 9 May 2025 13:20:28 +1000 Subject: [PATCH 243/867] UI tweaks for app disguise settings (#1140) * Adjustment * Remove unnecessary change * Tidy up * Remove unnecessary change * Update min height * Extending call timeout to match other platforms * Crowdin strings * Making sure the close button is set to danger color --------- Co-authored-by: ThomasSession --- .../securesms/ScreenLockActionBarActivity.kt | 9 +-- .../components/SwitchPreferenceCompat.kt | 59 +++++++++++++------ .../securesms/disguise/AppDisguiseManager.kt | 44 ++++++-------- .../appearance/AppDisguiseSettings.kt | 37 +++++++++--- .../AppDisguiseSettingsViewModel.kt | 52 +++++++++++++--- .../appearance/AppearanceSettingsActivity.kt | 20 ++++--- .../securesms/ui/components/SessionSwitch.kt | 12 ++-- .../securesms/webrtc/WebRtcCallBridge.kt | 2 +- .../layout/activity_appearance_settings.xml | 10 ++-- .../res/layout/switch_compat_preference.xml | 13 ++-- 10 files changed, 164 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt index deb73ddf9a..87f221eade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt @@ -12,11 +12,6 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import java.io.FileOutputStream -import java.lang.Exception -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -30,7 +25,9 @@ import org.thoughtcrime.securesms.onboarding.landing.LandingActivity import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.FilenameUtils -import javax.inject.Inject +import java.io.File +import java.io.FileOutputStream +import java.util.Locale abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt index 9161dd828d..f8c7e38a94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt @@ -2,33 +2,29 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet -import androidx.preference.CheckBoxPreference -import com.squareup.phrase.Phrase +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.preference.PreferenceViewHolder +import androidx.preference.TwoStatePreference +import kotlinx.coroutines.flow.MutableStateFlow import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.ui.components.SessionSwitch import org.thoughtcrime.securesms.ui.getSubbedCharSequence -import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.ui.setThemedContent -class SwitchPreferenceCompat : CheckBoxPreference { +class SwitchPreferenceCompat : TwoStatePreference { private var listener: OnPreferenceClickListener? = null - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr) { - setLayoutRes() - } - - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context!!, attrs, defStyleAttr, defStyleRes) { - setLayoutRes() - } - - constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { - setLayoutRes() - } + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context) : super(context) - constructor(context: Context?) : super(context!!) { - setLayoutRes() - } + private val checkState = MutableStateFlow(isChecked) + private val enableState = MutableStateFlow(isEnabled) - private fun setLayoutRes() { + init { widgetLayoutResource = R.layout.switch_compat_preference if (this.hasKey()) { @@ -43,6 +39,31 @@ class SwitchPreferenceCompat : CheckBoxPreference { } } + override fun setChecked(checked: Boolean) { + super.setChecked(checked) + + checkState.value = checked + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + enableState.value = enabled + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val composeView = holder.findViewById(R.id.compose_preference) as ComposeView + composeView.setThemedContent { + SessionSwitch( + checked = checkState.collectAsState().value, + onCheckedChange = null, + enabled = isEnabled + ) + } + } + override fun setOnPreferenceClickListener(listener: OnPreferenceClickListener?) { this.listener = listener } diff --git a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt index c83cf0d168..31cdb28d32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt @@ -105,38 +105,28 @@ class AppDisguiseManager @Inject constructor( } all.map { alias -> - // Set the state to enabled or disabled based on the selected alias, - // and also taking the default state into account. This is trying to - // not change the state if the default is sufficient. - val state = when { - alias === enabledAlias && alias.defaultEnabled -> { - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - } - - alias === enabledAlias -> { - PackageManager.COMPONENT_ENABLED_STATE_ENABLED - } - - alias.defaultEnabled -> { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - } - - else -> { - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - } - } + val state = if (alias === enabledAlias) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED ComponentName(application, alias.activityAliasName) to state } }.collectLatest { all -> - all.forEach { (name, state) -> - Log.d(TAG, "Set state $name: $state") - - application.packageManager.setComponentEnabledSetting( - name, - state, - PackageManager.DONT_KILL_APP + if (android.os.Build.VERSION.SDK_INT >= 33) { + application.packageManager.setComponentEnabledSettings( + all.map { (name, state) -> + PackageManager.ComponentEnabledSetting( + name, state, PackageManager.DONT_KILL_APP or PackageManager.SYNCHRONOUS + ) + } ) + } else { + all.forEach { (name, state) -> + application.packageManager.setComponentEnabledSetting( + name, + state, + PackageManager.DONT_KILL_APP + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index e8e5c8fa59..659233a914 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -42,7 +42,10 @@ import androidx.compose.ui.unit.dp import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.toBitmap import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.SessionSwitch import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -61,10 +64,10 @@ fun AppDisguiseSettingsScreen( ) { AppDisguiseSettings( onBack = onBack, - setOn = viewModel::setOn, isOn = viewModel.isOn.collectAsState().value, items = viewModel.alternativeIcons.collectAsState().value, - onItemSelected = viewModel::onIconSelected + dialogState = viewModel.confirmDialogState.collectAsState().value, + onCommand = viewModel::onCommand, ) } @@ -73,9 +76,9 @@ fun AppDisguiseSettingsScreen( private fun AppDisguiseSettings( items: List, isOn: Boolean, - setOn: (Boolean) -> Unit, - onItemSelected: (String) -> Unit, + dialogState: AppDisguiseSettingsViewModel.ConfirmDialogState, onBack: () -> Unit, + onCommand: (AppDisguiseSettingsViewModel.Command) -> Unit, ) { Scaffold( topBar = { @@ -98,7 +101,9 @@ private fun AppDisguiseSettings( Cell { Row( modifier = Modifier - .toggleable(value = isOn, onValueChange = setOn) + .toggleable(value = isOn, onValueChange = { + onCommand(AppDisguiseSettingsViewModel.Command.ToggleClicked(it)) + }) .padding(LocalDimensions.current.xsSpacing), verticalAlignment = Alignment.CenterVertically, ) { @@ -150,7 +155,7 @@ private fun AppDisguiseSettings( icon = item.icon, name = item.name, selected = item.selected, - onSelected = { onItemSelected(item.id) }, + onSelected = { onCommand(AppDisguiseSettingsViewModel.Command.IconSelected(item.id)) }, modifier = Modifier.weight(1f) ) } @@ -169,9 +174,23 @@ private fun AppDisguiseSettings( ) } } - } } + + if (dialogState.showDialog) { + AlertDialog( + onDismissRequest = { onCommand(AppDisguiseSettingsViewModel.Command.IconSelectDismissed) }, + text = stringResource(R.string.appIconAndNameChangeConfirmation), + title = stringResource(R.string.appIconAndNameChange), + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.closeApp), + color = LocalColors.current.danger, + ) { onCommand(AppDisguiseSettingsViewModel.Command.IconSelectConfirmed(dialogState.id)) }, + DialogButtonModel(text = GetString(R.string.cancel), dismissOnClick = true) + ) + ) + } } private const val ICON_ITEM_SIZE_DP = 90 @@ -287,9 +306,9 @@ private fun AppDisguiseSettingsPreview( ), ), isOn = true, - setOn = { }, - onItemSelected = { }, onBack = { }, + dialogState = AppDisguiseSettingsViewModel.ConfirmDialogState(null, false), + onCommand = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt index 7bc5229a0f..81ca1cdd7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -39,16 +40,42 @@ class AppDisguiseSettingsViewModel @Inject constructor( initialValue = emptyList() ) - fun onIconSelected(id: String) { - manager.setOn(true) - manager.setSelectedAliasName(id) - } + private val mutableConfirmDialogState = MutableStateFlow(ConfirmDialogState(null, false)) + val confirmDialogState: StateFlow get() = mutableConfirmDialogState + + fun onCommand(command: Command) { + when (command) { + is Command.IconSelectConfirmed -> { + mutableConfirmDialogState.value = ConfirmDialogState(null, false) + if (command.id == null) { + manager.setOn(false) + } else { + manager.setOn(true) + manager.setSelectedAliasName(command.id) + } + } + + Command.IconSelectDismissed -> { + mutableConfirmDialogState.value = ConfirmDialogState(null, false) + } + + is Command.IconSelected -> { + if (!isOn.value || command.id != manager.selectedAppAliasName.value) { + mutableConfirmDialogState.value = ConfirmDialogState( + id = command.id, + showDialog = true + ) + } + } - fun setOn(on: Boolean) { - manager.setOn(on) + is Command.ToggleClicked -> { + if (isOn.value == command.on) return - if (manager.selectedAppAliasName.value == null) { - manager.setSelectedAliasName(alternativeIcons.value.firstOrNull()?.id) + mutableConfirmDialogState.value = ConfirmDialogState( + id = if (command.on) manager.selectedAppAliasName.value ?: alternativeIcons.value.firstOrNull()?.id else null, + showDialog = true + ) + } } } @@ -58,4 +85,13 @@ class AppDisguiseSettingsViewModel @Inject constructor( @StringRes val name: Int, val selected: Boolean, ) + + data class ConfirmDialogState(val id: String?, val showDialog: Boolean) + + sealed interface Command { + data class IconSelected(val id: String) : Command + data class IconSelectConfirmed(val id: String?) : Command + data object IconSelectDismissed : Command + data class ToggleClicked(val on: Boolean) : Command + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index bf6642e68e..641568a831 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -3,9 +3,11 @@ package org.thoughtcrime.securesms.preferences.appearance import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.se.omapi.Session import android.util.SparseArray import android.view.View import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState import androidx.core.view.children import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint @@ -17,6 +19,8 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_ import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.ThemeState @AndroidEntryPoint @@ -107,10 +111,6 @@ class AppearanceSettingsActivity: ScreenLockActionBarActivity(), View.OnClickLis } } - private fun updateFollowSystemToggle(followSystemSettings: Boolean) { - binding.systemSettingsSwitch.isChecked = followSystemSettings - } - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) binding = ActivityAppearanceSettingsBinding.inflate(layoutInflater) @@ -128,8 +128,13 @@ class AppearanceSettingsActivity: ScreenLockActionBarActivity(), View.OnClickLis it.setOnClickListener(this@AppearanceSettingsActivity) } // system settings toggle - systemSettingsSwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setNewFollowSystemSettings(isChecked) } - systemSettingsSwitchHolder.setOnClickListener { systemSettingsSwitch.toggle() } + systemSettingsSwitch.setThemedContent { + SessionSwitch( + checked = viewModel.uiState.collectAsState().value.followSystem, + onCheckedChange = viewModel::setNewFollowSystemSettings, + enabled = true + ) + } systemSettingsAppIcon.setOnClickListener { startActivity(Intent(this@AppearanceSettingsActivity, AppDisguiseSettingsActivity::class.java)) @@ -138,10 +143,9 @@ class AppearanceSettingsActivity: ScreenLockActionBarActivity(), View.OnClickLis lifecycleScope.launchWhenResumed { viewModel.uiState.collectLatest { themeState -> - val (theme, accent, followSystem) = themeState + val (theme, accent) = themeState updateSelectedTheme(theme) updateSelectedAccent(accent) - updateFollowSystemToggle(followSystem) if (currentTheme != null && currentTheme != themeState) { recreate() } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt index 5a8f768d3d..ce40fc2a43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import org.thoughtcrime.securesms.ui.theme.LocalColors // todo Get proper styling that works well with ax on all themes and then move this composable in the components file @@ -11,17 +12,20 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors fun SessionSwitch( checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Switch( checked = checked, modifier = modifier, onCheckedChange = onCheckedChange, + enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = LocalColors.current.primary, - checkedTrackColor = LocalColors.current.background, - uncheckedThumbColor = LocalColors.current.text, - uncheckedTrackColor = LocalColors.current.background, + checkedTrackColor = LocalColors.current.primary.copy(alpha = 0.3f), + uncheckedThumbColor = LocalColors.current.disabled, + uncheckedTrackColor = LocalColors.current.disabled.copy(alpha = 0.3f), + uncheckedBorderColor = Color.Transparent, ) ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt index 8fe10bcbaa..d65b5f0f22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt @@ -77,7 +77,7 @@ class WebRtcCallBridge @Inject constructor( const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" const val EXTRA_CALL_ID = "call_id" - private const val TIMEOUT_SECONDS = 30L + private const val TIMEOUT_SECONDS = 60L private const val RECONNECT_SECONDS = 5L private const val MAX_RECONNECTS = 5 diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index 79974ea14c..cfa6d7193b 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -350,7 +350,8 @@ android:layout_marginBottom="@dimen/massive_spacing" android:layout_width="match_parent" android:layout_height="wrap_content" - android:minHeight="50dp"> + android:minHeight="56dp"> + - + android:minHeight="56dp"> - + From 67033edae84d5b83aaf379b8c2f279c7c15dd50f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 14:02:42 +1000 Subject: [PATCH 244/867] import --- app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 08782c690c..1e78f3153c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset From 6bcb7efff6c55fc68b2aa2f5f7ac5cbc4b44601c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 May 2025 15:53:07 +1000 Subject: [PATCH 245/867] Fixing conversation app bar --- .../conversation/v2/ConversationActivityV2.kt | 1 + .../ui/components/ConversationAppBar.kt | 29 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 409135a061..1814aac04e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -823,6 +823,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // called from onCreate private fun setUpToolBar() { + binding.conversationAppBar.applySafeInsetsPaddings(WindowInsetsCompat.Type.statusBars()) binding.conversationAppBar.setThemedContent { val data by viewModel.appBarData.collectAsState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 13a573bb40..3f89610893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -14,7 +15,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -23,6 +26,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,7 +65,6 @@ fun ConversationAppBar( onCallPressed: () -> Unit, onAvatarPressed: () -> Unit ) { - //todo UCS need to sort out the centering when multiple actions val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) CenterAlignedTopAppBar( @@ -79,7 +82,8 @@ fun ConversationAppBar( if (data.pagerData.isNotEmpty()) { // Settings content pager ConversationSettingsPager( - modifier = Modifier.padding(top = 2.dp), + modifier = Modifier.padding(top = 2.dp) + .fillMaxWidth(0.8f), pages = data.pagerData, pagerState = pagerState ) @@ -111,7 +115,7 @@ fun ConversationAppBar( } // Avatar - if(data.showAvatar) { + if (data.showAvatar) { Avatar( modifier = Modifier.qaTag(R.string.qa_conversation_avatar) .clickable { onAvatarPressed() }, @@ -157,7 +161,7 @@ private fun ConversationSettingsPager( ) { HorizontalPager( state = pagerState, - modifier = modifier.fillMaxWidth() + modifier = modifier, ) { page -> Row ( modifier = Modifier.fillMaxWidth() @@ -201,8 +205,7 @@ private fun ConversationSettingsPager( // '>' icon if(pages.size > 1) { Image( - modifier = Modifier.size(12.dp) - .rotate(180f), + modifier = Modifier.size(12.dp).rotate(180f), painter = painterResource(id = R.drawable.ic_chevron_left), colorFilter = ColorFilter.tint(LocalColors.current.text), contentDescription = null, @@ -273,6 +276,20 @@ class ConversationTopBarParamsProvider : PreviewParameterProvider Date: Mon, 12 May 2025 08:33:24 +1000 Subject: [PATCH 246/867] Allowing clicking on members in group memebrs screen --- .../securesms/groups/GroupMembersViewModel.kt | 32 +++++++++++++++++-- .../groups/compose/GroupMembersScreen.kt | 12 +++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt index 35434eb63e..38cb1d49fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt @@ -1,30 +1,58 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.content.Intent +import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = GroupMembersViewModel.Factory::class) class GroupMembersViewModel @AssistedInject constructor( @Assisted private val groupId: AccountId, - @ApplicationContext context: Context, - storage: StorageProtocol, + @ApplicationContext private val context: Context, + private val storage: StorageProtocol, configFactory: ConfigFactoryProtocol, usernameUtils: UsernameUtils, avatarUtils: AvatarUtils ) : BaseGroupMembersViewModel(groupId, context, storage, usernameUtils, configFactory, avatarUtils) { + private val _navigationActions = Channel() + val navigationActions = _navigationActions.receiveAsFlow() + @AssistedFactory interface Factory { fun create(groupId: AccountId): GroupMembersViewModel } + + fun onMemberClicked(accountId: AccountId) { + viewModelScope.launch(Dispatchers.Default) { + val address = Address.fromSerialized(accountId.hexString) + val threadId = storage.getThreadId(address) + + val intent = Intent( + context, + ConversationActivityV2::class.java + ) + intent.putExtra(ConversationActivityV2.ADDRESS, address) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + + _navigationActions.send(intent) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 176f6abd6b..2fdfd19c59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R @@ -18,6 +19,7 @@ import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.GroupMembersViewModel +import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.qaTag @@ -33,12 +35,19 @@ fun GroupMembersScreen( viewModel: GroupMembersViewModel, onBack: () -> Unit, ) { + + val context = LocalContext.current + ObserveAsEvents(flow = viewModel.navigationActions) { intent -> + context.startActivity(intent) + } + GroupMembers( onBack = onBack, members = viewModel.members.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, onSearchQueryChanged = viewModel::onSearchQueryChanged, onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onMemberClicked = viewModel::onMemberClicked ) } @@ -51,6 +60,7 @@ fun GroupMembers( searchQuery: String, onSearchQueryChanged: (String) -> Unit, onSearchQueryClear: () -> Unit, + onMemberClicked: (AccountId) -> Unit, ) { Scaffold( topBar = { @@ -83,6 +93,7 @@ fun GroupMembers( // Each member's view MemberItem( accountId = member.accountId, + onClick = { onMemberClicked(member.accountId) }, title = member.name, subtitle = member.statusLabel, subtitleColor = if (member.highlightStatus) { @@ -174,6 +185,7 @@ private fun EditGroupPreview() { searchQuery = "", onSearchQueryChanged = {}, onSearchQueryClear = {}, + onMemberClicked = {}, ) } } From 4cd9b8c65737602712a837e9c741900e99dbe130 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 08:55:43 +1000 Subject: [PATCH 247/867] Fixing test --- .../v2/settings/ConversationSettingsViewModel.kt | 2 +- .../DisappearingMessagesViewModelTest.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index ce20f5ad5d..18df9d3e10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -257,7 +257,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) ) } -//todo UCS make group members items tappable + conversation.is1on1 -> { val mainOptions = mutableListOf() val dangerOptions = mutableListOf() diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index 44cb886a22..b031004552 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -493,10 +493,10 @@ fun typeOption(time: Duration, type: ExpiryType, selected: Boolean = false, enab fun typeOption(mode: ExpiryMode, selected: Boolean = false, enabled: Boolean = true) = ExpiryRadioOption( - mode, - GetString(mode.type.title), - mode.type.subtitle?.let(::GetString), - GetString(mode.type.contentDescription), + value = mode, + title = GetString(mode.type.title), + subtitle = mode.type.subtitle?.let(::GetString), + qaTag = GetString(mode.type.contentDescription), selected = selected, enabled = enabled ) From 3242dddf9771616fdf28573210f66c4057303662 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 12 May 2025 09:21:51 +1000 Subject: [PATCH 248/867] Bump version to 1.23.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 42374e4d04..0b4a8dd744 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ configurations.configureEach { exclude module: "commons-logging" } -def canonicalVersionCode = 406 -def canonicalVersionName = "1.23.0" +def canonicalVersionCode = 407 +def canonicalVersionName = "1.23.1" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, From a6158bde46dd2ecd7dab698ce6097e9481282b80 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 09:35:10 +1000 Subject: [PATCH 249/867] Fixed tests --- .../disappearingmessages/DisappearingMessagesViewModelTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index b031004552..fc80dcc7ae 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -128,7 +128,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { ).isEqualTo( UiState( OptionsCardData( - R.string.disappearingMessagesTimer, + title = R.string.disappearingMessagesTimer, typeOption(ExpiryMode.NONE, selected = true), timeOption(ExpiryType.AFTER_SEND, 12.hours), timeOption(ExpiryType.AFTER_SEND, 1.days), @@ -426,6 +426,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { ) = ExpiryRadioOption( value = type.mode(time), title = GetString(time), + qaTag = GetString(type.mode(time).duration), enabled = enabled, selected = selected ) From dba86807695e1db7b80a4ff6f50b74a9eebf2a0e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 01:36:58 +0200 Subject: [PATCH 250/867] Update app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt Co-authored-by: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> --- .../org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt index 38cb1d49fa..abd8296c6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt @@ -33,7 +33,7 @@ class GroupMembersViewModel @AssistedInject constructor( ) : BaseGroupMembersViewModel(groupId, context, storage, usernameUtils, configFactory, avatarUtils) { private val _navigationActions = Channel() - val navigationActions = _navigationActions.receiveAsFlow() + val navigationActions get() = _navigationActions.receiveAsFlow() @AssistedFactory interface Factory { From 800fefabf09a63146fdeb28e5f4b9543cc575f1b Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 12 May 2025 09:43:50 +1000 Subject: [PATCH 251/867] Make sure hasPath runs in background thread (#1146) --- .../securesms/home/PathStatusView.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt index a422ddf9e5..5a03844f38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.snode.OnionRequestAPI import org.thoughtcrime.securesms.util.toPx @@ -55,19 +56,22 @@ class PathStatusView : View { override fun onAttachedToWindow() { super.onAttachedToWindow() - updateJob = GlobalScope.launch(Dispatchers.Main) { + updateJob = GlobalScope.launch { OnionRequestAPI.hasPath .collectLatest { pathsBuilt -> - if (pathsBuilt) { - setBackgroundResource(R.drawable.accent_dot) - val hasPathsColor = context.getColor(R.color.accent_green) - mainColor = hasPathsColor - sessionShadowColor = hasPathsColor - } else { - setBackgroundResource(R.drawable.paths_building_dot) - val pathsBuildingColor = ContextCompat.getColor(context, R.color.paths_building) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor + withContext(Dispatchers.Main) { + if (pathsBuilt) { + setBackgroundResource(R.drawable.accent_dot) + val hasPathsColor = context.getColor(R.color.accent_green) + mainColor = hasPathsColor + sessionShadowColor = hasPathsColor + } else { + setBackgroundResource(R.drawable.paths_building_dot) + val pathsBuildingColor = + ContextCompat.getColor(context, R.color.paths_building) + mainColor = pathsBuildingColor + sessionShadowColor = pathsBuildingColor + } } } } From 9dba763f294219e4c29b4e430c73f6f45b51e564 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 10:29:27 +1000 Subject: [PATCH 252/867] Added navigation to the group members from the conversation app bar --- app/src/main/AndroidManifest.xml | 4 +- .../conversation/v2/ConversationActivityV2.kt | 8 ++++ .../conversation/v2/ConversationViewModel.kt | 11 +++++- .../securesms/groups/GroupMembersActivity.kt | 38 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f03ddb1cb..7ee17bd497 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -188,7 +188,9 @@ - + { + val intent = Intent(this@ConversationActivityV2, GroupMembersActivity::class.java).apply { + putExtra(GroupMembersActivity.GROUP_ID, event.groupId) + } + startActivity(intent) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 3a8d38170a..409756a8c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -411,7 +411,7 @@ class ConversationViewModel( pagerData += ConversationAppBarPagerData( title = title, action = { - //todo UCS take user to appropriate group members screen (old code had no click action for this) + showGroupMembers() }, ) } @@ -1214,6 +1214,14 @@ class ConversationViewModel( } } + fun showGroupMembers() { + recipient?.let { convo -> + val groupId = recipient?.address?.toString() ?: return + + _uiEvents.tryEmit(ConversationUiEvent.ShowGroupMembers(groupId)) + } + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -1337,6 +1345,7 @@ data class ConversationUiState( sealed interface ConversationUiEvent { data class NavigateToConversation(val threadId: Long) : ConversationUiEvent data class ShowDisappearingMessages(val threadId: Long) : ConversationUiEvent + data class ShowGroupMembers(val groupId: String) : ConversationUiEvent } sealed interface MessageRequestUiState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt new file mode 100644 index 0000000000..b5a43d0788 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.groups + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen + +/** + * Forced to add an activity entry point for this screen + * (which is otherwise accessed without an activity through the ConversationSettingsNavHost) + * because this is navigated to from the conversation app bar + */ +@AndroidEntryPoint +class GroupMembersActivity: FullComposeScreenLockActivity() { + + private val groupId: String by lazy { + intent.getStringExtra(GROUP_ID) ?: "" + } + + @Composable + override fun ComposeContent() { + val viewModel: GroupMembersViewModel = + hiltViewModel { factory -> + factory.create(AccountId(groupId)) + } + + GroupMembersScreen( + viewModel = viewModel, + onBack = { finish() }, + ) + } + + companion object { + const val GROUP_ID = "group_id" + } +} From 9e582c743d4e06b17f73594df74808d0e8fd0652 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 10:58:55 +1000 Subject: [PATCH 253/867] Added navigation access to the notification settings screen from the conversation app bar --- app/src/main/AndroidManifest.xml | 3 ++ .../conversation/v2/ConversationActivityV2.kt | 13 ++++--- .../conversation/v2/ConversationViewModel.kt | 11 ++++-- .../v2/menus/ConversationMenuHelper.kt | 7 ++-- .../NotificationSettingsActivity.kt | 37 +++++++++++++++++++ .../NotificationSettingsScreen.kt | 10 ++++- .../NotificationSettingsViewModel.kt | 4 -- 7 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ee17bd497..1e86d774b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -191,6 +191,9 @@ + { + val intent = Intent(this@ConversationActivityV2, NotificationSettingsActivity::class.java).apply { + putExtra(NotificationSettingsActivity.THREAD_ID, event.threadId) + } + startActivity(intent) + } } } } @@ -1409,11 +1417,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // endregion // region Interaction - // TODO: don't need to allow new closed group check here, removed in new disappearing messages - override fun showDisappearingMessages(thread: Recipient) { - viewModel.showDisappearingMessages() - } - private fun callRecipient() { if(viewModel.recipient == null) return diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 409756a8c2..2e59b9ca5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -391,7 +391,7 @@ class ConversationViewModel( title = if(conversation.isMuted) application.getString(R.string.notificationsHeaderMute) else application.getString(R.string.notificationsHeaderMentionsOnly), action = { - //todo UCS take user to new mute screen (old code had no click action for this) + showNotificationSettings() } ) } @@ -1202,7 +1202,7 @@ class ConversationViewModel( fun getUsername(accountId: String) = usernameUtils.getContactNameWithAccountID(accountId) - fun showDisappearingMessages() { + private fun showDisappearingMessages() { recipient?.let { convo -> if (convo.isLegacyGroupRecipient) { groupDb.getGroup(convo.address.toGroupString()).orNull()?.run { @@ -1214,7 +1214,7 @@ class ConversationViewModel( } } - fun showGroupMembers() { + private fun showGroupMembers() { recipient?.let { convo -> val groupId = recipient?.address?.toString() ?: return @@ -1222,6 +1222,10 @@ class ConversationViewModel( } } + private fun showNotificationSettings() { + _uiEvents.tryEmit(ConversationUiEvent.ShowNotificationSettings(threadId)) + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -1345,6 +1349,7 @@ data class ConversationUiState( sealed interface ConversationUiEvent { data class NavigateToConversation(val threadId: Long) : ConversationUiEvent data class ShowDisappearingMessages(val threadId: Long) : ConversationUiEvent + data class ShowNotificationSettings(val threadId: Long) : ConversationUiEvent data class ShowGroupMembers(val groupId: String) : ConversationUiEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 9f46545f7e..e8665fbea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -186,7 +186,7 @@ object ConversationMenuHelper { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } R.id.menu_add_shortcut -> { addShortcut(context, thread) } - R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } + //R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } /* R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_block -> { block(context, thread, deleteThread = false) }*/ R.id.menu_copy_account_id -> { copyAccountID(context, thread) } @@ -256,10 +256,10 @@ object ConversationMenuHelper { }.execute() } - private fun showDisappearingMessages(context: Context, thread: Recipient) { + /* private fun showDisappearingMessages(context: Context, thread: Recipient) { val listener = context as? ConversationMenuListener ?: return listener.showDisappearingMessages(thread) - } + }*/ /* private fun unblock(context: Context, thread: Recipient) { if (!thread.isContactRecipient) { return } @@ -475,7 +475,6 @@ object ConversationMenuHelper { interface ConversationMenuListener { fun copyAccountID(accountId: String) fun copyOpenGroupUrl(thread: Recipient) - fun showDisappearingMessages(thread: Recipient) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt new file mode 100644 index 0000000000..713af5a710 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity + +/** + * Forced to add an activity entry point for this screen + * (which is otherwise accessed without an activity through the ConversationSettingsNavHost) + * because this is navigated to from the conversation app bar + */ +@AndroidEntryPoint +class NotificationSettingsActivity: FullComposeScreenLockActivity() { + + private val threadId: Long by lazy { + intent.getLongExtra(DisappearingMessagesActivity.THREAD_ID, -1) + } + + @Composable + override fun ComposeContent() { + val viewModel = + hiltViewModel { factory -> + factory.create(threadId) + } + + NotificationSettingsScreen( + viewModel = viewModel, + onBack = { finish() } + ) + } + + companion object { + const val THREAD_ID = "thread_id" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt index 445542a468..f601da8fe1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox import org.thoughtcrime.securesms.ui.Callbacks @@ -88,6 +90,7 @@ fun NotificationSettings( } } + val coroutineScope = rememberCoroutineScope() PrimaryOutlineButton( stringResource(R.string.set), modifier = Modifier @@ -95,7 +98,12 @@ fun NotificationSettings( .align(Alignment.CenterHorizontally) .padding(bottom = LocalDimensions.current.spacing), enabled = state.enableButton, - onClick = callbacks::onSetClick + onClick = { + coroutineScope.launch { + callbacks.onSetClick() + onBack() // leave screen once value is set + } + } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 9c7f65451a..b6d37e5d81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -41,7 +41,6 @@ class NotificationSettingsViewModel @AssistedInject constructor( @ApplicationContext private val context: Context, private val recipientDatabase: RecipientDatabase, private val repository: ConversationRepository, - private val navigator: ConversationSettingsNavigator, ) : ViewModel(), Callbacks { private var thread: Recipient? = null @@ -214,9 +213,6 @@ class NotificationSettingsViewModel @AssistedInject constructor( Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() } } - - // navigate back to the conversation settings - navigator.navigateUp() } override fun setValue(value: Any) { From 9ef214f1d26c8cd130a7763cc01172ccb1085eb6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 11:17:38 +1000 Subject: [PATCH 254/867] Consolidating our muting/notification settings with the new screen --- .../org/thoughtcrime/securesms/MuteDialog.kt | 52 ------------------- .../v2/menus/ConversationMenuHelper.kt | 2 - .../v2/utilities/NotificationUtils.kt | 18 ------- .../home/ConversationOptionsBottomSheet.kt | 22 ++++---- .../securesms/home/HomeActivity.kt | 33 ++---------- .../ic_outline_notification_important_24.xml | 10 ---- .../fragment_conversation_bottom_sheet.xml | 23 +------- 7 files changed, 15 insertions(+), 145 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt delete mode 100644 app/src/main/res/drawable/ic_outline_notification_important_24.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt deleted file mode 100644 index d5e551d02a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.thoughtcrime.securesms - -import android.content.Context -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.R -import org.session.libsession.LocalisedTimeUtil -import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY -import org.thoughtcrime.securesms.ui.getSubbedString -import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -fun showMuteDialog( - context: Context, - onMuteDuration: (Long) -> Unit -): AlertDialog = context.showSessionDialog { - title(R.string.notificationsMute) - - items(Option.entries.mapIndexed { index, entry -> - - if (entry.stringRes == R.string.notificationsMute) { - context.getString(R.string.notificationsMute) - } else { - val largeTimeUnitString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - Option.entries[index].duration.milliseconds - ) - context.getSubbedString(entry.stringRes, TIME_LARGE_KEY to largeTimeUnitString) - } - }.toTypedArray()) { - // Note: We add the current timestamp to the mute duration to get the un-mute timestamp - // that gets stored in the database via ConversationMenuHelper.mute(). - // Also: This is a kludge, but we ADD one second to the mute duration because otherwise by - // the time the view for how long the conversation is muted for gets set then it's actually - // less than the entire duration - so 1 hour becomes 59 minutes, 1 day becomes 23 hours etc. - // As we really want to see the actual set time (1 hour / 1 day etc.) then we'll bump it by - // 1 second which is neither here nor there in the grand scheme of things. - val muteTime = Option.entries[it].duration - val muteTimeFromNow = if (muteTime == Long.MAX_VALUE) muteTime - else muteTime + System.currentTimeMillis() + 1.seconds.inWholeMilliseconds - onMuteDuration(muteTimeFromNow) - } -} - -private enum class Option(@StringRes val stringRes: Int, val duration: Long) { - ONE_HOUR(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(1)), - TWO_HOURS(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(2)), - ONE_DAY(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(1)), - SEVEN_DAYS(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(7)), - FOREVER(R.string.notificationsMute, duration = Long.MAX_VALUE ); -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index e8665fbea4..ba7f3de471 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -41,12 +41,10 @@ import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.contacts.SelectContactsToInviteToGroupActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity.Companion.groupIDKey -import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt deleted file mode 100644 index f012f925ed..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities - -import android.content.Context -import network.loki.messenger.R -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.showSessionDialog - -object NotificationUtils { - fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { - context.showSessionDialog { - title(R.string.sessionNotifications) - singleChoiceItems( - context.resources.getStringArray(R.array.notify_types), - thread.notifyType - ) { notifyTypeHandler(it) } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 1896eec3e4..8e4f4099a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -17,6 +17,7 @@ import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.isGroupDestroyed import org.session.libsession.utilities.wasKickedFromGroupV2 import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread @@ -45,7 +46,6 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto var onDeleteTapped: (() -> Unit)? = null var onMarkAllAsReadTapped: (() -> Unit)? = null var onNotificationTapped: (() -> Unit)? = null - var onSetMuteTapped: ((Boolean) -> Unit)? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentConversationBottomSheetBinding.inflate(LayoutInflater.from(parentContext), container, false) @@ -64,8 +64,6 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.deleteTextView -> onDeleteTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke() - binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) - binding.muteNotificationsTextView -> onSetMuteTapped?.invoke(true) } } @@ -95,16 +93,14 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.copyCommunityUrl.isVisible = recipient.isCommunityRecipient binding.copyCommunityUrl.setOnClickListener(this) - binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber - && !isDeprecatedLegacyGroup - binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber - && !isDeprecatedLegacyGroup - - binding.unMuteNotificationsTextView.setOnClickListener(this) - binding.muteNotificationsTextView.setOnClickListener(this) - binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted - && !isDeprecatedLegacyGroup - + val notificationIconRes = when{ + recipient.isMuted -> R.drawable.ic_volume_off + recipient.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS -> + R.drawable.ic_at_sign + else -> R.drawable.ic_volume_2 + } + binding.notificationsTextView.setCompoundDrawablesWithIntrinsicBounds(notificationIconRes, 0, 0, 0) + binding.notificationsTextView.isVisible = !recipient.isLocalNumber && !isDeprecatedLegacyGroup binding.notificationsTextView.setOnClickListener(this) // delete diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 1f75b7e280..0bfc2a7abf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -54,7 +54,7 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.start.StartConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper -import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase @@ -75,7 +75,6 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity -import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.disableClipping @@ -563,15 +562,13 @@ class HomeActivity : ScreenLockActionBarActivity(), bottomSheet.dismiss() deleteConversation(thread) } - bottomSheet.onSetMuteTapped = { muted -> - bottomSheet.dismiss() - setConversationMuted(thread, muted) - } bottomSheet.onNotificationTapped = { bottomSheet.dismiss() - NotificationUtils.showNotifyDialog(this, thread.recipient) { notifyType -> - setNotifyType(thread, notifyType) + // go to the notification settings + val intent = Intent(this, NotificationSettingsActivity::class.java).apply { + putExtra(NotificationSettingsActivity.THREAD_ID, thread.threadId) } + startActivity(intent) } bottomSheet.onPinTapped = { bottomSheet.dismiss() @@ -626,26 +623,6 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { - if (!isMuted) { - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setMuted(thread.recipient, 0) - } - } else { - showMuteDialog(this) { until -> - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setMuted(thread.recipient, until) - } - } - } - } - - private fun setNotifyType(thread: ThreadRecord, newNotifyType: Int) { - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setNotifyType(thread.recipient, newNotifyType) - } - } - private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.Default) { storage.setPinned(threadId, pinned) diff --git a/app/src/main/res/drawable/ic_outline_notification_important_24.xml b/app/src/main/res/drawable/ic_outline_notification_important_24.xml deleted file mode 100644 index 5a4d7814d4..0000000000 --- a/app/src/main/res/drawable/ic_outline_notification_important_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index 258d905602..49971383ee 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -71,33 +71,12 @@ app:drawableTint="?attr/colorControlNormal" tools:visibility="visible" /> - - - - From 1f052fe419daa6c94d3dba1572e870f2c75ac662 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 12:01:32 +1000 Subject: [PATCH 255/867] Added crowdin string --- .../notification/NotificationSettingsViewModel.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index b6d37e5d81..87cd285812 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -2,8 +2,10 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import android.content.Context import android.widget.Toast +import androidx.compose.ui.res.stringResource import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -16,6 +18,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator @@ -125,10 +129,13 @@ class NotificationSettingsViewModel @AssistedInject constructor( // then add a new option that specifies how much longer the mute is on for if(currentMutedUntil != null && currentMutedUntil!! > 0L && currentMutedUntil!! < System.currentTimeMillis() + TimeUnit.DAYS.toMillis(14)){ // more than two weeks from now means forever + val title = Phrase.from(context.getString(R.string.notificationsMutedForTime)) + .put(DATE_TIME_KEY, formatTime(currentMutedUntil!!)) + .format().toString() muteRadioOptions.add( RadioOption( value = currentMutedUntil!!, - title = GetString("Muted Until: ${formatTime(currentMutedUntil!!)}"), //todo UCS need the crowdin string + title = GetString(title), qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_muted_until), selected = selectedMuteDuration == currentMutedUntil ) From da2feb576794c3ba6e5b730fa6ed7a45868e561f Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 12 May 2025 15:06:17 +1000 Subject: [PATCH 256/867] App disguise bug fixes (#1149) * Make sure activity is finished after changing app disguise settings * Fix styling issue --- .../components/SwitchPreferenceCompat.kt | 4 +- .../securesms/disguise/AppDisguiseManager.kt | 54 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt index f8c7e38a94..a39972b23d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt @@ -18,8 +18,8 @@ class SwitchPreferenceCompat : TwoStatePreference { constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, androidx.preference.R.attr.switchPreferenceCompatStyle) + constructor(context: Context) : this(context, null, androidx.preference.R.attr.switchPreferenceCompatStyle) private val checkState = MutableStateFlow(isChecked) private val enableState = MutableStateFlow(isEnabled) diff --git a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt index 31cdb28d32..a59b774f15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt @@ -1,9 +1,12 @@ package org.thoughtcrime.securesms.disguise +import android.app.Activity import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager +import android.os.Bundle import androidx.annotation.DrawableRes import androidx.annotation.StringRes import kotlinx.coroutines.CoroutineScope @@ -11,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -39,6 +43,8 @@ class AppDisguiseManager @Inject constructor( ) { private val scope: CoroutineScope = GlobalScope + private var currentActivity: Activity? = null + val allAppAliases: Flow> = flow { emit( application.packageManager @@ -108,11 +114,16 @@ class AppDisguiseManager @Inject constructor( val state = if (alias === enabledAlias) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED - ComponentName(application, alias.activityAliasName) to state + Triple( + ComponentName(application, alias.activityAliasName), + state, + alias.defaultEnabled + ) } }.collectLatest { all -> + val packageManager = application.packageManager if (android.os.Build.VERSION.SDK_INT >= 33) { - application.packageManager.setComponentEnabledSettings( + packageManager.setComponentEnabledSettings( all.map { (name, state) -> PackageManager.ComponentEnabledSetting( name, state, PackageManager.DONT_KILL_APP or PackageManager.SYNCHRONOUS @@ -120,16 +131,51 @@ class AppDisguiseManager @Inject constructor( } ) } else { - all.forEach { (name, state) -> - application.packageManager.setComponentEnabledSetting( + // Query current enable state for each component + val changed = all.filter { (name, desiredState, defaultEnabled) -> + val state = packageManager.getComponentEnabledSetting(name) + val wasEnabled = when (state) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> false + else -> defaultEnabled + } + + val willBeEnabled = (desiredState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) || + (desiredState == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT && defaultEnabled) + wasEnabled != willBeEnabled + } + + changed.forEach { (name, state) -> + packageManager.setComponentEnabledSetting( name, state, PackageManager.DONT_KILL_APP ) } + + if (changed.isNotEmpty()) { + // Finish current activity if the disguise is on + currentActivity?.finishAffinity() + } } } } + + application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) { + currentActivity = activity + } + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) { + if (currentActivity === activity) { + currentActivity = null + } + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) } fun setSelectedAliasName(name: String?) { From b0cbca87f3a365ab516975060726d50e5ce2652e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 15:33:08 +1000 Subject: [PATCH 257/867] Base setup for new convo search --- .../conversation/v2/ConversationActivityV2.kt | 19 +++++++++++++----- .../v2/menus/ConversationMenuHelper.kt | 20 +++++++------------ .../settings/ConversationSettingsActivity.kt | 4 ++++ .../settings/ConversationSettingsNavHost.kt | 6 ++++++ .../settings/ConversationSettingsNavigator.kt | 9 +++++++++ .../settings/ConversationSettingsViewModel.kt | 9 ++++++++- .../thoughtcrime/securesms/ui/Components.kt | 3 ++- 7 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 642df62b98..80326cda2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -26,7 +26,6 @@ import android.text.style.ImageSpan import android.util.Pair import android.util.TypedValue import android.view.ActionMode -import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup.LayoutParams @@ -321,7 +320,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } // Search val searchViewModel: SearchViewModel by viewModels() - var searchViewItem: MenuItem? = null private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -431,6 +429,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private var isVoiceToastShowing = false + // launcher that handles getting results back from the settings page + private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res -> + if (res.resultCode == RESULT_OK && + res.data?.getBooleanExtra(SHOW_SEARCH, false) == true) { + onSearchOpened() + } + } + // Only show a toast related to voice messages if the toast is not already showing (used if to // rate limit & prevent toast queueing when the user spams the microphone button). private fun showVoiceMessageToastIfNotAlreadyVisible() { @@ -484,6 +490,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, const val FROM_GROUP_THREAD_ID = "from_group_thread_id" const val SCROLL_MESSAGE_ID = "scroll_message_id" const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" + const val SHOW_SEARCH = "show_search" // Request codes const val PICK_DOCUMENT = 2 const val TAKE_PHOTO = 7 @@ -847,11 +854,13 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, onBackPressed = ::finish, onCallPressed = ::callRecipient, onAvatarPressed = { - startActivity(ConversationSettingsActivity.createIntent( + val intent = ConversationSettingsActivity.createIntent( context = this, threadId = viewModel.threadId, threadAddress = viewModel.recipient?.address - )) + ) + + settingsLauncher.launch(intent) } ) } @@ -1579,7 +1588,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, deprecationManager = viewModel.legacyGroupDeprecationManager ) actionModeCallback.delegate = this - searchViewItem?.collapseActionView() + if(binding.searchBottomBar.isVisible) onSearchClosed() if (actionMode == null) { // Nothing should be selected if this is the case adapter.toggleSelection(message, position) this.actionMode = startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index ba7f3de471..fa706e9ab8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -9,10 +9,6 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ContextThemeWrapper -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -39,8 +35,6 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ShortcutLauncherActivity -import org.thoughtcrime.securesms.contacts.SelectContactsToInviteToGroupActivity -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity @@ -131,7 +125,7 @@ object ConversationMenuHelper { }*/ // Search - val searchViewItem = menu.findItem(R.id.menu_search) + /*val searchViewItem = menu.findItem(R.id.menu_search) (context as ConversationActivityV2).searchViewItem = searchViewItem val searchView = searchViewItem.actionView as SearchView val queryListener = object : OnQueryTextListener { @@ -161,7 +155,7 @@ object ConversationMenuHelper { context.onSearchClosed() return true } - }) + })*/ } /** @@ -182,7 +176,7 @@ object ConversationMenuHelper { ): ReceiveChannel? { when (item.itemId) { R.id.menu_view_all_media -> { showAllMedia(context, thread) } - R.id.menu_search -> { search(context) } + // R.id.menu_search -> { search(context) } R.id.menu_add_shortcut -> { addShortcut(context, thread) } //R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } /* R.id.menu_unblock -> { unblock(context, thread) } @@ -208,10 +202,10 @@ object ConversationMenuHelper { // activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address)) } - private fun search(context: Context) { - val searchViewModel = (context as ConversationActivityV2).searchViewModel - searchViewModel.onSearchOpened() - } +// private fun search(context: Context) { +// val searchViewModel = (context as ConversationActivityV2).searchViewModel +// searchViewModel.onSearchOpened() +// } @SuppressLint("StaticFieldLeak") private fun addShortcut(context: Context, thread: Recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt index 03d3203c14..4317090da8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt @@ -33,6 +33,10 @@ class ConversationSettingsActivity: FullComposeScreenLockActivity() { threadId = intent.getLongExtra(THREAD_ID, 0), threadAddress = IntentCompat.getParcelableExtra(intent, THREAD_ADDRESS, Address::class.java), navigator = navigator, + returnResult = { code, value -> + setResult(RESULT_OK, Intent().putExtra(code, value)) + finish() + }, onBack = this::finish ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 633d44ee23..5bff78319e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.settings import android.annotation.SuppressLint +import android.content.Intent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -91,6 +92,7 @@ fun ConversationSettingsNavHost( threadId: Long, threadAddress: Address?, navigator: ConversationSettingsNavigator, + returnResult: (String, Boolean) -> Unit, onBack: () -> Unit ){ SharedTransitionLayout { @@ -109,6 +111,10 @@ fun ConversationSettingsNavHost( is NavigationAction.NavigateToIntent -> { navController.context.startActivity(action.intent) } + + is NavigationAction.ReturnResult -> { + returnResult(action.code, action.value) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt index 7204738632..8e51192203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavigator.kt @@ -33,6 +33,10 @@ class ConversationSettingsNavigator @Inject constructor(){ suspend fun navigateToIntent(intent: Intent) { _navigationActions.send(NavigationAction.NavigateToIntent(intent)) } + + suspend fun returnResult(code: String, value: Boolean) { + _navigationActions.send(NavigationAction.ReturnResult(code, value)) + } } sealed interface NavigationAction { @@ -46,4 +50,9 @@ sealed interface NavigationAction { data class NavigateToIntent( val intent: Intent ): NavigationAction + + data class ReturnResult( + val code: String, + val value: Boolean + ): NavigationAction } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 18df9d3e10..6bb380a61e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -54,6 +54,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.RecipientDatabase @@ -868,6 +869,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private fun goBackToSearch(){ + viewModelScope.launch { + navigator.returnResult(ConversationActivityV2.SHOW_SEARCH, true) + } + } + fun onCommand(command: Commands) { when (command) { is Commands.CopyAccountId -> copyAccountId() @@ -958,7 +965,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( name = context.getString(R.string.searchConversation), icon = R.drawable.ic_search, qaTag = R.string.qa_conversation_settings_search, - onClick = ::copyAccountId //todo UCS get proper method + onClick = ::goBackToSearch ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1e78f3153c..6991904687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -711,6 +711,7 @@ fun SearchBar( innerTextField() if (query.isEmpty() && placeholder != null) { Text( + modifier = Modifier.qaTag(R.string.qa_conversation_search_input), text = placeholder, color = LocalColors.current.textSecondary, style = LocalType.current.xl @@ -724,7 +725,7 @@ fun SearchBar( colorFilter = ColorFilter.tint( LocalColors.current.textSecondary ), - modifier = Modifier + modifier = Modifier.qaTag(R.string.qa_conversation_search_clear) .padding( horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xxsSpacing From ceeb82a27dbb1edce091bc32e43f230bc4e5bf71 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 May 2025 15:49:48 +1000 Subject: [PATCH 258/867] Removing callbacks --- .../DisappearingMessagesViewModel.kt | 7 ++-- .../ui/DisappearingMessages.kt | 10 +++--- .../ui/DisappearingMessagesPreview.kt | 4 +++ .../ui/DisappearingMessagesScreen.kt | 3 +- .../NotificationSettingsScreen.kt | 17 +++++----- .../NotificationSettingsViewModel.kt | 33 ++++++++++--------- .../thoughtcrime/securesms/ui/Components.kt | 14 ++------ 7 files changed, 42 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 21a54845d1..3ebd1a938f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.toUiState import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator @@ -39,7 +38,7 @@ class DisappearingMessagesViewModel @AssistedInject constructor( private val groupDb: GroupDatabase, private val storage: Storage, private val navigator: ConversationSettingsNavigator, -) : ViewModel(), ExpiryCallbacks { +) : ViewModel() { private val _state = MutableStateFlow( State( @@ -84,9 +83,9 @@ class DisappearingMessagesViewModel @AssistedInject constructor( } } - override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } + fun onOptionSelected(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } - override fun onSetClick() = viewModelScope.launch { + fun onSetClicked() = viewModelScope.launch { val state = _state.value val mode = state.expiryMode val address = state.address diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index 2d94f419ac..01b83d7d6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -21,8 +21,6 @@ import androidx.compose.ui.text.style.TextAlign import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox -import org.thoughtcrime.securesms.ui.Callbacks -import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.AppBarBackIcon @@ -34,14 +32,14 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType -typealias ExpiryCallbacks = Callbacks typealias ExpiryRadioOption = RadioOption @OptIn(ExperimentalMaterial3Api::class) @Composable fun DisappearingMessages( state: UiState, - callbacks: ExpiryCallbacks = NoOpCallbacks, + onOptionSelected: (ExpiryMode) -> Unit, + onSetClicked: () -> Unit, onBack: () -> Unit ) { Scaffold( @@ -86,7 +84,7 @@ fun DisappearingMessages( Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) state.cards.forEachIndexed { index, option -> - OptionsCard(option, callbacks) + OptionsCard(option, onOptionSelected) // add spacing if not the last item if (index != state.cards.lastIndex) { @@ -118,7 +116,7 @@ fun DisappearingMessages( .qaTag(R.string.AccessibilityId_setButton) .align(Alignment.CenterHorizontally) .padding(bottom = LocalDimensions.current.spacing), - onClick = callbacks::onSetClick + onClick = onSetClicked ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt index c0f8eeb07d..7def693e9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -22,6 +22,8 @@ fun PreviewStates( PreviewTheme { DisappearingMessages( state.toUiState(), + onOptionSelected = {}, + onSetClicked = {}, onBack = {}, ) } @@ -54,6 +56,8 @@ fun PreviewThemes( PreviewTheme(colors) { DisappearingMessages( State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), + onOptionSelected = {}, + onSetClicked = {}, onBack = {} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt index 96f7662648..9309113fad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt @@ -13,7 +13,8 @@ fun DisappearingMessagesScreen( val uiState by viewModel.uiState.collectAsState(UiState()) DisappearingMessages( state = uiState, - callbacks = viewModel, + onOptionSelected = viewModel::onOptionSelected, + onSetClicked = viewModel::onSetClicked, onBack = onBack ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt index f601da8fe1..d66595cac8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -22,9 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox -import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption @@ -44,7 +42,8 @@ fun NotificationSettingsScreen( NotificationSettings( state = state, - callbacks = viewModel, + onOptionSelected = viewModel::onOptionSelected, + onSetClicked = viewModel::onSetClicked, onBack = onBack ) } @@ -53,7 +52,8 @@ fun NotificationSettingsScreen( @Composable fun NotificationSettings( state: NotificationSettingsViewModel.UiState, - callbacks: Callbacks = NoOpCallbacks, + onOptionSelected: (Any) -> Unit, + onSetClicked: suspend () -> Unit, onBack: () -> Unit ) { Scaffold( @@ -77,13 +77,13 @@ fun NotificationSettings( // notification options if(state.notificationTypes != null) { - OptionsCard(state.notificationTypes, callbacks) + OptionsCard(state.notificationTypes, onOptionSelected) } // mute types if(state.muteTypes != null) { Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - OptionsCard(state.muteTypes, callbacks) + OptionsCard(state.muteTypes, onOptionSelected) } Spacer(modifier = Modifier.height(bottomContentPadding)) @@ -100,7 +100,7 @@ fun NotificationSettings( enabled = state.enableButton, onClick = { coroutineScope.launch { - callbacks.onSetClick() + onSetClicked() onBack() // leave screen once value is set } } @@ -147,7 +147,8 @@ fun PreviewNotificationSettings(){ ), enableButton = true ), - callbacks = NoOpCallbacks, + onOptionSelected = {}, + onSetClicked = {}, onBack = {} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 87cd285812..af70aedacd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import android.content.Context import android.widget.Toast -import androidx.compose.ui.res.stringResource import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase @@ -16,19 +15,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsNavigator import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_MENTIONS import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.repository.ConversationRepository -import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption @@ -45,7 +42,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( @ApplicationContext private val context: Context, private val recipientDatabase: RecipientDatabase, private val repository: ConversationRepository, -) : ViewModel(), Callbacks { +) : ViewModel() { private var thread: Recipient? = null private val durationForever: Long = Long.MAX_VALUE @@ -192,15 +189,15 @@ class NotificationSettingsViewModel @AssistedInject constructor( } } - override fun onSetClick() = viewModelScope.launch { + suspend fun onSetClicked() { when(selectedOption){ is NotificationType.All, is NotificationType.MentionsOnly -> { unmute() setNotifyType(selectedOption.notifyType) - } + else -> { - val muteDuration = selectedMuteDuration ?: return@launch + val muteDuration = selectedMuteDuration ?: return mute(if(muteDuration == durationForever) muteDuration else System.currentTimeMillis() + muteDuration) @@ -222,7 +219,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( } } - override fun setValue(value: Any) { + fun onOptionSelected(value: Any) { when(value){ is Long -> selectedMuteDuration = value @@ -232,19 +229,25 @@ class NotificationSettingsViewModel @AssistedInject constructor( updateState() } - private fun unmute() { + private suspend fun unmute() { val conversation = thread ?: return - recipientDatabase.setMuted(conversation, 0) + withContext(Dispatchers.Default) { + recipientDatabase.setMuted(conversation, 0) + } } - private fun mute(until: Long) { + private suspend fun mute(until: Long) { val conversation = thread ?: return - recipientDatabase.setMuted(conversation, until) + withContext(Dispatchers.Default) { + recipientDatabase.setMuted(conversation, until) + } } - private fun setNotifyType(notifyType: Int) { + private suspend fun setNotifyType(notifyType: Int) { val conversation = thread ?: return - recipientDatabase.setNotifyType(conversation, notifyType) + withContext(Dispatchers.Default) { + recipientDatabase.setNotifyType(conversation, notifyType) + } } data class UiState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1e78f3153c..156543c0bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -93,16 +93,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.transparentButtonColors import kotlin.math.roundToInt -interface Callbacks { - fun onSetClick(): Any? - fun setValue(value: T) -} - -object NoOpCallbacks: Callbacks { - override fun onSetClick() {} - override fun setValue(value: Any) {} -} - data class RadioOption( val value: T, val title: GetString, @@ -122,7 +112,7 @@ data class OptionsCardData( } @Composable -fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { +fun OptionsCard(card: OptionsCardData, onOptionSelected: (T) -> Unit) { Column { if (card.title != null && card.title.string().isNotEmpty()) { Text( @@ -141,7 +131,7 @@ fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { ) { itemsIndexed(card.options) { i, it -> if (i != 0) Divider() - TitledRadioButton(option = it) { callbacks.setValue(it.value) } + TitledRadioButton(option = it) { onOptionSelected(it.value) } } } } From 4a728c909124eaa809efdb5eb5669d01978450a5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 May 2025 09:46:43 +1000 Subject: [PATCH 259/867] Search bar in conversation app bar Updated highlight color too --- .../conversation/v2/ConversationActivityV2.kt | 12 +- .../conversation/v2/ConversationViewModel.kt | 8 + .../v2/messages/VisibleMessageContentView.kt | 8 +- .../conversation/v2/search/SearchViewModel.kt | 33 +-- .../thoughtcrime/securesms/ui/Components.kt | 4 +- .../ui/components/ConversationAppBar.kt | 192 ++++++++++++------ .../securesms/ui/theme/Dimensions.kt | 1 + 7 files changed, 183 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 80326cda2e..3acedc70c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -848,11 +848,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, binding.conversationAppBar.setThemedContent { val data by viewModel.appBarData.collectAsState() + val query by searchViewModel.searchQuery.collectAsState() ConversationAppBar( data = data, onBackPressed = ::finish, onCallPressed = ::callRecipient, + searchQuery = query ?: "", + onSearchQueryChanged = ::onSearchQueryUpdated, + onSearchQueryClear = { onSearchQueryUpdated("") }, + onSearchCanceled = ::onSearchClosed, onAvatarPressed = { val intent = ConversationSettingsActivity.createIntent( context = this, @@ -2599,7 +2604,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { - searchViewModel.onMissingResult() } + searchViewModel.onMissingResult() + } } } @@ -2608,6 +2614,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } fun onSearchOpened() { + viewModel.onSearchOpened() searchViewModel.onSearchOpened() binding.searchBottomBar.visibility = View.VISIBLE binding.searchBottomBar.setData(0, 0) @@ -2617,6 +2624,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } fun onSearchClosed() { + viewModel.onSearchClosed() searchViewModel.onSearchClosed() binding.searchBottomBar.visibility = View.GONE binding.inputBar.visibility = View.VISIBLE @@ -2626,8 +2634,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } fun onSearchQueryUpdated(query: String) { - searchViewModel.onQueryUpdated(query, viewModel.threadId) binding.searchBottomBar.showLoading() + searchViewModel.onQueryUpdated(query, viewModel.threadId) adapter.onSearchQueryUpdated(query) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 2e59b9ca5a..e22c526f97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1202,6 +1202,14 @@ class ConversationViewModel( fun getUsername(accountId: String) = usernameUtils.getContactNameWithAccountID(accountId) + fun onSearchOpened(){ + _appBarData.update { _appBarData.value.copy(showSearch = true) } + } + + fun onSearchClosed(){ + _appBarData.update { _appBarData.value.copy(showSearch = false) } + } + private fun showDisappearingMessages() { recipient?.let { convo -> if (convo.isLegacyGroupRecipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index bddb33b666..1841f49ef3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -457,9 +457,13 @@ class VisibleMessageContentView : ConstraintLayout { context = context ) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), - { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) + { + BackgroundColorSpan(context.getColorFromAttr(R.attr.colorPrimary)) + }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), - { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) + { + ForegroundColorSpan(context.getColorFromAttr(android.R.attr.textColorPrimary)) + }, body, searchQuery) Linkify.addLinks(body, Linkify.WEB_URLS) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index a80d892b3c..ebb3e872ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.conversation.v2.search import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.io.Closeable import javax.inject.Inject import org.session.libsession.utilities.Debouncer @@ -18,24 +20,27 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { private val result: CloseableLiveData = CloseableLiveData() - private val debouncer: Debouncer = Debouncer(500) - private var firstSearch = false + private val debouncer: Debouncer = Debouncer(200) private var searchOpen = false - private var activeQuery: String? = null private var activeThreadId: Long = 0 val searchResults: LiveData get() = result + private val mutableSearchQuery: MutableStateFlow = MutableStateFlow(null) + val searchQuery: StateFlow get() = mutableSearchQuery + + private val MIN_QUERY_SIZE = 2 + fun onQueryUpdated(query: String, threadId: Long) { - if (query == activeQuery) { + if (query == mutableSearchQuery.value) { return } updateQuery(query, threadId) } fun onMissingResult() { - if (activeQuery != null) { - updateQuery(activeQuery!!, activeThreadId) + if (mutableSearchQuery.value != null) { + updateQuery(mutableSearchQuery.value!!, activeThreadId) } } @@ -55,12 +60,11 @@ class SearchViewModel @Inject constructor( fun onSearchOpened() { searchOpen = true - firstSearch = true } fun onSearchClosed() { searchOpen = false - activeQuery = null + mutableSearchQuery.value = null debouncer.clear() result.close() } @@ -71,13 +75,18 @@ class SearchViewModel @Inject constructor( } private fun updateQuery(query: String, threadId: Long) { - activeQuery = query + mutableSearchQuery.value = query activeThreadId = threadId + + if(query.length < MIN_QUERY_SIZE) { + result.value = SearchResult(CursorList.emptyList(), 0) + return + } + debouncer.publish { - firstSearch = false searchRepository.query(query, threadId) { messages: CursorList -> runOnMain { - if (searchOpen && query == activeQuery) { + if (searchOpen && query == mutableSearchQuery.value) { result.setValue(SearchResult(messages, 0)) } else { messages.close() @@ -87,8 +96,6 @@ class SearchViewModel @Inject constructor( } } - public fun getActiveQuery() = activeQuery - class SearchResult(private val results: CursorList, val position: Int) : Closeable { fun getResults(): List { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 6991904687..1c2247629a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -691,7 +692,8 @@ fun SearchBar( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(100)) + .heightIn(min = LocalDimensions.current.minSearchInputHeight) + .background(backgroundColor, MaterialTheme.shapes.small) ) { Image( painterResource(id = R.drawable.ic_search), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 3f89610893..2d9d25525c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,11 +30,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -42,8 +48,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -61,71 +70,126 @@ import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun ConversationAppBar( data: ConversationAppBarData, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onSearchCanceled: () -> Unit, onBackPressed: () -> Unit, onCallPressed: () -> Unit, - onAvatarPressed: () -> Unit + onAvatarPressed: () -> Unit, + modifier: Modifier = Modifier ) { - val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) + Box ( + modifier = modifier + ) { + // cross fade between the default app bar and the search bar + Crossfade(targetState = data.showSearch) { showSearch -> + when(showSearch){ + false -> { + val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) - CenterAlignedTopAppBar( - windowInsets = WindowInsets(0, 0, 0, 0), - title = { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - AppBarText( - modifier = Modifier.qaTag(R.string.AccessibilityId_conversationTitle), - title = data.title, - singleLine = true - ) + CenterAlignedTopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppBarText( + modifier = Modifier.qaTag(R.string.AccessibilityId_conversationTitle), + title = data.title, + singleLine = true + ) - if (data.pagerData.isNotEmpty()) { - // Settings content pager - ConversationSettingsPager( - modifier = Modifier.padding(top = 2.dp) - .fillMaxWidth(0.8f), - pages = data.pagerData, - pagerState = pagerState - ) + if (data.pagerData.isNotEmpty()) { + // Settings content pager + ConversationSettingsPager( + modifier = Modifier.padding(top = 2.dp) + .fillMaxWidth(0.8f), + pages = data.pagerData, + pagerState = pagerState + ) + + // Dot indicators + PagerIndicator( + modifier = Modifier.padding(top = 2.dp), + pageCount = data.pagerData.size, + currentPage = pagerState.currentPage + ) + } + } + }, + navigationIcon = { + AppBarBackIcon(onBack = onBackPressed) + }, + actions = { + if (data.showCall) { + IconButton( + onClick = onCallPressed + ) { + Icon( + painter = painterResource(id = R.drawable.ic_phone), + contentDescription = stringResource(id = R.string.AccessibilityId_call), + tint = LocalColors.current.text, + modifier = Modifier.size(LocalDimensions.current.iconMedium) + ) + } + } - // Dot indicators - PagerIndicator( - modifier = Modifier.padding(top = 2.dp), - pageCount = data.pagerData.size, - currentPage = pagerState.currentPage + // Avatar + if (data.showAvatar) { + Avatar( + modifier = Modifier.qaTag(R.string.qa_conversation_avatar) + .clickable { onAvatarPressed() }, + size = LocalDimensions.current.iconLargeAvatar, + data = data.avatarUIData + ) + } + }, + colors = appBarColors(LocalColors.current.background) ) } - } - }, - navigationIcon = { - AppBarBackIcon(onBack = onBackPressed) - }, - actions = { - if (data.showCall) { - IconButton( - onClick = onCallPressed - ) { - Icon( - painter = painterResource(id = R.drawable.ic_phone), - contentDescription = stringResource(id = R.string.AccessibilityId_call), - tint = LocalColors.current.text, - modifier = Modifier.size(LocalDimensions.current.iconMedium) - ) + + true -> { + Row( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + .heightIn(min = LocalDimensions.current.appBarHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.search), + modifier = Modifier.weight(1f) + .focusRequester(focusRequester), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) + + Text( + modifier = Modifier.clickable { + onSearchCanceled() + }, + text = stringResource(R.string.cancel), + style = LocalType.current.large, + ) + + //todo UCS we get the weird android 15 bounce on every search query + //todo UCS I need to update the search loading view + //todo UCS show "no matches" + } } } - // Avatar - if (data.showAvatar) { - Avatar( - modifier = Modifier.qaTag(R.string.qa_conversation_avatar) - .clickable { onAvatarPressed() }, - size = LocalDimensions.current.iconLargeAvatar, - data = data.avatarUIData - ) - } - }, - colors = appBarColors(LocalColors.current.background) - ) + } + } } /** @@ -136,10 +200,10 @@ data class ConversationAppBarData( val pagerData: List, val showAvatar: Boolean = false, val showCall: Boolean = false, + val showSearch: Boolean = false, val avatarUIData: AvatarUIData ) - /** * Data class representing a pager item data */ @@ -261,7 +325,8 @@ class ConversationTopBarPreviewParams( val title: String, val settingsPagesCount: Int, val isCallAvailable: Boolean, - val showAvatar: Boolean + val showAvatar: Boolean, + val showSearch: Boolean = false ) /** @@ -276,6 +341,14 @@ class ConversationTopBarParamsProvider : PreviewParameterProvider Date: Tue, 13 May 2025 10:09:29 +1000 Subject: [PATCH 260/867] Updating highlight glow intensity --- .../conversation/v2/messages/VisibleMessageContentView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 1841f49ef3..c2d1ba568d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -437,10 +437,11 @@ class VisibleMessageContentView : ConstraintLayout { fun playHighlight() { // Show the highlight colour immediately then slowly fade out val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme) - val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0) + val startColor = ColorUtils.setAlphaComponent(targetColor, (0.5f * 255).toInt()) + val endColor = ColorUtils.setAlphaComponent(targetColor, 0) binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1 binding.contentParent.sessionShadowColor = targetColor - GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600) + GlowViewUtilities.animateShadowColorChange(binding.contentParent, startColor, endColor, 1600) } // endregion From 8cfe73e126f1dd25692d9e424f728faa7359c328 Mon Sep 17 00:00:00 2001 From: stfsession Date: Tue, 13 May 2025 10:17:01 +1000 Subject: [PATCH 261/867] [Automated] Update translations from Crowdin (#1147) Co-authored-by: Bilb <1544279+Bilb@users.noreply.github.com> --- .../src/main/res/values-b+cs+CZ/strings.xml | 6 ++++++ .../src/main/res/values-b+hu+HU/strings.xml | 2 ++ .../src/main/res/values-b+pl+PL/strings.xml | 15 +++++++++++++++ .../src/main/res/values-b+uk+UA/strings.xml | 1 + libsession/src/main/res/values/strings.xml | 5 +++-- 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/libsession/src/main/res/values-b+cs+CZ/strings.xml b/libsession/src/main/res/values-b+cs+CZ/strings.xml index 59effd9fcb..440f6ccb38 100644 --- a/libsession/src/main/res/values-b+cs+CZ/strings.xml +++ b/libsession/src/main/res/values-b+cs+CZ/strings.xml @@ -266,6 +266,7 @@ Vytvořit Vytváření hovoru Vyjmout + Došlo k chybě databáze.\n\nExportujte své aplikační logy a sdílejte je pro účely diagnostiky. Pokud to nebude úspěšné, přeinstalujte {app_name} a obnovte svůj účet. Všimli jsme si, že spuštění aplikace {app_name} trvá dlouho.\n\nMůžete pokračovat v čekání, exportovat logy zařízení k řešení problémů nebo zkusit restartovat {app_name}. Databáze vaší aplikace není kompatibilní s touto verzí {app_name}. Přeinstalujte aplikaci a obnovte svůj účet pro vytvoření nové databáze a pokračování v používání {app_name}.\n\nVarování: To povede ke ztrátě všech zpráv a příloh starších než dva týdny. Optimalizace databáze @@ -431,6 +432,7 @@ Vytvořit skupinu Prosím vyberte alespoň jednoho dalšího člena skupiny. Smazat skupinu + Jste si jisti, že chcete smazat {group_name}?\n\nTímto odeberete všechny členy a smažete veškerý obsah skupiny. Opravdu chcete smazat {group_name}? Skupina {group_name} byla smazána správcem skupiny. Nebudete moci posílat další zprávy. Zadejte popis skupiny @@ -643,6 +645,8 @@ Ve skupině %1$s máte %2$d nových zpráv. Odpovědět na + Dokud není vaše žádost o zprávu přijata, nemůžete posílat přílohy + Dokud není vaše žádost o zprávu přijata, nemůžete posílat hlasové zprávy {name} vás pozval(a) do skupiny {group_name}. Odesláním zprávy do této skupiny automaticky přijmete pozvánku do skupiny. Vaše žádost o komunikaci teď čeká na vyřízení. @@ -707,6 +711,7 @@ Ztlumit na {time_large} Zrušit ztlumení Ztlumeno + Ztlumeno do {date_time} Pomalý režim {app_name} bude občas kontrolovat nové zprávy na pozadí. Zvuk @@ -922,6 +927,7 @@ Nelze aktualizovat {app_name} se nepodařilo aktualizovat. Přejděte prosím na {session_download_url} a nainstalujte novou verzi ručně, poté kontaktujte naše Centrum pomoci a dejte nám vědět o tomto problému. Je dostupná nová verze {app_name}, klikněte pro aktualizaci + Je dostupná nová verze ({version}) aplikace {app_name}. Přejít na poznámky k vydání Aktualizace {app_name} Verze {version} diff --git a/libsession/src/main/res/values-b+hu+HU/strings.xml b/libsession/src/main/res/values-b+hu+HU/strings.xml index 3bf78edec4..13bd3d7312 100644 --- a/libsession/src/main/res/values-b+hu+HU/strings.xml +++ b/libsession/src/main/res/values-b+hu+HU/strings.xml @@ -545,6 +545,8 @@ %1$d új üzeneted érkezett. Válasz erre + Addig nem küldhet mellékleteket, amíg az üzenetkérelmét el nem fogadják + Addig nem küldhet hangüzeneteket, amíg az üzenetkérelmét el nem fogadják {name} meghívott a {group_name} csoportba. Üzenet küldése ebbe a csoportba automatikusan elfogadja a csoportmeghívást. Az üzenetkérésed jelenleg függőben van. diff --git a/libsession/src/main/res/values-b+pl+PL/strings.xml b/libsession/src/main/res/values-b+pl+PL/strings.xml index ed4970cd43..8cb2e5f991 100644 --- a/libsession/src/main/res/values-b+pl+PL/strings.xml +++ b/libsession/src/main/res/values-b+pl+PL/strings.xml @@ -190,14 +190,22 @@ Czy na pewno chcesz usunąć swoje dane z sieci? Jeśli będziesz kontynuować, nie będziesz w stanie przywrócić wiadomości ani kontaktów. Czy na pewno chcesz wyczyścić urządzenie? Wyczyść tylko urządzenie + Wyczyść urządzenie i uruchom ponownie + Wyczyść urządzenie i przywróć Wyczyść wszystkie wiadomości Czy na pewno chcesz usunąć z urządzenia wszystkie wiadomości z konwersacji z użytkownikiem {name}? + Czy na pewno chcesz wyczyścić wszystkie wiadomości z konwersacji z {name} na tym urządzeniu? Czy na pewno chcesz wyczyścić z urządzenia wszystkie wiadomości ze społeczności {community_name}? + Czy na pewno chcesz wyczyścić wszystkie wiadomości z {community_name} na tym urządzeniu? Wyczyść u wszystkich Wyczyść u mnie Czy na pewno chcesz usunąć wszystkie wiadomości z grupy {group_name}? + Czy na pewno chcesz wyczyścić wszystkie wiadomości z {group_name}? Czy na pewno chcesz usunąć z urządzenia wszystkie wiadomości z grupy {group_name}? + Czy na pewno chcesz wyczyścić wszystkie wiadomości z {group_name} na tym urządzeniu? Czy na pewno chcesz usunąć z urządzenia wszystkie swoje notatki? + Czy na pewno chcesz usunąć z urządzenia wszystkie Moje notatki? + Wyczyść na tym urządzeniu Zamknij Zamknij okno Hash zatwierdzenia: {hash} @@ -264,6 +272,8 @@ Kopiuj Utwórz Wytnij + Czy na pewno chcesz usunąć wszystkie wiadomości, załączniki i dane konta z tego urządzenia i utworzyć nowe konto? + Czy na pewno chcesz usunąć wszystkie wiadomości, załączniki i dane konta z tego urządzenia i przywrócić konto z sieci? Zauważyliśmy, że uruchomienie aplikacji {app_name} zajmuje dużo czasu.\n\n Możesz kontynuować oczekiwanie, wyeksportować dzienniki urządzenia do udostępnienia w celu rozwiązania problemów lub spróbować ponownie uruchomić aplikację {app_name}. Twoja baza danych aplikacji jest niezgodna z tą wersją aplikacji {app_name}. Aby wygenerować nową bazę danych i dalej korzystać z aplikacji {app_name}, zainstaluj aplikację ponownie i przywróć swoje konto.\n\nUwaga: spowoduje to utratę wszystkich wiadomości i załączników starszych niż dwa tygodnie. Optymalizacja bazy danych @@ -522,6 +532,7 @@ Chcielibyśmy poznać Twoją opinię Ukryj Przełącz widoczność systemowego paska menu + Czy na pewno chcesz ukryć Moje notatki na liście konwersacji? Ukryj pozostałe Zdjęcie Klawiatura w trybie prywatnym @@ -613,6 +624,8 @@ Masz %1$d nowych wiadomości. Odpowiadanie do + Nie można wysyłać załączników, dopóki prośba o wiadomość nie zostanie zaakceptowana + Nie można wysyłać wiadomości głosowych, dopóki prośba o wiadomość nie zostanie zaakceptowana {name} zaprasza Cię do grupy{group_name}. Wysłanie wiadomości do tej grupy automatycznie zaakceptuje zaproszenie do grupy. Twoja prośba o wiadomość czeka na akceptację. @@ -856,6 +869,8 @@ Pokaż Pokaż wszystko Pokaż mniej + Pokaż moje notatki + Czy na pewno chcesz wyświetlać Moje notatki na liście konwersacji? Naklejki Przejdź do strony wsparcia technicznego Informacja systemowa: {information} diff --git a/libsession/src/main/res/values-b+uk+UA/strings.xml b/libsession/src/main/res/values-b+uk+UA/strings.xml index 8cd1d38455..0504c136be 100644 --- a/libsession/src/main/res/values-b+uk+UA/strings.xml +++ b/libsession/src/main/res/values-b+uk+UA/strings.xml @@ -14,6 +14,7 @@ Це ваш Account ID. Інші користувачі можуть просканувати його, щоб почати розмову з вами. Актуальний розмір Додати + Додати адміністраторів Адміністратори не можуть бути видалені. {name} та ще {count} інших було підвищено до адміністраторів. Підвищити адміністратора diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index c303f2090e..d724686cf5 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ About Accept Copy Account ID + Account ID Account ID Copied Copy your Account ID then share it with your friends so they can message you. Enter Account ID @@ -16,7 +17,7 @@ Actual Size Add Add Admins - Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. + Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. Admins cannot be removed. {name} and {count} others were promoted to Admin. Promote Admins @@ -51,7 +52,7 @@ Anonymous App Icon Change App Icon and Name - Changing the app icon and name requires Session to be closed. Notifications will continue to use the default Session icon and name. + Changing the app icon and name requires {app_name} to be closed. Notifications will continue to use the default {app_name} icon and name. Alternate app icon and name is displayed on home screen and app drawer. Icon and name Alternate app icon is displayed on home screen and app library. App name will still appear as \'{app_name}\'. From 1f67b9374832847a351c7cf101f48d875676775a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 May 2025 11:52:26 +1000 Subject: [PATCH 262/867] Updated tests --- .../disappearingmessages/DisappearingMessagesViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index fc80dcc7ae..7bf6879bd3 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -377,7 +377,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { advanceUntilIdle() - viewModel.setValue(afterSendMode(1.days)) + viewModel.onOptionSelected(afterSendMode(1.days)) advanceUntilIdle() From 866d1d94443fbd3086e2810fe596c567b9bc65bd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 May 2025 13:33:59 +1000 Subject: [PATCH 263/867] Search updated loading and no results ui --- .../conversation/v2/ConversationActivityV2.kt | 6 ++-- .../conversation/v2/search/SearchBottomBar.kt | 17 ++++++--- .../conversation/v2/search/SearchViewModel.kt | 11 ++++-- .../res/layout/view_search_bottom_bar.xml | 36 ++++++++++++++----- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 3acedc70c1..d58371a6c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -2609,7 +2609,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - binding.searchBottomBar.setData(result.position, result.getResults().size) + binding.searchBottomBar.setData(result.position, result.getResults().size, searchViewModel.searchQuery.value) }) } @@ -2617,7 +2617,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, viewModel.onSearchOpened() searchViewModel.onSearchOpened() binding.searchBottomBar.visibility = View.VISIBLE - binding.searchBottomBar.setData(0, 0) + binding.searchBottomBar.setData(0, 0, searchViewModel.searchQuery.value) binding.inputBar.visibility = View.INVISIBLE binding.root.requestApplyInsets() @@ -2636,7 +2636,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, fun onSearchQueryUpdated(query: String) { binding.searchBottomBar.showLoading() searchViewModel.onQueryUpdated(query, viewModel.threadId) - adapter.onSearchQueryUpdated(query) + adapter.onSearchQueryUpdated(query.takeUnless { it.length < 2 }) } override fun onSearchMoveUpPressed() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt index 5f2fd73ab6..53642aca3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewSearchBottomBarBinding +import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel.Companion.MIN_QUERY_SIZE class SearchBottomBar : LinearLayout { private lateinit var binding: ViewSearchBottomBarBinding @@ -21,8 +22,8 @@ class SearchBottomBar : LinearLayout { binding = ViewSearchBottomBarBinding.inflate(LayoutInflater.from(context), this, true) } - fun setData(position: Int, count: Int) = with(binding) { - searchProgressWheel.visibility = GONE + fun setData(position: Int, count: Int, searchQuery: String?) = with(binding) { + binding.loading.visibility = GONE searchUp.setOnClickListener { v: View? -> if (eventListener != null) { eventListener!!.onSearchMoveUpPressed() @@ -33,9 +34,15 @@ class SearchBottomBar : LinearLayout { eventListener!!.onSearchMoveDownPressed() } } - if (count > 0) { + if (count > 0) { // we have results searchPosition.text = resources.getQuantityString(R.plurals.searchMatches, count, position + 1, count) - } else { + } else if ( // we have a legitimate query but no results + searchQuery != null && + searchQuery.length >= MIN_QUERY_SIZE && + count == 0 + ) { + searchPosition.text = resources.getString(R.string.searchMatchesNone) + } else { // we have no legitimate query yet searchPosition.text = "" } setViewEnabled(searchUp, position < count - 1) @@ -43,7 +50,7 @@ class SearchBottomBar : LinearLayout { } fun showLoading() { - binding.searchProgressWheel.visibility = VISIBLE + binding.loading.visibility = VISIBLE } private fun setViewEnabled(view: View, enabled: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index ebb3e872ff..6b315ceeb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -19,6 +19,10 @@ class SearchViewModel @Inject constructor( private val searchRepository: SearchRepository ) : ViewModel() { + companion object { + const val MIN_QUERY_SIZE = 2 + } + private val result: CloseableLiveData = CloseableLiveData() private val debouncer: Debouncer = Debouncer(200) private var searchOpen = false @@ -29,8 +33,6 @@ class SearchViewModel @Inject constructor( private val mutableSearchQuery: MutableStateFlow = MutableStateFlow(null) val searchQuery: StateFlow get() = mutableSearchQuery - private val MIN_QUERY_SIZE = 2 - fun onQueryUpdated(query: String, threadId: Long) { if (query == mutableSearchQuery.value) { return @@ -96,7 +98,10 @@ class SearchViewModel @Inject constructor( } } - class SearchResult(private val results: CursorList, val position: Int) : Closeable { + class SearchResult( + private val results: CursorList, + val position: Int + ) : Closeable { fun getResults(): List { return results diff --git a/app/src/main/res/layout/view_search_bottom_bar.xml b/app/src/main/res/layout/view_search_bottom_bar.xml index 77079a0d09..4c7e53c934 100644 --- a/app/src/main/res/layout/view_search_bottom_bar.xml +++ b/app/src/main/res/layout/view_search_bottom_bar.xml @@ -29,6 +29,7 @@ android:layout_marginStart="16dp" android:padding="4dp" android:background="?selectableItemBackgroundBorderless" + android:contentDescription="@string/qa_conversation_search_up" android:src="@drawable/ic_chevron_up" android:tint="?colorAccent" tools:ignore="UseAppTint" /> @@ -40,6 +41,7 @@ android:padding="4dp" android:layout_gravity="center_vertical" android:background="?selectableItemBackgroundBorderless" + android:contentDescription="@string/qa_conversation_search_down" android:src="@drawable/ic_chevron_down" android:tint="?colorAccent" tools:ignore="UseAppTint" /> @@ -55,17 +57,33 @@ android:text="37 of 73" android:textStyle="bold"/> - + android:layout_centerInParent="true"> + + + + + From 5f0d2b5eecbce2588e37511f2e2bfe9efb716abb Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 13 May 2025 14:37:42 +1000 Subject: [PATCH 264/867] [SES-3792] - Fix navigation bar issue on conversation screen (#1152) --- .../securesms/BaseActionBarActivity.kt | 27 +++++++++++++++---- .../conversation/v2/ConversationActivityV2.kt | 4 +++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt index 6afb6d8eef..2a880b273f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt @@ -66,6 +66,12 @@ abstract class BaseActionBarActivity : AppCompatActivity() { return themeState.accentStyle } + // Whether we should apply scrim automatically to the navigation bar + // If set to true, the system will detect if a scrim is needed based on the content + // If set to false, no scrim will be applied + open val applyAutoScrimForNavigationBar: Boolean + get() = true + override fun getTheme(): Resources.Theme { if (modifiedTheme != null) { return modifiedTheme!! @@ -83,17 +89,28 @@ abstract class BaseActionBarActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - val detectDarkMode = { _: Resources -> - ThemeUtil.isDarkTheme(this) + val detectDarkMode = { _: Resources -> ThemeUtil.isDarkTheme(this) } + + // The code above does this: + // If applyAutoScrimForNavigationBar is set to true, we use auto system bar style and the + // system will detect if it needs to apply a scrim so that a contrast is enforced. The end result + // could be that the scrim is present or not, depending on the color on the screen. + // However, if applyAutoScrimForNavigationBar is set to false, we use the specific + // SystemBarStyle where the contrast isn't enforced. This means that the scrim is always NOT applied. + val navigationBarStyle = when { + applyAutoScrimForNavigationBar -> { + SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim, detectDarkMode) + } + detectDarkMode(resources) -> SystemBarStyle.dark(Color.TRANSPARENT) + else -> SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) } enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT, detectDarkMode), - navigationBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim, detectDarkMode) + statusBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim, detectDarkMode), + navigationBarStyle = navigationBarStyle ) super.onCreate(savedInstanceState) - val actionBar = supportActionBar if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 4cc735f8d0..2825d3958d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Resources import android.database.Cursor +import android.graphics.Color import android.graphics.Rect import android.graphics.Typeface import android.net.Uri @@ -258,6 +259,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override val applyDefaultWindowInsets: Boolean get() = false + override val applyAutoScrimForNavigationBar: Boolean + get() = false + private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { // post screenshot message From 980245552ef522efa78648229822e4240d9b35a5 Mon Sep 17 00:00:00 2001 From: stfsession Date: Tue, 13 May 2025 15:07:26 +1000 Subject: [PATCH 265/867] [Automated] Update translations from Crowdin (#1153) Co-authored-by: Bilb <1544279+Bilb@users.noreply.github.com> --- libsession/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index d724686cf5..c3d9df62e6 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ Change App Icon and Name Changing the app icon and name requires {app_name} to be closed. Notifications will continue to use the default {app_name} icon and name. Alternate app icon and name is displayed on home screen and app drawer. + The selected app icon and name is displayed on the home screen and app drawer. Icon and name Alternate app icon is displayed on home screen and app library. App name will still appear as \'{app_name}\'. Use alternate app icon @@ -895,6 +896,7 @@ Searching... Select Select All + Select app icon Send Sending Sending Call Offer From 98620257e8512ea1235eac92b5b6ab76ed20c2d4 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 13 May 2025 15:27:25 +1000 Subject: [PATCH 266/867] Update app disguise (#1151) --- app/src/main/AndroidManifest.xml | 14 ---- .../securesms/disguise/AppDisguiseManager.kt | 35 +++----- .../appearance/AppDisguiseSettings.kt | 82 +++++++++---------- .../AppDisguiseSettingsViewModel.kt | 62 +++++--------- .../drawable/ic_launcher_news_background.xml | 21 ----- .../drawable/ic_launcher_news_foreground.xml | 63 -------------- .../mipmap-anydpi-v26/ic_launcher_news.xml | 5 -- app/src/main/res/values/themes.xml | 4 +- .../utilities/TextSecurePreferences.kt | 5 -- 9 files changed, 71 insertions(+), 220 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_launcher_news_background.xml delete mode 100644 app/src/main/res/drawable/ic_launcher_news_foreground.xml delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a8c205874d..6244803d77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -528,20 +528,6 @@ - - - - - - - - = prefChangeNotification - .mapLatest { prefs.isAppDiguiseOn } - .stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = prefs.isAppDiguiseOn - ) - init { scope.launch { combine( selectedAppAliasName, allAppAliases, - isOn, - ) { selected, all, on -> - val enabledAlias = when { - on -> all.firstOrNull { it.activityAliasName == selected } ?: all.first { it.defaultEnabled } - else -> all.first { it.defaultEnabled } - } + ) { selected, all -> + val enabledAlias = all.firstOrNull { it.activityAliasName == selected } + ?: all.first { it.defaultEnabled } all.map { alias -> val state = if (alias === enabledAlias) PackageManager.COMPONENT_ENABLED_STATE_ENABLED @@ -184,11 +176,6 @@ class AppDisguiseManager @Inject constructor( prefChangeNotification.tryEmit(Unit) } - fun setOn(on: Boolean) { - prefs.isAppDiguiseOn = on - prefChangeNotification.tryEmit(Unit) - } - data class AppAlias( val activityAliasName: String, val defaultEnabled: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index 659233a914..45b79d1488 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold @@ -41,13 +40,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.toBitmap +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -64,8 +65,7 @@ fun AppDisguiseSettingsScreen( ) { AppDisguiseSettings( onBack = onBack, - isOn = viewModel.isOn.collectAsState().value, - items = viewModel.alternativeIcons.collectAsState().value, + items = viewModel.iconList.collectAsState().value, dialogState = viewModel.confirmDialogState.collectAsState().value, onCommand = viewModel::onCommand, ) @@ -75,8 +75,7 @@ fun AppDisguiseSettingsScreen( @Composable private fun AppDisguiseSettings( items: List, - isOn: Boolean, - dialogState: AppDisguiseSettingsViewModel.ConfirmDialogState, + dialogState: AppDisguiseSettingsViewModel.ConfirmDialogState?, onBack: () -> Unit, onCommand: (AppDisguiseSettingsViewModel.Command) -> Unit, ) { @@ -92,38 +91,19 @@ private fun AppDisguiseSettings( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) ) { - Text( - stringResource(R.string.appIcon), - style = LocalType.current.large, - color = LocalColors.current.textSecondary - ) - - Cell { - Row( - modifier = Modifier - .toggleable(value = isOn, onValueChange = { - onCommand(AppDisguiseSettingsViewModel.Command.ToggleClicked(it)) - }) - .padding(LocalDimensions.current.xsSpacing), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(R.string.appIconEnableIconAndName), - modifier = Modifier.weight(1f), - style = LocalType.current.large, - color = LocalColors.current.text - ) - - SessionSwitch(checked = isOn, onCheckedChange = null) - } - } - BoxWithConstraints { // Calculate the number of columns based on the min width we want each column // to be. val minColumnWidth = LocalDimensions.current.xxsSpacing + ICON_ITEM_SIZE_DP.dp - val numColumn = + val maxNumColumn = (constraints.maxWidth / LocalDensity.current.run { minColumnWidth.toPx() }).toInt() + + // Make sure we fit all the items in the columns by trying each column size until + // we find one that suits. When the column size gets down to 1, it will always fit : + // n % 1 is always 0. + val numColumn = (maxNumColumn downTo 1) + .first { items.size % it == 0 } + val numRows = ceil(items.size.toFloat() / numColumn).toInt() Column( @@ -155,7 +135,13 @@ private fun AppDisguiseSettings( icon = item.icon, name = item.name, selected = item.selected, - onSelected = { onCommand(AppDisguiseSettingsViewModel.Command.IconSelected(item.id)) }, + onSelected = { + onCommand( + AppDisguiseSettingsViewModel.Command.IconSelected( + item.id + ) + ) + }, modifier = Modifier.weight(1f) ) } @@ -166,7 +152,8 @@ private fun AppDisguiseSettings( Text( stringResource(R.string.appIconAndNameDescription), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(top = LocalDimensions.current.smallSpacing), style = LocalType.current.base, color = LocalColors.current.textSecondary, @@ -177,10 +164,13 @@ private fun AppDisguiseSettings( } } - if (dialogState.showDialog) { + if (dialogState != null) { AlertDialog( onDismissRequest = { onCommand(AppDisguiseSettingsViewModel.Command.IconSelectDismissed) }, - text = stringResource(R.string.appIconAndNameChangeConfirmation), + text = Phrase.from(LocalContext.current, R.string.appIconAndNameChangeConfirmation) + .put(APP_NAME, stringResource(R.string.app_name)) + .format() + .toString(), title = stringResource(R.string.appIconAndNameChange), buttons = listOf( DialogButtonModel( @@ -240,6 +230,7 @@ private fun IconItem( } } } + .qaTag("$name option") .selectable( selected = selected, onClick = onSelected, @@ -261,6 +252,8 @@ private fun IconItem( @Preview @Preview(device = Devices.TABLET) +@Preview(widthDp = 486) +@Preview(widthDp = 300) @Composable private fun AppDisguiseSettingsPreview( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors @@ -268,6 +261,12 @@ private fun AppDisguiseSettingsPreview( PreviewTheme(colors) { AppDisguiseSettings( items = listOf( + AppDisguiseSettingsViewModel.IconAndName( + id = "3", + icon = R.mipmap.ic_launcher, + name = R.string.app_name, + selected = true + ), AppDisguiseSettingsViewModel.IconAndName( id = "1", icon = R.mipmap.ic_launcher_weather, @@ -280,12 +279,6 @@ private fun AppDisguiseSettingsPreview( name = R.string.appNameStocks, selected = false ), - AppDisguiseSettingsViewModel.IconAndName( - id = "3", - icon = R.mipmap.ic_launcher_news, - name = R.string.appNameNews, - selected = true - ), AppDisguiseSettingsViewModel.IconAndName( id = "1", icon = R.mipmap.ic_launcher_notes, @@ -305,9 +298,8 @@ private fun AppDisguiseSettingsPreview( selected = false ), ), - isOn = true, onBack = { }, - dialogState = AppDisguiseSettingsViewModel.ConfirmDialogState(null, false), + dialogState = null, onCommand = {} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt index 81ca1cdd7f..e3b28a32c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt @@ -17,65 +17,46 @@ import javax.inject.Inject class AppDisguiseSettingsViewModel @Inject constructor( private val manager: AppDisguiseManager ) : ViewModel() { - // Whether the app disguise is enabled - val isOn: StateFlow get() = manager.isOn - // The contents of the selection items - val alternativeIcons: StateFlow> = combine( + val iconList: StateFlow> = combine( manager.allAppAliases, manager.selectedAppAliasName, - manager.isOn - ) { aliases, selected, on -> - aliases.mapNotNull { alias -> - IconAndName( - id = alias.activityAliasName, - icon = alias.appIcon ?: return@mapNotNull null, - name = alias.appName ?: return@mapNotNull null, - selected = on && alias.activityAliasName == selected - ) - } + ) { aliases, selected -> + aliases + .sortedByDescending { it.defaultEnabled } // The default enabled alias must be first + .mapNotNull { alias -> + IconAndName( + id = alias.activityAliasName, + icon = alias.appIcon ?: return@mapNotNull null, + name = alias.appName ?: return@mapNotNull null, + selected = selected?.let { alias.activityAliasName == it } ?: alias.defaultEnabled + ) + } }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) - private val mutableConfirmDialogState = MutableStateFlow(ConfirmDialogState(null, false)) - val confirmDialogState: StateFlow get() = mutableConfirmDialogState + private val mutableConfirmDialogState = MutableStateFlow(null) + val confirmDialogState: StateFlow get() = mutableConfirmDialogState fun onCommand(command: Command) { when (command) { is Command.IconSelectConfirmed -> { - mutableConfirmDialogState.value = ConfirmDialogState(null, false) - if (command.id == null) { - manager.setOn(false) - } else { - manager.setOn(true) - manager.setSelectedAliasName(command.id) - } + mutableConfirmDialogState.value = null + manager.setSelectedAliasName(command.id) } Command.IconSelectDismissed -> { - mutableConfirmDialogState.value = ConfirmDialogState(null, false) + mutableConfirmDialogState.value = null } is Command.IconSelected -> { - if (!isOn.value || command.id != manager.selectedAppAliasName.value) { - mutableConfirmDialogState.value = ConfirmDialogState( - id = command.id, - showDialog = true - ) + if (command.id != manager.selectedAppAliasName.value) { + mutableConfirmDialogState.value = ConfirmDialogState(id = command.id,) } } - - is Command.ToggleClicked -> { - if (isOn.value == command.on) return - - mutableConfirmDialogState.value = ConfirmDialogState( - id = if (command.on) manager.selectedAppAliasName.value ?: alternativeIcons.value.firstOrNull()?.id else null, - showDialog = true - ) - } } } @@ -86,12 +67,11 @@ class AppDisguiseSettingsViewModel @Inject constructor( val selected: Boolean, ) - data class ConfirmDialogState(val id: String?, val showDialog: Boolean) + data class ConfirmDialogState(val id: String) sealed interface Command { data class IconSelected(val id: String) : Command - data class IconSelectConfirmed(val id: String?) : Command + data class IconSelectConfirmed(val id: String) : Command data object IconSelectDismissed : Command - data class ToggleClicked(val on: Boolean) : Command } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_news_background.xml b/app/src/main/res/drawable/ic_launcher_news_background.xml deleted file mode 100644 index 0f8152c178..0000000000 --- a/app/src/main/res/drawable/ic_launcher_news_background.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_news_foreground.xml b/app/src/main/res/drawable/ic_launcher_news_foreground.xml deleted file mode 100644 index 975dc532c7..0000000000 --- a/app/src/main/res/drawable/ic_launcher_news_foreground.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml deleted file mode 100644 index 1faf61b144..0000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_news.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6a6e01581a..2b50ecfc34 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -312,7 +312,7 @@ @style/Light.Popup @null @style/ThemeOverlay.AppCompat.ActionBar - @style/Widget.Session.ActionBar + @style/Widget.Session.ActionBar.Flat ?colorAccent ?colorAccent ?android:textColorPrimary @@ -490,7 +490,7 @@ ?android:textColorPrimary @null @style/ThemeOverlay.AppCompat.ActionBar - @style/Widget.Session.ActionBar + @style/Widget.Session.ActionBar.Flat ?colorAccent ?colorAccent ?android:textColorPrimary diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 4dfe17b23b..51b1001909 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -207,7 +207,6 @@ interface TextSecurePreferences { var migratedToMultiPartConfig: Boolean var selectedActivityAliasName: String? - var isAppDiguiseOn: Boolean companion object { val TAG = TextSecurePreferences::class.simpleName @@ -1023,10 +1022,6 @@ class AppTextSecurePreferences @Inject constructor( setStringPreference("selected_activity_alias_name", value) } - override var isAppDiguiseOn: Boolean - get() = getBooleanPreference("is_app_diguise_on", false) - set(value) = setBooleanPreference("is_app_diguise_on", value) - override fun setConfigurationMessageSynced(value: Boolean) { setBooleanPreference(TextSecurePreferences.CONFIGURATION_SYNCED, value) _events.tryEmit(TextSecurePreferences.CONFIGURATION_SYNCED) From 3e6424f8ae98cf8de8aba9be641ce0cb1f1d6f9c Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 13 May 2025 15:28:40 +1000 Subject: [PATCH 267/867] Updated strings for app disguise --- .../securesms/preferences/appearance/AppDisguiseSettings.kt | 2 +- app/src/main/res/layout/activity_appearance_settings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index 45b79d1488..204c6d2cf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -151,7 +151,7 @@ private fun AppDisguiseSettings( } Text( - stringResource(R.string.appIconAndNameDescription), + stringResource(R.string.appIconAndNameSelectionDescription), modifier = Modifier .fillMaxWidth() .padding(top = LocalDimensions.current.smallSpacing), diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index cfa6d7193b..47879f03b6 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -409,7 +409,7 @@ app:drawableEndCompat="@drawable/ic_chevron_right" app:drawableTint="?android:textColorPrimary" android:layout_gravity="center_vertical" - android:text="@string/appIconSelect" + android:text="@string/selectAppIcon" android:layout_width="match_parent" android:layout_height="wrap_content" /> From c027098fc8acd277c5000de3f5d5966f142cff44 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 13 May 2025 15:41:33 +1000 Subject: [PATCH 268/867] Tidy up Gradle scripts (#1150) --- .drone.jsonnet | 12 +- .github/dependabot.yml | 9 + .github/workflows/build_and_test.yml | 4 +- app/build.gradle | 446 ------------------ app/build.gradle.kts | 396 ++++++++++++++++ app/ipToCode.gradle.kts | 41 -- .../org/thoughtcrime/securesms/ui/Util.kt | 9 - app/{ => src/play}/google-services.json | 0 build-logic/build.gradle.kts | 35 ++ build-logic/settings.gradle.kts | 7 + .../src/main/groovy}/WitnessPlugin.groovy | 2 - .../kotlin/GenerateIPCountryDataPlugin.kt | 80 ++++ .../src/main/kotlin/RenameApkPlugin.kt | 63 +++ build.gradle | 79 ---- build.gradle.kts | 79 ++++ buildSrc/build.gradle | 10 - .../org/signal/signing/ApkSignerUtil.java | 141 ------ .../gradle-plugins/witness.properties | 1 - content-descriptions/build.gradle | 41 -- content-descriptions/build.gradle.kts | 44 ++ gradle.properties | 4 +- gradle/libs.versions.toml | 40 +- gradle/wrapper/gradle-wrapper.jar | Bin 55190 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 304 +++++++----- gradlew.bat | 78 +-- liblazysodium/build.gradle | 2 - liblazysodium/build.gradle.kts | 2 + libsession/build.gradle | 95 ---- libsession/build.gradle.kts | 93 ++++ libsignal/build.gradle | 40 -- libsignal/build.gradle.kts | 40 ++ scripts/drone-static-upload.sh | 2 +- settings.gradle | 15 - settings.gradle.kts | 17 + 35 files changed, 1131 insertions(+), 1106 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 app/ipToCode.gradle.kts rename app/{ => src/play}/google-services.json (100%) create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts rename {buildSrc/src/main/groovy/org/whispersystems/witness => build-logic/src/main/groovy}/WitnessPlugin.groovy (98%) create mode 100644 build-logic/src/main/kotlin/GenerateIPCountryDataPlugin.kt create mode 100644 build-logic/src/main/kotlin/RenameApkPlugin.kt delete mode 100644 build.gradle create mode 100644 build.gradle.kts delete mode 100644 buildSrc/build.gradle delete mode 100644 buildSrc/src/main/java/org/signal/signing/ApkSignerUtil.java delete mode 100644 buildSrc/src/main/resources/META-INF/gradle-plugins/witness.properties delete mode 100644 content-descriptions/build.gradle create mode 100644 content-descriptions/build.gradle.kts delete mode 100644 liblazysodium/build.gradle create mode 100644 liblazysodium/build.gradle.kts delete mode 100644 libsession/build.gradle create mode 100644 libsession/build.gradle.kts delete mode 100644 libsignal/build.gradle create mode 100644 libsignal/build.gradle.kts delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts diff --git a/.drone.jsonnet b/.drone.jsonnet index c319dc7fa6..06a452cd54 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -39,8 +39,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ 'apt-get update --allow-releaseinfo-change', - 'apt-get install -y ninja-build openjdk-17-jdk', - 'update-java-alternatives -s java-1.17.0-openjdk-amd64', + 'apt-get install -y ninja-build openjdk-21-jdk', + 'update-java-alternatives -s java-1.21.0-openjdk-amd64', './gradlew testPlayDebugUnitTestCoverageReport' ], } @@ -81,10 +81,10 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ 'apt-get update --allow-releaseinfo-change', - 'apt-get install -y ninja-build openjdk-17-jdk', - 'update-java-alternatives -s java-1.17.0-openjdk-amd64', - './gradlew assemblePlayDebug assembleWebsiteDebug', - './gradlew -Phuawei=1 assembleHuaweiDebug', + 'apt-get install -y ninja-build openjdk-21-jdk', + 'update-java-alternatives -s java-1.21.0-openjdk-amd64', + './gradlew --no-daemon assemblePlayDebug assembleWebsiteDebug', + './gradlew --no-daemon -Phuawei=1 assembleHuaweiDebug', './scripts/drone-static-upload.sh' ], } diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..e7f1ffe25d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: gradle + schedule: + interval: weekly + + - package-ecosystem: github-actions + schedule: + interval: weekly \ No newline at end of file diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d41427d41c..5f78effdc1 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -36,11 +36,11 @@ jobs: - uses: actions/checkout@v4 with: submodules: 'recursive' - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Build and test with Gradle id: build diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 42374e4d04..0000000000 --- a/app/build.gradle +++ /dev/null @@ -1,446 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' - id 'org.jetbrains.kotlin.plugin.compose' - id 'com.google.devtools.ksp' - id 'com.google.dagger.hilt.android' - id 'kotlin-parcelize' - id 'kotlinx-serialization' -} - -apply plugin: 'witness' - -configurations.configureEach { - exclude module: "commons-logging" -} - -def canonicalVersionCode = 406 -def canonicalVersionName = "1.23.0" - -def postFixSize = 10 -def abiPostFix = ['armeabi-v7a' : 1, - 'arm64-v8a' : 2, - 'x86' : 3, - 'x86_64' : 4, - 'universal' : 5] - -// Function to get the current git commit hash so we can embed it along w/ the build version. -// Note: This is visible in the SettingsActivity, right at the bottom (R.id.versionTextView). -def getGitHash = { -> - def stdout = new ByteArrayOutputStream() - exec { - commandLine "git", "rev-parse", "--short", "HEAD" - standardOutput = stdout - } - return stdout.toString().trim() -} - -android { - namespace 'network.loki.messenger' - useLibrary 'org.apache.http.legacy' - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = '17' - } - - packagingOptions { - resources { - excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro'] - } - } - - - splits { - abi { - enable !project.hasProperty('huawei') // huawei builds do not need the split variants - reset() - include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' - universalApk true - } - } - - buildFeatures { - viewBinding true - buildConfig true - } - - composeOptions { - kotlinCompilerExtensionVersion '1.5.15' - } - - defaultConfig { - versionCode canonicalVersionCode * postFixSize - versionName canonicalVersionName - - compileSdk androidCompileSdkVersion - minSdkVersion androidMinimumSdkVersion - targetSdkVersion androidTargetSdkVersion - - multiDexEnabled = true - - vectorDrawables.useSupportLibrary = true - setProperty("archivesBaseName", "session-${versionName}") - - buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" - buildConfigField "String", "GIT_HASH", "\"$getGitHash\"" - buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"" - buildConfigField "int", "CONTENT_PROXY_PORT", "443" - buildConfigField "String", "USER_AGENT", "\"OWA\"" - buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode" - resourceConfigurations += [] - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - // The following argument makes the Android Test Orchestrator run its - // "pm clear" command after each test invocation. This command ensures - // that the app's state is completely cleared between tests. - testInstrumentationRunnerArguments clearPackageData: 'true' - testOptions { - execution 'ANDROIDX_TEST_ORCHESTRATOR' - } - } - - sourceSets { - String sharedTestDir = 'src/sharedTest/java' - test.java.srcDirs += sharedTestDir - androidTest.java.srcDirs += sharedTestDir - main { - assets.srcDirs += "$buildDir/generated/binary" - } - test { - resources.srcDirs += "$buildDir/generated/binary" - resources.srcDirs += "$projectDir/src/main/assets" - } - } - - buildTypes { - release { - minifyEnabled false - } - debug { - isDefault true - minifyEnabled false - enableUnitTestCoverage true - signingConfig signingConfigs.debug - } - } - - signingConfigs { - play { - if (project.hasProperty('SESSION_STORE_FILE')) { - storeFile file(SESSION_STORE_FILE) - storePassword SESSION_STORE_PASSWORD - keyAlias SESSION_KEY_ALIAS - keyPassword SESSION_KEY_PASSWORD - } - } - huawei { - if (project.hasProperty('SESSION_HUAWEI_STORE_FILE')) { - storeFile file(SESSION_HUAWEI_STORE_FILE) - storePassword SESSION_HUAWEI_STORE_PASSWORD - keyAlias SESSION_HUAWEI_KEY_ALIAS - keyPassword SESSION_HUAWEI_KEY_PASSWORD - } - } - - debug { - // This keystore is for debug builds only and it should never be used to - // sign the release apk. - storeFile new File(rootProject.projectDir, "etc/debug.keystore") - storePassword "android" - keyAlias "androiddebugkey" - keyPassword "android" - } - } - - flavorDimensions "distribution" - productFlavors { - play { - isDefault true - dimension "distribution" - apply plugin: 'com.google.gms.google-services' - ext.websiteUpdateUrl = "null" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" - buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID" - buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" - buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"' - signingConfig signingConfigs.play - } - - huawei { - dimension "distribution" - ext.websiteUpdateUrl = "null" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" - buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI" - buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" - buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"_HUAWEI\"' - signingConfig signingConfigs.huawei - } - - website { - dimension "distribution" - ext.websiteUpdateUrl = "https://github.com/session-foundation/session-android/releases" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" - buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID" - buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\"" - buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"' - } - } - - applicationVariants.configureEach { variant -> - variant.outputs.each { output -> - def abiName = output.getFilter("ABI") ?: 'universal' - def postFix = abiPostFix.get(abiName, 0) - - def flavour = (variant.flavorName == 'huawei') ? "-huawei" : "" - - if (postFix >= postFixSize) throw new AssertionError("postFix is too large") - output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}${flavour}.apk" - output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix - } - } - - - testOptions { - unitTests { - includeAndroidResources = true - } - } - - def huaweiEnabled = project.properties['huawei'] != null - lint { - abortOnError true - baseline file('lint-baseline.xml') - } - - applicationVariants.configureEach { variant -> - if (variant.flavorName == 'huawei') { - variant.getPreBuildProvider().configure { task -> - task.doFirst { - if (!huaweiEnabled) { - def message = 'Huawei is not enabled. Please add -Phuawei command line arg. See BUILDING.md' - logger.error(message) - throw new GradleException(message) - } - } - } - } - } - - tasks.register('testPlayDebugUnitTestCoverageReport', JacocoReport) { - dependsOn 'testPlayDebugUnitTest' - - reports { - xml.required = true - } - - // Add files that should not be listed in the report (e.g. generated Files from dagger) - def fileFilter = [] - def mainSrc = "$projectDir/src/main/java" - def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/playDebug", excludes: fileFilter) - - // Compiled Kotlin class files are written into build-variant-specific subdirectories of 'build/tmp/kotlin-classes'. - classDirectories.from = files([kotlinDebugTree]) - - // To produce an accurate report, the bytecode is mapped back to the original source code. - sourceDirectories.from = files([mainSrc]) - - // Execution data generated when running the tests against classes instrumented by the JaCoCo agent. - // This is enabled with 'enableUnitTestCoverage' in the 'debug' build type. - executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec" - } - - - testNamespace 'network.loki.messenger.test' - lint { - abortOnError true - baseline file('lint-baseline.xml') - } -} - -apply { - from("ipToCode.gradle.kts") -} - -preBuild.dependsOn ipToCode - -dependencies { - implementation project(':content-descriptions') - - ksp(libs.androidx.hilt.compiler) - ksp(libs.dagger.hilt.compiler) - ksp(libs.glide.ksp) - implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.androidx.hilt.work) - - implementation(libs.hilt.android) - implementation libs.androidx.appcompat - implementation libs.androidx.recyclerview - implementation libs.material - implementation libs.flexbox - implementation libs.androidx.legacy.support.v13 - implementation libs.androidx.cardview - implementation libs.androidx.preference.ktx - implementation libs.androidx.legacy.preference.v14 - implementation libs.androidx.gridlayout - implementation libs.androidx.exifinterface - implementation libs.androidx.constraintlayout - implementation libs.androidx.lifecycle.common.java8 - implementation libs.androidx.lifecycle.runtime.ktx - implementation libs.androidx.lifecycle.livedata.ktx - implementation libs.androidx.lifecycle.process - implementation libs.androidx.lifecycle.viewmodel.compose - implementation libs.androidx.lifecycle.extensions - implementation libs.androidx.paging.runtime.ktx - implementation libs.androidx.activity.ktx - implementation libs.androidx.activity.compose - implementation libs.androidx.fragment.ktx - implementation libs.androidx.core.ktx - implementation libs.androidx.work.runtime.ktx - - playImplementation (libs.firebase.messaging) { - exclude group: 'com.google.firebase', module: 'firebase-core' - exclude group: 'com.google.firebase', module: 'firebase-analytics' - exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' - } - - if (project.hasProperty('huawei')) huaweiImplementation libs.huawei.push - - implementation libs.androidx.media3.exoplayer - implementation libs.androidx.media3.ui - implementation libs.conscrypt.android - implementation libs.aesgcmprovider - implementation libs.android - implementation libs.shortcutbadger - implementation libs.httpclient.android - implementation libs.photoview - implementation libs.glide - implementation libs.compose - implementation libs.roundedimageview - implementation libs.eventbus - implementation libs.android.image.cropper - implementation (libs.subsampling.scale.image.view) { - exclude group: 'com.android.support', module: 'support-annotations' - } - implementation (libs.tooltips) { - exclude group: 'com.android.support', module: 'appcompat-v7' - } - implementation (libs.kinkerapps.android.smsmms) { - exclude group: 'com.squareup.okhttp', module: 'okhttp' - exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' - } - implementation libs.stream - implementation libs.androidx.sqlite.ktx - implementation libs.sqlcipher.android - implementation project(":libsignal") - implementation project(":libsession") - implementation libs.kotlinx.serialization.json - implementation libs.curve25519.java - implementation project(":liblazysodium") - implementation libs.protobuf.java - implementation libs.jackson.databind - implementation libs.okhttp - implementation libs.phrase - implementation libs.copper.flow - implementation libs.kotlinx.coroutines.android - implementation libs.kovenant - implementation libs.kovenant.android - implementation libs.rxbinding - implementation libs.opencsv - testImplementation libs.junit - testImplementation libs.assertj.core - testImplementation libs.mockito.inline - testImplementation libs.mockito.kotlin - androidTestImplementation libs.mockito.android - androidTestImplementation libs.mockito.kotlin - testImplementation libs.androidx.core - testImplementation libs.androidx.core.testing - testImplementation libs.kotlinx.coroutines.testing - androidTestImplementation libs.kotlinx.coroutines.testing - // Core library - androidTestImplementation libs.androidx.core - - // AndroidJUnitRunner and JUnit Rules - androidTestImplementation libs.androidx.runner - androidTestImplementation libs.androidx.rules - - // Assertions - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.truth - testImplementation libs.truth - androidTestImplementation libs.truth - - // Espresso dependencies - androidTestImplementation libs.androidx.espresso.core - androidTestImplementation libs.androidx.espresso.contrib - androidTestImplementation libs.androidx.espresso.intents - androidTestImplementation libs.androidx.espresso.accessibility - androidTestImplementation libs.androidx.espresso.web - androidTestImplementation libs.androidx.idling.concurrent - androidTestImplementation libs.androidx.espresso.idling.resource - androidTestImplementation libs.androidx.compose.ui.test.junit4 - debugImplementation libs.androidx.compose.ui.test.manifest - androidTestUtil libs.androidx.orchestrator - - testImplementation libs.robolectric - testImplementation libs.robolectric.shadows.multidex - testImplementation libs.conscrypt.openjdk.uber // For Robolectric - testImplementation libs.turbine - - // compose - implementation platform(libs.androidx.compose.bom) - testImplementation platform(libs.androidx.compose.bom) - androidTestImplementation platform(libs.androidx.compose.bom) - - implementation libs.androidx.compose.ui - implementation libs.androidx.compose.animation - implementation libs.androidx.compose.ui.tooling - implementation libs.androidx.compose.runtime.livedata - implementation libs.androidx.compose.foundation.layout - implementation libs.androidx.compose.material3 - - androidTestImplementation libs.androidx.ui.test.junit4.android - debugImplementation libs.androidx.compose.ui.test.manifest - - // Navigation - implementation libs.androidx.navigation.fragment.ktx - implementation libs.androidx.navigation.ui.ktx - implementation libs.androidx.navigation.compose - - implementation libs.accompanist.themeadapter.appcompat - implementation libs.accompanist.permissions - implementation libs.accompanist.drawablepainter - - implementation libs.androidx.camera.camera2 - implementation libs.androidx.camera.lifecycle - implementation libs.androidx.camera.view - - implementation libs.zxing.core - - // Note: 1.1.0 is the latest stable release as of 2024/12/18 - implementation libs.androidx.biometric -} - -static def getLastCommitTimestamp() { - new ByteArrayOutputStream().withStream { os -> - return os.toString() + "000" - } -} - -/** - * Discovers supported languages listed as under the res/values- directory. - */ -def autoResConfig() { - def files = new ArrayList() - def root = file("src/main/res") - root.eachFile { f -> files.add(f.name) } - ['en'] + files.collect { f -> f =~ /^values-([a-z]{2}(-r[A-Z]{2})?)$/ } - .findAll { matcher -> matcher.find() } - .collect { matcher -> matcher.group(1) } - .sort() -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000000..087726ede1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,396 @@ +import com.android.build.api.variant.FilterConfiguration +import java.io.ByteArrayOutputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.kotlin.plugin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) + alias(libs.plugins.dependency.analysis) + alias(libs.plugins.google.services) + + id("generate-ip-country-data") + id("rename-apk") + id("witness") +} + +val huaweiEnabled = project.properties["huawei"] != null + +configurations.configureEach { + exclude(module = "commons-logging") +} + +val canonicalVersionCode = 406 +val canonicalVersionName = "1.23.0" + +val postFixSize = 10 +val abiPostFix = mapOf( + "armeabi-v7a" to 1, + "arm64-v8a" to 2, + "x86" to 3, + "x86_64" to 4, + "universal" to 5 +) + +val getGitHash = providers + .exec { + commandLine("git", "rev-parse", "--short", "HEAD") + } + .standardOutput + .asText + .map { it.trim() } + +android { + namespace = "network.loki.messenger" + useLibrary("org.apache.http.legacy") + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources.excludes += listOf( + "LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", + "META-INF/LICENSE", "META-INF/NOTICE", "META-INF/proguard/androidx-annotations.pro" + ) + } + + splits { + abi { + isEnable = !huaweiEnabled + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = true + } + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.kotlinComposeCompilerVersion.get() + } + + defaultConfig { + versionCode = canonicalVersionCode * postFixSize + versionName = canonicalVersionName + + compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() + minSdk = libs.versions.androidMinSdkVersion.get().toInt() + targetSdk = libs.versions.androidTargetSdkVersion.get().toInt() + + multiDexEnabled = true + + vectorDrawables.useSupportLibrary = true + + buildConfigField("long", "BUILD_TIMESTAMP", "${getLastCommitTimestamp()}L") + buildConfigField("String", "GIT_HASH", "\"${getGitHash.get()}\"") + buildConfigField("String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"") + buildConfigField("int", "CONTENT_PROXY_PORT", "443") + buildConfigField("String", "USER_AGENT", "\"OWA\"") + buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + } + + sourceSets { + val sharedTestDir = "src/sharedTest/java" + getByName("test").java.srcDirs(sharedTestDir) + getByName("androidTest").java.srcDirs(sharedTestDir) + + getByName("test").resources.srcDirs("$projectDir/src/main/assets") + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + + getByName("debug") { + isDefault = true + isMinifyEnabled = false + enableUnitTestCoverage = false + signingConfig = signingConfigs.getByName("debug") + } + } + + signingConfigs { + create("play") { + if (project.hasProperty("SESSION_STORE_FILE")) { + storeFile = file(project.property("SESSION_STORE_FILE")!!) + storePassword = project.property("SESSION_STORE_PASSWORD") as? String + keyAlias = project.property("SESSION_KEY_ALIAS") as? String + keyPassword = project.property("SESSION_KEY_PASSWORD") as? String + } + } + + if (huaweiEnabled) { + create("huawei") { + if (project.hasProperty("SESSION_HUAWEI_STORE_FILE")) { + storeFile = file(project.property("SESSION_HUAWEI_STORE_FILE")!!) + storePassword = project.property("SESSION_HUAWEI_STORE_PASSWORD") as? String + keyAlias = project.property("SESSION_HUAWEI_KEY_ALIAS") as? String + keyPassword = project.property("SESSION_HUAWEI_KEY_PASSWORD") as? String + } + } + } + + getByName("debug") { + storeFile = file("${rootProject.projectDir}/etc/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + flavorDimensions += "distribution" + productFlavors { + create("play") { + isDefault = true + dimension = "distribution" + ext["websiteUpdateUrl"] = "null" + buildConfigField("boolean", "PLAY_STORE_DISABLED", "false") + buildConfigField("org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID") + buildConfigField("String", "NOPLAY_UPDATE_URL", ext["websiteUpdateUrl"] as String) + buildConfigField("String", "PUSH_KEY_SUFFIX", "\"\"") + signingConfig = signingConfigs.getByName("play") + } + + if (huaweiEnabled) { + create("huawei") { + dimension = "distribution" + ext["websiteUpdateUrl"] = "null" + buildConfigField("boolean", "PLAY_STORE_DISABLED", "true") + buildConfigField("org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI") + buildConfigField("String", "NOPLAY_UPDATE_URL", ext["websiteUpdateUrl"] as String) + buildConfigField("String", "PUSH_KEY_SUFFIX", "\"_HUAWEI\"") + signingConfig = signingConfigs.getByName("huawei") + } + } + + create("website") { + dimension = "distribution" + ext["websiteUpdateUrl"] = "https://github.com/session-foundation/session-android/releases" + buildConfigField("boolean", "PLAY_STORE_DISABLED", "true") + buildConfigField("org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID") + buildConfigField("String", "NOPLAY_UPDATE_URL", "\"${ext["websiteUpdateUrl"]}\"") + buildConfigField("String", "PUSH_KEY_SUFFIX", "\"\"") + } + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } + + lint { + abortOnError = true + baseline = file("lint-baseline.xml") + } + + tasks.register("testPlayDebugUnitTestCoverageReport") { + dependsOn("testPlayDebugUnitTest") + + reports { + xml.required.set(true) + } + + val fileFilter = emptyList() + val mainSrc = "$projectDir/src/main/java" + val buildDir = project.layout.buildDirectory.get().asFile + val kotlinDebugTree = fileTree("${buildDir}/tmp/kotlin-classes/playDebug") { + exclude(fileFilter) + } + + classDirectories.setFrom(files(kotlinDebugTree)) + sourceDirectories.setFrom(files(mainSrc)) + executionData.setFrom(file("${buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec")) + } + + testNamespace = "network.loki.messenger.test" +} + +dependencies { + implementation(project(":content-descriptions")) + + ksp(libs.androidx.hilt.compiler) + ksp(libs.dagger.hilt.compiler) + ksp(libs.glide.ksp) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + + implementation(libs.hilt.android) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.recyclerview) + implementation(libs.material) + implementation(libs.flexbox) + implementation(libs.androidx.cardview) + implementation(libs.androidx.legacy.preference.v14) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.core.ktx) + + val playImplementation = configurations.maybeCreate("playImplementation") + playImplementation(libs.firebase.messaging) { + exclude(group = "com.google.firebase", module = "firebase-core") + exclude(group = "com.google.firebase", module = "firebase-analytics") + exclude(group = "com.google.firebase", module = "firebase-measurement-connector") + } + + if (huaweiEnabled) { + val huaweiImplementation = configurations.maybeCreate("huaweiImplementation") + huaweiImplementation(libs.huawei.push) + } + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.conscrypt.android) + implementation(libs.aesgcmprovider) + implementation(libs.android) + implementation(libs.shortcutbadger) + implementation(libs.photoview) + implementation(libs.glide) + implementation(libs.compose) + implementation(libs.eventbus) + implementation(libs.android.image.cropper) + implementation(libs.subsampling.scale.image.view) { + exclude(group = "com.android.support", module = "support-annotations") + } + implementation(libs.tooltips) { + exclude(group = "com.android.support", module = "appcompat-v7") + } + implementation(libs.kinkerapps.android.smsmms) { + exclude(group = "com.squareup.okhttp", module = "okhttp") + exclude(group = "com.squareup.okhttp", module = "okhttp-urlconnection") + } + implementation(libs.stream) + implementation(libs.androidx.sqlite.ktx) + implementation(libs.sqlcipher.android) + implementation(project(":libsignal")) + implementation(project(":libsession")) + implementation(libs.kotlinx.serialization.json) + implementation(project(":liblazysodium")) + implementation(libs.protobuf.java) + implementation(libs.jackson.databind) + implementation(libs.okhttp) + implementation(libs.phrase) + implementation(libs.copper.flow) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kovenant) + implementation(libs.kovenant.android) + implementation(libs.opencsv) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.rxbinding) + testImplementation(libs.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.kotlin) + androidTestImplementation(libs.mockito.android) + androidTestImplementation(libs.mockito.kotlin) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.kotlinx.coroutines.testing) + androidTestImplementation(libs.kotlinx.coroutines.testing) + androidTestImplementation(libs.androidx.core) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.rules) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.truth) + testImplementation(libs.truth) + androidTestImplementation(libs.truth) + testRuntimeOnly(libs.mockito.core) + + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.espresso.accessibility) + androidTestImplementation(libs.androidx.espresso.web) + androidTestImplementation(libs.androidx.idling.concurrent) + androidTestImplementation(libs.androidx.espresso.idling.resource) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + androidTestUtil(libs.androidx.orchestrator) + + testImplementation(libs.robolectric) + testImplementation(libs.robolectric.shadows.multidex) + testImplementation(libs.conscrypt.openjdk.uber) + testImplementation(libs.turbine) + + implementation(platform(libs.androidx.compose.bom)) + testImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + + androidTestImplementation(libs.androidx.ui.test.junit4.android) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + implementation(libs.androidx.navigation.compose) + + implementation(libs.accompanist.permissions) + implementation(libs.accompanist.drawablepainter) + + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + + implementation(libs.zxing.core) + + implementation(libs.androidx.biometric) +} + +fun getLastCommitTimestamp(): String { + return ByteArrayOutputStream().use { os -> + os.toString() + "000" + } +} + +fun autoResConfig(): List { + val files = mutableListOf() + val root = file("src/main/res") + root.listFiles()?.forEach { files.add(it.name) } + return listOf("en") + files.mapNotNull { it.takeIf { f -> f.startsWith("values-") }?.substringAfter("values-") } + .sorted() +} + +// Assign version code postfix to APKs based on ABI +androidComponents { + onVariants { variant -> + variant.outputs.forEach { output -> + val abiName = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier ?: "universal" + val versionCodeAdditions = checkNotNull(abiPostFix[abiName]) { "$abiName does not exist" } + output.versionCode.set(canonicalVersionCode * postFixSize + versionCodeAdditions) + } + } +} + +// Disable google services for non-google variants +androidComponents { + onVariants { variant -> + if (variant.flavorName != "play") { + tasks.named { it.contains("GoogleServices") } + .configureEach { enabled = false } + } + } +} \ No newline at end of file diff --git a/app/ipToCode.gradle.kts b/app/ipToCode.gradle.kts deleted file mode 100644 index 9ec2b29806..0000000000 --- a/app/ipToCode.gradle.kts +++ /dev/null @@ -1,41 +0,0 @@ -import java.io.File -import java.io.DataOutputStream -import java.io.FileOutputStream - -task("ipToCode") { - val inputFile = File("${projectDir}/geolite2_country_blocks_ipv4.csv") - - val outputDir = "${buildDir}/generated/binary" - val outputFile = File(outputDir, "geolite2_country_blocks_ipv4.bin").apply { parentFile.mkdirs() } - - outputs.file(outputFile) - - doLast { - - // Ensure the input file exists - if (!inputFile.exists()) { - throw IllegalArgumentException("Input file does not exist: ${inputFile.absolutePath}") - } - - // Create a DataOutputStream to write binary data - DataOutputStream(FileOutputStream(outputFile)).use { out -> - inputFile.useLines { lines -> - var prevCode = -1 - lines.drop(1).forEach { line -> - runCatching { - val ints = line.split(".", "/", ",") - val code = ints[5].toInt().also { if (it == prevCode) return@forEach } - val ip = ints.take(4).fold(0) { acc, s -> acc shl 8 or s.toInt() } - - out.writeInt(ip) - out.writeInt(code) - - prevCode = code - } - } - } - } - - println("Processed data written to: ${outputFile.absolutePath}") - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index de2271162d..cb30fe11f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -13,10 +13,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.fragment.app.Fragment -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionState -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.shouldShowRationale import com.squareup.phrase.Phrase import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @@ -52,11 +48,6 @@ fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { } } -@ExperimentalPermissionsApi -fun PermissionState.isPermanentlyDenied(): Boolean { - return !status.shouldShowRationale && !status.isGranted -} - fun Context.findActivity(): Activity { var context = this while (context is ContextWrapper) { diff --git a/app/google-services.json b/app/src/play/google-services.json similarity index 100% rename from app/google-services.json rename to app/src/play/google-services.json diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000000..c838f896de --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + `kotlin-dsl` + `java-gradle-plugin` + `groovy-gradle-plugin` +} + +repositories { + google() + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation(libs.agpApi) + gradleApi() +} + +gradlePlugin { + plugins { + create("generate-ip-country-data") { + id = "generate-ip-country-data" + implementationClass = "GenerateIPCountryDataPlugin" + } + + create("witness") { + id = "witness" + implementationClass = "WitnessPlugin" + } + + create("rename-apk") { + id = "rename-apk" + implementationClass = "RenameApkPlugin" + } + } +} \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000000..b5a0fabf66 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy b/build-logic/src/main/groovy/WitnessPlugin.groovy similarity index 98% rename from buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy rename to build-logic/src/main/groovy/WitnessPlugin.groovy index 31dae7e99b..909fad0169 100644 --- a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy +++ b/build-logic/src/main/groovy/WitnessPlugin.groovy @@ -1,5 +1,3 @@ -package org.whispersystems.witness - import org.gradle.api.InvalidUserDataException import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/build-logic/src/main/kotlin/GenerateIPCountryDataPlugin.kt b/build-logic/src/main/kotlin/GenerateIPCountryDataPlugin.kt new file mode 100644 index 0000000000..379cd41a7a --- /dev/null +++ b/build-logic/src/main/kotlin/GenerateIPCountryDataPlugin.kt @@ -0,0 +1,80 @@ +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.ApplicationVariant +import com.android.build.api.variant.HasUnitTest +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.internal.extensions.stdlib.capitalized +import org.gradle.kotlin.dsl.internal.sharedruntime.codegen.pluginEntriesFrom +import org.gradle.kotlin.dsl.register +import java.io.DataOutputStream +import java.io.FileOutputStream +import java.io.File + +class GenerateIPCountryDataPlugin : Plugin { + override fun apply(project: Project) { + project.plugins.withId("com.android.application") { + val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + androidComponents.onVariants { variant -> + val task = project.tasks.register("generate${variant.name.capitalized()}IpCountryData", GenerateCountryBlocksTask::class.java) { + outputDir.set(project.layout.buildDirectory.dir("generated/${variant.name}")) + } + + variant.sources.assets?.addGeneratedSourceDirectory( + task, + GenerateCountryBlocksTask::outputDir + ) + + // Also add the generated source directory to the unit test sources + (variant as? HasUnitTest)?.unitTest?.sources?.resources?.addGeneratedSourceDirectory( + task, + GenerateCountryBlocksTask::outputDir + ) + } + } + + } +} + + +abstract class GenerateCountryBlocksTask : DefaultTask() { + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val inputFile = File(project.projectDir, "geolite2_country_blocks_ipv4.csv") + check(inputFile.exists()) { "$inputFile does not exist and it is required" } + + val outputDir = outputDir.get().asFile + + outputDir.mkdirs() + + val outputFile = File(outputDir, "geolite2_country_blocks_ipv4.bin") + + // Create a DataOutputStream to write binary data + DataOutputStream(FileOutputStream(outputFile)).use { out -> + inputFile.useLines { lines -> + var prevCode = -1 + lines.drop(1).forEach { line -> + runCatching { + val ints = line.split(".", "/", ",") + val code = ints[5].toInt().also { if (it == prevCode) return@forEach } + val ip = ints.take(4).fold(0) { acc, s -> acc shl 8 or s.toInt() } + + out.writeInt(ip) + out.writeInt(code) + + prevCode = code + } + } + } + } + + println("Processed data written to: ${outputFile.absolutePath}") + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/RenameApkPlugin.kt b/build-logic/src/main/kotlin/RenameApkPlugin.kt new file mode 100644 index 0000000000..d66c785213 --- /dev/null +++ b/build-logic/src/main/kotlin/RenameApkPlugin.kt @@ -0,0 +1,63 @@ +import com.android.build.api.artifact.ArtifactTransformationRequest +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.FilterConfiguration +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.internal.extensions.stdlib.capitalized +import java.io.File +import javax.inject.Inject + +class RenameApkPlugin : Plugin { + override fun apply(project: Project) { + project.plugins.withId("com.android.application") { + val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + androidComponents.onVariants { variant -> + val taskProvider = project.tasks.register( + "rename${variant.name.capitalized()}Apk", + RenameApkTask::class.java, + variant.flavorName + ) + + val request = variant.artifacts.use(taskProvider) + .wiredWithDirectories(RenameApkTask::inputDir, RenameApkTask::outputDir) + .toTransformMany(SingleArtifact.APK) + + taskProvider.configure { + this.request.set(request) + } + } + } + } +} + +abstract class RenameApkTask @Inject constructor(private val flavourName: String?) : DefaultTask() { + @get:InputFiles + abstract val inputDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Internal + abstract val request: Property> + + @TaskAction + fun run() { + request.get() + .submit(this) { artifact -> + val abi = artifact.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier ?: "universal" + val flavorPostfix = flavourName?.let { "-$it" } ?: "" + val name = "session-${artifact.versionName}-$abi$flavorPostfix.apk" + val dst = outputDir.file(name).get().asFile + File(artifact.outputFile).copyTo(dst, overwrite = true) + dst + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle deleted file mode 100644 index ac5ae33460..0000000000 --- a/build.gradle +++ /dev/null @@ -1,79 +0,0 @@ -buildscript { - repositories { - google() - mavenCentral() - if (project.hasProperty('huawei')) maven { - url 'https://developer.huawei.com/repo/' - content { - includeGroup 'com.huawei.agconnect' - } - } - } - - dependencies { - classpath files('libs/gradle-witness.jar') - classpath "com.squareup:javapoet:1.13.0" - if (project.hasProperty('huawei')) classpath 'com.huawei.agconnect:agcp:1.9.1.300' - } -} - -// List plugins AND their versions here, but don't apply. This allows you to use the plugin -// in your module without specifying the version. -plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.plugin.serialization) apply false - alias(libs.plugins.kotlin.plugin.compose) apply false - alias(libs.plugins.ksp) apply false - alias(libs.plugins.hilt.android) apply false - alias(libs.plugins.google.services) apply false -} - -allprojects { - repositories { - maven { - url uri("https://oxen.rocks/session-foundation/libsession-util-android/maven") - content { - includeGroup('org.sessionfoundation') - } - } - - google() - mavenCentral() - maven { - url "https://raw.github.com/signalapp/maven/master/photoview/releases/" - content { - includeGroupByRegex "com\\.github\\.chrisbanes.*" - } - } - maven { - url "https://raw.github.com/signalapp/maven/master/shortcutbadger/releases/" - content { - includeGroupByRegex "me\\.leolin.*" - } - } - maven { - url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/" - content { - includeGroupByRegex "org\\.signal.*" - } - } - maven { url "https://jitpack.io" } - if (project.hasProperty('huawei')) maven { - url 'https://developer.huawei.com/repo/' - content { - includeGroup 'com.huawei.android.hms' - includeGroup 'com.huawei.agconnect' - includeGroup 'com.huawei.hmf' - includeGroup 'com.huawei.hms' - } - } - } - - project.ext { - androidMinimumSdkVersion = 26 - androidTargetSdkVersion = 35 - androidCompileSdkVersion = 35 - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000..e6e16f8ecb --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,79 @@ +buildscript { + repositories { + google() + mavenCentral() + if (project.hasProperty("huawei")) { + maven { + url = uri("https://developer.huawei.com/repo/") + content { + includeGroup("com.huawei.agconnect") + } + } + } + } + + dependencies { +// classpath(files("libs/gradle-witness.jar")) +// classpath("com.squareup:javapoet:1.13.0") + if (project.hasProperty("huawei")) { + classpath("com.huawei.agconnect:agcp:1.9.1.300") + } + } +} + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.plugin.serialization) apply false + alias(libs.plugins.kotlin.plugin.parcelize) apply false + alias(libs.plugins.kotlin.plugin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.dependency.analysis) apply false +} + +allprojects { + repositories { + maven { + url = uri("https://oxen.rocks/session-foundation/libsession-util-android/maven") + content { + includeGroup("org.sessionfoundation") + } + } + + google() + mavenCentral() + maven { + url = uri("https://raw.github.com/signalapp/maven/master/photoview/releases/") + content { + includeGroupByRegex("com\\.github\\.chrisbanes.*") + } + } + maven { + url = uri("https://raw.github.com/signalapp/maven/master/shortcutbadger/releases/") + content { + includeGroupByRegex("me\\.leolin.*") + } + } + maven { + url = uri("https://raw.github.com/signalapp/maven/master/sqlcipher/release/") + content { + includeGroupByRegex("org\\.signal.*") + } + } + maven { url = uri("https://jitpack.io") } + if (project.hasProperty("huawei")) { + maven { + url = uri("https://developer.huawei.com/repo/") + content { + includeGroup("com.huawei.android.hms") + includeGroup("com.huawei.agconnect") + includeGroup("com.huawei.hmf") + includeGroup("com.huawei.hms") + } + } + } + } +} \ No newline at end of file diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle deleted file mode 100644 index a039f0df97..0000000000 --- a/buildSrc/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -apply plugin: 'java-gradle-plugin' - -repositories { - google() - mavenCentral() -} - -dependencies { - implementation 'com.android.tools.build:apksig:4.0.2' -} diff --git a/buildSrc/src/main/java/org/signal/signing/ApkSignerUtil.java b/buildSrc/src/main/java/org/signal/signing/ApkSignerUtil.java deleted file mode 100644 index 144d55e17b..0000000000 --- a/buildSrc/src/main/java/org/signal/signing/ApkSignerUtil.java +++ /dev/null @@ -1,141 +0,0 @@ -package org.signal.signing; - -import com.android.apksig.ApkSigner; -import com.android.apksig.apk.ApkFormatException; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.security.InvalidKeyException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.Provider; -import java.security.Security; -import java.security.SignatureException; -import java.security.UnrecoverableKeyException; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.Collections; -import java.util.Enumeration; -import java.util.LinkedList; -import java.util.List; - -public class ApkSignerUtil { - - private final String providerClass; - - private final String providerArgument; - - private final String keyStoreType; - - private final String keyStorePassword; - - - public ApkSignerUtil(String providerClass, String providerArgument, String keyStoreType, String keyStorePassword) { - this.providerClass = providerClass; - this.providerArgument = providerArgument; - this.keyStoreType = keyStoreType; - this.keyStorePassword = keyStorePassword; - } - - public void calculateSignature(String inputApkFile, String outputApkFile) - throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException, ApkFormatException, InvalidKeyException, SignatureException - { - System.out.println("Running calculateSignature()..."); - - if (providerClass != null) { - installProvider(providerClass, providerArgument); - } - - ApkSigner apkSigner = new ApkSigner.Builder(Collections.singletonList(loadKeyStore(keyStoreType, keyStorePassword))) - .setV1SigningEnabled(true) - .setV2SigningEnabled(true) - .setInputApk(new File(inputApkFile)) - .setOutputApk(new File(outputApkFile)) - .setOtherSignersSignaturesPreserved(false) - .build(); - - apkSigner.sign(); - } - - private void installProvider(String providerName, String providerArgument) { - try { - Class providerClass = Class.forName(providerName); - - if (!Provider.class.isAssignableFrom(providerClass)) { - throw new IllegalArgumentException("JCA Provider class " + providerClass + " not subclass of " + Provider.class.getName()); - } - - Provider provider; - - if (providerArgument != null) { - provider = (Provider) providerClass.getConstructor(String.class).newInstance(providerArgument); - } else { - provider = (Provider) providerClass.getConstructor().newInstance(); - } - - Security.addProvider(provider); - } catch (ClassNotFoundException | InstantiationException | InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - throw new IllegalArgumentException(e); - } - } - - private ApkSigner.SignerConfig loadKeyStore(String keyStoreType, String keyStorePassword) throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException { - KeyStore keyStoreEntity = KeyStore.getInstance(keyStoreType == null ? KeyStore.getDefaultType() : keyStoreType); - char[] password = getPassword(keyStorePassword); - keyStoreEntity.load(null, password); - - Enumeration aliases = keyStoreEntity.aliases(); - String keyAlias = null; - - while (aliases != null && aliases.hasMoreElements()) { - String alias = aliases.nextElement(); - if (keyStoreEntity.isKeyEntry(alias)) { - keyAlias = alias; - break; - } - } - - if (keyAlias == null) { - throw new IllegalArgumentException("Keystore has no key entries!"); - } - - PrivateKey privateKey = (PrivateKey) keyStoreEntity.getKey(keyAlias, password); - Certificate[] certificates = keyStoreEntity.getCertificateChain(keyAlias); - - if (certificates == null || certificates.length == 0) { - throw new IllegalArgumentException("Unable to load certificates!"); - } - - List results = new LinkedList<>(); - - for (Certificate certificate : certificates) { - results.add((X509Certificate)certificate); - } - - - return new ApkSigner.SignerConfig.Builder("Signal Signer", privateKey, results).build(); - } - - private char[] getPassword(String encoded) throws IOException { - if (encoded.startsWith("file:")) { - String name = encoded.substring("file:".length()); - BufferedReader reader = new BufferedReader(new FileReader(new File(name))); - String password = reader.readLine(); - - if (password.length() == 0) { - throw new IOException("Failed to read password from file: " + name); - } - - return password.toCharArray(); - } else { - return encoded.toCharArray(); - } - } - -} diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/witness.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/witness.properties deleted file mode 100644 index dae767f677..0000000000 --- a/buildSrc/src/main/resources/META-INF/gradle-plugins/witness.properties +++ /dev/null @@ -1 +0,0 @@ -implementation-class=org.whispersystems.witness.WitnessPlugin diff --git a/content-descriptions/build.gradle b/content-descriptions/build.gradle deleted file mode 100644 index 173e7d24a2..0000000000 --- a/content-descriptions/build.gradle +++ /dev/null @@ -1,41 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - namespace 'org.session.content_descriptions' - - defaultConfig { - compileSdk androidCompileSdkVersion - minSdk androidMinimumSdkVersion - targetSdkVersion androidCompileSdkVersion - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = '11' - } -} - -dependencies { - - implementation libs.androidx.core.ktx - implementation libs.androidx.appcompat - implementation libs.material - testImplementation libs.junit - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core -} \ No newline at end of file diff --git a/content-descriptions/build.gradle.kts b/content-descriptions/build.gradle.kts new file mode 100644 index 0000000000..f47af35735 --- /dev/null +++ b/content-descriptions/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "org.session.content_descriptions" + + defaultConfig { + compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() + minSdk = libs.versions.androidMinSdkVersion.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3a36026d73..ff6e0d83a7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,8 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Mon Jun 26 09:56:43 AEST 2023 -android.enableJetifier=true -org.gradle.jvmargs=-Xmx3096M -Dkotlin.daemon.jvm.options\="-Xmx3096M" +android.enableJetifier=false +org.gradle.jvmargs=-Xmx3072m android.useAndroidX=true android.nonTransitiveRClass=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec038baf7a..316d044b1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,7 @@ [versions] +androidMinSdkVersion = "26" +androidTargetSdkVersion = "35" +androidCompileSdkVersion = "35" accompanistPermissionsVersion = "0.36.0" accompanistThemeadapterAppcompatVersion = "0.33.1-alpha" activityKtxVersion = "1.10.1" @@ -12,6 +15,7 @@ biometricVersion = "1.1.0" cameraCamera2Version = "1.3.2" cardviewVersion = "1.0.0" composeBomVersion = "2025.03.01" +kotlinComposeCompilerVersion = "1.5.15" composeVersion = "1.0.0-beta01" conscryptAndroidVersion = "2.5.2" constraintlayoutVersion = "2.2.1" @@ -24,10 +28,9 @@ exifinterfaceVersion = "1.3.4" firebaseMessagingVersion = "24.0.0" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.6" -gradlePluginVersion = "8.9.0" +gradlePluginVersion = "8.10.0" +dependenciesAnalysisVersion = "2.17.0" googleServicesVersion = "4.4.2" -gridlayoutVersion = "1.0.0" -httpclientAndroidVersion = "4.3.5" jnaVersion = "5.12.1" junit = "1.1.5" kotlinVersion = "2.1.10" @@ -36,15 +39,14 @@ kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" libsessionUtilAndroidVersion = "1.0.4" -lifecycleExtensionsVersion = "2.2.0" media3ExoplayerVersion = "1.4.0" -mockitoInlineVersion = "4.11.0" +mockitoVersion = "5.2.0" +mockitoAndroidVersion = "5.17.0" +mockitoKotlinVersion = "5.4.0" navVersion = "2.8.0-beta05" appcompatVersion = "1.7.0" coreVersion = "1.16.0-rc01" coroutinesVersion = "1.9.0" -curve25519Version = "0.6.0" -jetpackHiltVersion = "1.2.0" daggerHiltVersion = "2.55" androidxHiltVersion = "1.2.0" glideVersion = "4.16.0" @@ -58,9 +60,7 @@ photoviewVersion = "2.1.3" phraseVersion = "1.2.0" lifecycleVersion = "2.7.0" materialVersion = "1.12.0" -mockitoKotlinVersion = "4.1.0" okhttpVersion = "4.12.0" -pagingVersion = "3.0.0" preferenceVersion = "1.2.1" protobufVersion = "4.29.3" recyclerviewVersion = "1.4.0" @@ -85,7 +85,6 @@ huaweiPushVersion = "6.7.0.300" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistThemeadapterAppcompatVersion" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } -accompanist-themeadapter-appcompat = { module = "com.google.accompanist:accompanist-themeadapter-appcompat", version.ref = "accompanistThemeadapterAppcompatVersion" } aesgcmprovider = { module = "org.signal:aesgcmprovider", version.ref = "aesgcmproviderVersion" } android = { module = "io.github.webrtc-sdk:android", version.ref = "androidVersion" } android-image-cropper = { module = "com.vanniktech:android-image-cropper", version.ref = "androidImageCropperVersion" } @@ -109,12 +108,9 @@ androidx-idling-concurrent = { module = "androidx.test.espresso.idling:idling-co androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navVersion" } -androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navVersion" } -androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navVersion" } androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestratorVersion" } androidx-rules = { module = "androidx.test:rules", version.ref = "testCoreVersion" } androidx-runner = { module = "androidx.test:runner", version.ref = "runnerVersion" } -androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqliteKtxVersion" } androidx-truth = { module = "androidx.test.ext:truth", version.ref = "testCoreVersion" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } @@ -122,6 +118,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiTestJunit4Version" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android" } +agpApi = { module = "com.android.tools.build:gradle-api", version.ref = "gradlePluginVersion" } assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCoreVersion" } conscrypt-openjdk-uber = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscryptAndroidVersion" } copper-flow = { module = "app.cash.copper:copper-flow", version.ref = "copperFlowVersion" } @@ -136,28 +133,20 @@ jna = { module = "net.java.dev.jna:jna", version.ref = "jnaVersion" } junit = { module = "junit:junit", version.ref = "junitVersion" } kinkerapps-android-smsmms = { module = "com.klinkerapps:android-smsmms", version.ref = "androidSmsmmsVersion" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityKtxVersion" } -androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtxVersion" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" } androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "cardviewVersion" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreVersion" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } -androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayoutVersion" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltVersion" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHiltVersion" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHiltVersion" } androidx-legacy-preference-v14 = { module = "androidx.legacy:legacy-preference-v14", version.ref = "legacySupportV13Version" } -androidx-legacy-support-v13 = { module = "androidx.legacy:legacy-support-v13", version.ref = "legacySupportV13Version" } -androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycleVersion" } -androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleVersion" } -androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycleExtensionsVersion" } -androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleVersion" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleVersion" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleVersion" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3ExoplayerVersion" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3ExoplayerVersion" } -androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "pagingVersion" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceVersion" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerviewVersion" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtxVersion" } @@ -170,15 +159,14 @@ glide = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersio glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glideVersion" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "daggerHiltVersion" } dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHiltVersion" } -httpclient-android = { module = "org.apache.httpcomponents:httpclient-android", version.ref = "httpclientAndroidVersion" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } kovenant-android = { module = "nl.komponents.kovenant:kovenant-android", version.ref = "kovenantVersion" } kovenant = { module = "nl.komponents.kovenant:kovenant", version.ref = "kovenantVersion" } material = { module = "com.google.android.material:material", version.ref = "materialVersion" } -mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoInlineVersion" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInlineVersion" } +mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoAndroidVersion" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoVersion" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } opencsv = { module = "com.opencsv:opencsv", version.ref = "opencsvVersion" } @@ -204,7 +192,9 @@ android-application = { id = "com.android.application", version.ref = "gradlePlu android-library = { id = "com.android.library", version.ref = "gradlePluginVersion" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" } +kotlin-plugin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinVersion" } kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinVersion" } ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "daggerHiltVersion" } -google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } \ No newline at end of file +google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } +dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependenciesAnalysisVersion" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 87b738cbd051603d91cc39de6cb000dd98fe6b02..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 55190 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 z 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 \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 0f8d5937c4..9b42019c79 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,22 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -9,25 +27,29 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -35,48 +57,36 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/liblazysodium/build.gradle b/liblazysodium/build.gradle deleted file mode 100644 index e53c7ca016..0000000000 --- a/liblazysodium/build.gradle +++ /dev/null @@ -1,2 +0,0 @@ -configurations.maybeCreate("default") -artifacts.add("default", file('session-lazysodium-android.aar')) \ No newline at end of file diff --git a/liblazysodium/build.gradle.kts b/liblazysodium/build.gradle.kts new file mode 100644 index 0000000000..e0bf9f1310 --- /dev/null +++ b/liblazysodium/build.gradle.kts @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file("session-lazysodium-android.aar")) \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle deleted file mode 100644 index bc1d32c636..0000000000 --- a/libsession/build.gradle +++ /dev/null @@ -1,95 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.plugin.serialization) - alias(libs.plugins.ksp) - alias(libs.plugins.hilt.android) - id 'kotlin-parcelize' -} - -android { - defaultConfig { - compileSdk androidCompileSdkVersion - minSdkVersion androidMinimumSdkVersion - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - // The following argument makes the Android Test Orchestrator run its - // "pm clear" command after each test invocation. This command ensures - // that the app's state is completely cleared between tests. - testInstrumentationRunnerArguments clearPackageData: 'true' - testOptions { - execution 'ANDROIDX_TEST_ORCHESTRATOR' - } - - sourceSets { - test { - java.srcDirs = ['src/AndroidTest/java/org/session/libsession'] - } - } - } - - buildFeatures { - buildConfig true - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = '11' - } - - namespace 'org.session.libsession' -} - -dependencies { - implementation project(":libsignal") - implementation project(":liblazysodium") - - implementation(libs.hilt.android) - ksp(libs.dagger.hilt.compiler) - ksp(libs.androidx.hilt.compiler) - - api libs.libsession.util.android - - implementation (libs.jna) { - artifact { - type = 'aar' - } - } - - implementation libs.androidx.core.ktx - implementation libs.androidx.appcompat - implementation libs.androidx.preference.ktx - implementation libs.material - implementation libs.protobuf.java - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core - implementation libs.glide - implementation libs.stream - implementation libs.roundedimageview - implementation libs.kryo - implementation libs.jackson.databind - implementation libs.curve25519.java - implementation libs.okhttp - implementation libs.phrase - implementation libs.kotlin.reflect - implementation libs.kotlinx.coroutines.android - implementation libs.kotlinx.serialization.json - implementation libs.kovenant - - implementation libs.kotlinx.datetime - - testImplementation libs.junit - testImplementation libs.assertj.core - testImplementation libs.mockito.inline - testImplementation libs.mockito.kotlin - testImplementation libs.androidx.core - testImplementation libs.androidx.core.testing - testImplementation libs.kotlinx.coroutines.testing - testImplementation libs.conscrypt.openjdk.uber - implementation libs.eventbus -} \ No newline at end of file diff --git a/libsession/build.gradle.kts b/libsession/build.gradle.kts new file mode 100644 index 0000000000..35532b3a2c --- /dev/null +++ b/libsession/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.kotlin.plugin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) +} + +android { + defaultConfig { + compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() + minSdk = libs.versions.androidMinSdkVersion.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments["clearPackageData"] = "true" + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + sourceSets { + getByName("test").java.srcDirs("src/AndroidTest/java/org/session/libsession") + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + namespace = "org.session.libsession" +} + +dependencies { + implementation(project(":libsignal")) + implementation(project(":liblazysodium")) + + implementation(libs.hilt.android) + ksp(libs.dagger.hilt.compiler) + ksp(libs.androidx.hilt.compiler) + + api(libs.libsession.util.android) + + implementation(libs.jna) { + artifact { + type = "aar" + } + } + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.preference.ktx) + implementation(libs.material) + implementation(libs.protobuf.java) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + implementation(libs.glide) + implementation(libs.stream) + implementation(libs.roundedimageview) + implementation(libs.kryo) + implementation(libs.jackson.databind) + implementation(libs.curve25519.java) + implementation(libs.okhttp) + implementation(libs.phrase) + implementation(libs.kotlin.reflect) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kovenant) + + implementation(libs.kotlinx.datetime) + + testImplementation(libs.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.kotlinx.coroutines.testing) + testImplementation(libs.conscrypt.openjdk.uber) + implementation(libs.eventbus) +} \ No newline at end of file diff --git a/libsignal/build.gradle b/libsignal/build.gradle deleted file mode 100644 index 5c2b71dfb5..0000000000 --- a/libsignal/build.gradle +++ /dev/null @@ -1,40 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - defaultConfig { - compileSdk androidCompileSdkVersion - minSdkVersion androidMinimumSdkVersion - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = '11' - } - - buildFeatures { - buildConfig = true - } - - namespace 'org.session.libsignal' -} - -dependencies { - implementation libs.androidx.annotation - implementation libs.protobuf.java - implementation libs.jackson.databind - implementation libs.curve25519.java - implementation libs.okhttp - implementation libs.kotlin.reflect - implementation libs.kotlinx.coroutines.android - implementation libs.kovenant - testImplementation libs.junit - testImplementation libs.assertj.core - testImplementation libs.conscrypt.openjdk.uber -} diff --git a/libsignal/build.gradle.kts b/libsignal/build.gradle.kts new file mode 100644 index 0000000000..9b73427497 --- /dev/null +++ b/libsignal/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + defaultConfig { + compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() + minSdk = libs.versions.androidMinSdkVersion.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + buildConfig = true + } + + namespace = "org.session.libsignal" +} + +dependencies { + implementation(libs.androidx.annotation) + implementation(libs.protobuf.java) + implementation(libs.jackson.databind) + implementation(libs.curve25519.java) + implementation(libs.okhttp) + implementation(libs.kotlin.reflect) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kovenant) + testImplementation(libs.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.conscrypt.openjdk.uber) +} \ No newline at end of file diff --git a/scripts/drone-static-upload.sh b/scripts/drone-static-upload.sh index b5c9ee83f7..6ac509dd63 100755 --- a/scripts/drone-static-upload.sh +++ b/scripts/drone-static-upload.sh @@ -19,7 +19,7 @@ chmod 600 ssh_key # Define the output paths build_dir="app/build/outputs/apk/play/debug" -target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal.apk')" +target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal-play.apk')" # Validate the paths exist if [ ! -d $build_path ]; then diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index d93906a4f2..0000000000 --- a/settings.gradle +++ /dev/null @@ -1,15 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -rootProject.name = "session-android" - -include ':app' -include ':liblazysodium' -include ':libsession' -include ':libsignal' -include ':content-descriptions' // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000000..64cca3f031 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "session-android" + +includeBuild("build-logic") + +include(":app") +include(":liblazysodium") +include(":libsession") +include(":libsignal") +include(":content-descriptions") // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing \ No newline at end of file From 8cba48335dd3b1b85fabee26388e4904a6cdeec6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 May 2025 16:08:20 +1000 Subject: [PATCH 269/867] Avoid forced scroll during search --- .../securesms/conversation/v2/ConversationActivityV2.kt | 4 ++++ .../securesms/ui/components/ConversationAppBar.kt | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index d58371a6c0..0578939520 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -814,6 +814,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } private fun scrollToMostRecentMessageIfWeShould() { + //don't do anything during search - it still needs to happen when starting the search when the keyboard opens + // so we check the state of the query as an indication that we are scrolling due to search + if(!searchViewModel.searchQuery.value.isNullOrEmpty()) return + val lm = layoutManager ?: return Log.w(TAG, "Cannot scroll recycler view without a layout manager - bailing.") // Grab an initial 'previous' last visible message.. diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 2d9d25525c..ca25a38b05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -180,10 +180,6 @@ fun ConversationAppBar( text = stringResource(R.string.cancel), style = LocalType.current.large, ) - - //todo UCS we get the weird android 15 bounce on every search query - //todo UCS I need to update the search loading view - //todo UCS show "no matches" } } } From 463e6d359760316f2347c66cf4ad8dceb7c5bde1 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 May 2025 10:40:29 +1000 Subject: [PATCH 270/867] Fix incorrect QA tags (#1155) * Fix incorrect QA tags * Fix crash --- .../preferences/appearance/AppDisguiseSettings.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index 204c6d2cf8..d2241df684 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -42,7 +42,7 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.toBitmap import com.squareup.phrase.Phrase import network.loki.messenger.R -import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel @@ -168,7 +168,7 @@ private fun AppDisguiseSettings( AlertDialog( onDismissRequest = { onCommand(AppDisguiseSettingsViewModel.Command.IconSelectDismissed) }, text = Phrase.from(LocalContext.current, R.string.appIconAndNameChangeConfirmation) - .put(APP_NAME, stringResource(R.string.app_name)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) .format() .toString(), title = stringResource(R.string.appIconAndNameChange), @@ -206,6 +206,7 @@ private fun IconItem( val selectedBorderColor = LocalColors.current.textSecondary val density = LocalDensity.current val borderStroke = Stroke(density.run { 2.dp.toPx() }) + val nameText = stringResource(name) Column( modifier = modifier @@ -230,7 +231,7 @@ private fun IconItem( } } } - .qaTag("$name option") + .qaTag("$nameText option") .selectable( selected = selected, onClick = onSelected, @@ -242,7 +243,7 @@ private fun IconItem( ) Text( - stringResource(name), + nameText, textAlign = TextAlign.Center, style = LocalType.current.large, color = textColor, From 3dd1650d33e2c8348eeeffd404ab9e8786e1df07 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 May 2025 14:50:54 +1000 Subject: [PATCH 271/867] [SES-3806] - Initial work for 16kb page size (#1156) --- gradle/libs.versions.toml | 11 ++-- .../sending_receiving/MessageDecrypter.kt | 54 ++++++++----------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 316d044b1d..f31ab89c1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,12 +12,13 @@ androidVersion = "125.6422.07" annotationVersion = "1.5.0" assertjCoreVersion = "3.11.1" biometricVersion = "1.1.0" -cameraCamera2Version = "1.3.2" +cameraCamera2Version = "1.4.2" cardviewVersion = "1.0.0" composeBomVersion = "2025.03.01" kotlinComposeCompilerVersion = "1.5.15" composeVersion = "1.0.0-beta01" -conscryptAndroidVersion = "2.5.2" +conscryptAndroidVersion = "2.5.3" +conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" copperFlowVersion = "1.0.0" coreTestingVersion = "2.2.0" @@ -31,14 +32,14 @@ fragmentKtxVersion = "1.8.6" gradlePluginVersion = "8.10.0" dependenciesAnalysisVersion = "2.17.0" googleServicesVersion = "4.4.2" -jnaVersion = "5.12.1" +jnaVersion = "5.17.0" junit = "1.1.5" kotlinVersion = "2.1.10" kotlinxDatetimeVersion = "0.6.0" kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.4" +libsessionUtilAndroidVersion = "1.0.4-1-g7d21285" media3ExoplayerVersion = "1.4.0" mockitoVersion = "5.2.0" mockitoAndroidVersion = "5.17.0" @@ -120,7 +121,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android" } agpApi = { module = "com.android.tools.build:gradle-api", version.ref = "gradlePluginVersion" } assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCoreVersion" } -conscrypt-openjdk-uber = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscryptAndroidVersion" } +conscrypt-openjdk-uber = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscryptJavaVersion" } copper-flow = { module = "app.cash.copper:copper-flow", version.ref = "copperFlowVersion" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetimeVersion" } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index f123edcc47..afb23c476e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -2,6 +2,7 @@ package org.session.libsession.messaging.sending_receiving import com.goterl.lazysodium.interfaces.Box import com.goterl.lazysodium.interfaces.Sign +import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error import org.session.libsession.messaging.utilities.SodiumUtilities @@ -66,43 +67,34 @@ object MessageDecrypter { otherBlindedPublicKey: String, serverPublicKey: String ): Pair { - if (message.size < Box.NONCEBYTES + 2) throw Error.DecryptionFailed val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.DecryptionFailed - // Calculate the shared encryption key, receiving from A to B val otherKeyBytes = Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) - val kA = if (isOutgoing) blindedKeyPair.publicKey.asBytes else otherKeyBytes - val decryptionKey = SodiumUtilities.sharedBlindedEncryptionKey( - userEdKeyPair.secretKey.asBytes, - otherKeyBytes, - kA, - if (isOutgoing) otherKeyBytes else blindedKeyPair.publicKey.asBytes - ) ?: throw Error.DecryptionFailed - // v, ct, nc = data[0], data[1:-24], data[-24:size] - val version = message.first().toInt() - if (version != 0) throw Error.DecryptionFailed - val ciphertext = message.drop(1).dropLast(Box.NONCEBYTES).toByteArray() - val nonce = message.takeLast(Box.NONCEBYTES).toByteArray() + val senderKeyBytes: ByteArray + val recipientKeyBytes: ByteArray - // Decrypt the message - val innerBytes = SodiumUtilities.decrypt(ciphertext, decryptionKey, nonce) ?: throw Error.DecryptionFailed - if (innerBytes.size < Sign.PUBLICKEYBYTES) throw Error.DecryptionFailed - - // Split up: the last 32 bytes are the sender's *unblinded* ed25519 key - val plaintextEndIndex = innerBytes.size - Sign.PUBLICKEYBYTES - val plaintext = innerBytes.slice(0 until plaintextEndIndex).toByteArray() - val senderEdPublicKey = innerBytes.slice((plaintextEndIndex until innerBytes.size)).toByteArray() - - // Verify that the inner senderEdPublicKey (A) yields the same outer kA we got with the message - val blindingFactor = SodiumUtilities.generateBlindingFactor(serverPublicKey) ?: throw Error.DecryptionFailed - val sharedSecret = SodiumUtilities.combineKeys(blindingFactor, senderEdPublicKey) ?: throw Error.DecryptionFailed - if (!kA.contentEquals(sharedSecret)) throw Error.InvalidSignature + if (isOutgoing) { + senderKeyBytes = blindedKeyPair.publicKey.asBytes + recipientKeyBytes = otherKeyBytes + } else { + senderKeyBytes = otherKeyBytes + recipientKeyBytes = blindedKeyPair.publicKey.asBytes + } - // Get the sender's X25519 public key - val senderX25519PublicKey = SodiumUtilities.toX25519(senderEdPublicKey) ?: throw Error.InvalidSignature + try { + val (sessionId, plainText) = SessionEncrypt.decryptForBlindedRecipient( + ciphertext = message, + myEd25519Privkey = userEdKeyPair.secretKey.asBytes, + openGroupPubkey = Hex.fromStringCondensed(serverPublicKey), + senderBlindedId = byteArrayOf(0x15) + senderKeyBytes, + recipientBlindId = byteArrayOf(0x15) + recipientKeyBytes, + ) - val id = AccountId(IdPrefix.STANDARD, senderX25519PublicKey) - return Pair(plaintext, id.hexString) + return plainText.data to sessionId + } catch (e: Exception) { + Log.e("MessageDecrypter", "Failed to decrypt blinded message", e) + throw Error.DecryptionFailed + } } } \ No newline at end of file From 08e0cda6676878bc49a53fe0197d3adf15ea0dfc Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 May 2025 14:58:26 +1000 Subject: [PATCH 272/867] Clear icon in testfield, and start of settings dialogs --- .../settings/ConversationSettingsDialogs.kt | 105 ++++++++++++++++++ .../settings/ConversationSettingsViewModel.kt | 40 +++++++ .../thoughtcrime/securesms/ui/Components.kt | 3 +- .../securesms/ui/components/Text.kt | 52 ++++++++- 4 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt new file mode 100644 index 0000000000..a310f0bd41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@Composable +fun ConversationSettingsDialogs( + dialogsState: ConversationSettingsViewModel.DialogsState, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit +){ + val context = LocalContext.current + + // Nickname + if(dialogsState.nicknameDialog != null){ + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideNicknameDialog) + }, + title = AnnotatedString(stringResource(R.string.nicknameSet)), + text = annotatedStringResource(Phrase.from(context, R.string.nicknameDescription) + .put(NAME_KEY, dialogsState.nicknameDialog.name) + .format()), + content = { + //todo UCS add input + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.save)), + onClick = { + // delete messages based on chosen option + sendCommand(SetNickname("")) //todo UCS set real data (or will it be in the VM already?) + } //todo UCS handle disabled + ), + DialogButtonModel( + text = GetString(stringResource(R.string.remove)), + color = LocalColors.current.danger, + onClick = { + sendCommand(RemoveNickname) + } //todo UCS handle disabled + ) + ) + ) + } +} + +@Preview +@Composable +fun PreviewNicknameSetDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Thomas", + nickname = "Toto" + ) + ), + sendCommand = {} + ) + } +} + +@Preview +@Composable +fun PreviewNicknameEmptytDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Thomas", + nickname = null + ) + ), + sendCommand = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 6bb380a61e..4971a1c003 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -95,6 +95,9 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) val uiState: StateFlow = _uiState + private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) + val dialogState: StateFlow = _dialogState + private var recipient: Recipient? = null private val groupV2: GroupInfo.ClosedGroupInfo? by lazy { @@ -889,6 +892,22 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ClearMessagesGroupDeviceOnly -> clearMessages(false) is Commands.ClearMessagesGroupEveryone -> clearMessages(true) + + is Commands.HideNicknameDialog -> _dialogState.update { + it.copy(nicknameDialog = null) + } + + is Commands.HideGroupEditDialog -> _dialogState.update { + it.copy(groupEditDialog = null) + } + + is Commands.RemoveNickname -> { + //todo UCS implement + } + + is Commands.SetNickname -> { + //todo UCS implement + } } } @@ -944,6 +963,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( data object HideGroupAdminClearMessagesDialog : Commands data object ClearMessagesGroupDeviceOnly : Commands data object ClearMessagesGroupEveryone : Commands + + // dialogs + data object HideNicknameDialog : Commands + data object RemoveNickname : Commands + data class SetNickname(val nickname: String): Commands + + data object HideGroupEditDialog : Commands } @AssistedFactory @@ -1212,4 +1238,18 @@ class ConversationSettingsViewModel @AssistedInject constructor( @StringRes val subtitleQaTag: Int? = null, val onClick: () -> Unit ) + + data class DialogsState( + val nicknameDialog: NicknameDialogData? = null, + val groupEditDialog: NicknameDialogData? = null, + ) + + data class NicknameDialogData( + val name: String, + val nickname: String?, + ) + + data class GroupEditDialog( + val name: String? + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index e7b3ab2c1b..4266752d41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -700,7 +700,6 @@ fun SearchBar( ) Box(modifier = Modifier.weight(1f)) { - innerTextField() if (query.isEmpty() && placeholder != null) { Text( modifier = Modifier.qaTag(R.string.qa_conversation_search_input), @@ -708,6 +707,8 @@ fun SearchBar( color = LocalColors.current.textSecondary, style = LocalType.current.xl ) + } else { + innerTextField() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 69fc986da0..9ed8e3bcfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -2,15 +2,20 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent @@ -21,10 +26,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextStyle @@ -58,11 +66,23 @@ fun PreviewSessionOutlinedTextField() { placeholder = "", ) + SessionOutlinedTextField( + text = "text with clear", + placeholder = "", + showClear = true + ) + SessionOutlinedTextField( text = "", placeholder = "placeholder" ) + SessionOutlinedTextField( + text = "", + placeholder = "placeholder no clear", + showClear = true + ) + SessionOutlinedTextField( text = "text", placeholder = "", @@ -92,6 +112,7 @@ fun SessionOutlinedTextField( isTextErrorColor: Boolean = error != null, enabled: Boolean = true, singleLine: Boolean = false, + showClear: Boolean = false, ) { BasicTextField( value = text, @@ -119,15 +140,40 @@ fun SessionOutlinedTextField( ) .fillMaxWidth() .wrapContentHeight() - .padding(innerPadding) + .padding(innerPadding), ) { - innerTextField() - if (placeholder.isNotEmpty() && text.isEmpty()) { Text( text = placeholder, style = textStyle.copy(color = LocalColors.current.textSecondary), ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + ) { + innerTextField() + } + + if(showClear){ + Image( + painterResource(id = R.drawable.ic_x), + contentDescription = stringResource(R.string.clear), + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier.qaTag(R.string.qa_conversation_search_clear) + .padding(start = LocalDimensions.current.smallSpacing) + .size(LocalDimensions.current.iconSmall) + .clickable { + onChange("") + } + ) + } + } } } From 4ba92fe409f6bb6678465fe80c6c3979943dc6b4 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 May 2025 16:46:05 +1000 Subject: [PATCH 273/867] Fix image not showing up immediately when sending (#1157) --- .../conversation/v2/components/AlbumThumbnailView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index 4458a86d28..b85f0f6d9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -83,7 +83,9 @@ class AlbumThumbnailView : RelativeLayout { fun bind(glideRequests: RequestManager, message: MmsMessageRecord, isStart: Boolean, isEnd: Boolean) { - slides = message.slideDeck.thumbnailSlides.filter { it.isDone } + slides = message.slideDeck.thumbnailSlides.filter { + it.isDone || (message.isOutgoing && it.uri != null) + } if (slides.isEmpty()) { // this should never be encountered because it's checked by parent return From 8ce961b76e794d8b01fa6b4a1f38a1151370459a Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 May 2025 16:46:05 +1000 Subject: [PATCH 274/867] Fix image not showing up immediately when sending (#1157) --- .../conversation/v2/components/AlbumThumbnailView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index 4458a86d28..b85f0f6d9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -83,7 +83,9 @@ class AlbumThumbnailView : RelativeLayout { fun bind(glideRequests: RequestManager, message: MmsMessageRecord, isStart: Boolean, isEnd: Boolean) { - slides = message.slideDeck.thumbnailSlides.filter { it.isDone } + slides = message.slideDeck.thumbnailSlides.filter { + it.isDone || (message.isOutgoing && it.uri != null) + } if (slides.isEmpty()) { // this should never be encountered because it's checked by parent return From fc3f9d8afe2a7398ca90e634b250154a5f834197 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 May 2025 16:57:23 +1000 Subject: [PATCH 275/867] Nickname dialog --- .../settings/ConversationSettingsDialogs.kt | 79 +++++++++++++------ .../v2/settings/ConversationSettingsScreen.kt | 31 ++++++-- .../settings/ConversationSettingsViewModel.kt | 47 ++++++++++- .../src/main/res/values/strings.xml | 3 + 4 files changed, 127 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index a310f0bd41..9e65b4f05d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -1,33 +1,27 @@ package org.thoughtcrime.securesms.conversation.v2.settings import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.squareup.phrase.Phrase import network.loki.messenger.R -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideNicknameDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.RemoveNickname +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.SetNickname +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme @Composable @@ -49,23 +43,33 @@ fun ConversationSettingsDialogs( text = annotatedStringResource(Phrase.from(context, R.string.nicknameDescription) .put(NAME_KEY, dialogsState.nicknameDialog.name) .format()), + showCloseButton = true, content = { - //todo UCS add input + SessionOutlinedTextField( + text = dialogsState.nicknameDialog.inputNickname ?: "", + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionIdInput) + .padding(top = LocalDimensions.current.smallSpacing), + placeholder = stringResource(R.string.accountIdOrOnsEnter), + onChange = { updatedText -> + sendCommand(UpdateNickname(updatedText)) + }, + onContinue = { sendCommand(SetNickname) }, + error = dialogsState.nicknameDialog.error, + ) }, buttons = listOf( DialogButtonModel( text = GetString(stringResource(id = R.string.save)), - onClick = { - // delete messages based on chosen option - sendCommand(SetNickname("")) //todo UCS set real data (or will it be in the VM already?) - } //todo UCS handle disabled + enabled = dialogsState.nicknameDialog.setEnabled, + onClick = { sendCommand(SetNickname) } ), DialogButtonModel( text = GetString(stringResource(R.string.remove)), color = LocalColors.current.danger, + enabled = dialogsState.nicknameDialog.removeEnabled, onClick = { sendCommand(RemoveNickname) - } //todo UCS handle disabled + } ) ) ) @@ -79,8 +83,33 @@ fun PreviewNicknameSetDialog() { ConversationSettingsDialogs( dialogsState = ConversationSettingsViewModel.DialogsState( nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( - name = "Thomas", - nickname = "Toto" + name = "Rick", + currentNickname = "Razza", + inputNickname = "Rickety", + setEnabled = true, + removeEnabled = true, + error = null, + ) + ), + sendCommand = {} + ) + } +} + + +@Preview +@Composable +fun PreviewNicknameEmptyDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Rick", + currentNickname = null, + inputNickname = null, + setEnabled = false, + removeEnabled = false, + error = null, ) ), sendCommand = {} @@ -90,13 +119,17 @@ fun PreviewNicknameSetDialog() { @Preview @Composable -fun PreviewNicknameEmptytDialog() { +fun PreviewNicknameEmptyWithInputDialog() { PreviewTheme { ConversationSettingsDialogs( dialogsState = ConversationSettingsViewModel.DialogsState( nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( - name = "Thomas", - nickname = null + name = "Rick", + currentNickname = null, + inputNickname = "Rickety", + setEnabled = true, + removeEnabled = false, + error = null, ) ), sendCommand = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index cfb9377af8..c099c2ab2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +31,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,10 +56,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupDeviceOnly -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupEveryone -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupAdminClearMessagesDialog -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel @@ -97,9 +96,11 @@ fun ConversationSettingsScreen( onBack: () -> Unit, ) { val data by viewModel.uiState.collectAsState() + val dialogsState by viewModel.dialogState.collectAsState() ConversationSettings( data = data, + dialogsState = dialogsState, sendCommand = viewModel::onCommand, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope, @@ -112,6 +113,7 @@ fun ConversationSettingsScreen( @Composable fun ConversationSettings( data: ConversationSettingsViewModel.UIState, + dialogsState: ConversationSettingsViewModel.DialogsState, sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope, sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, @@ -165,7 +167,12 @@ fun ConversationSettings( // name and edit icon Row( modifier = Modifier.fillMaxWidth() - .safeContentWidth(), + .safeContentWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = { sendCommand(ShowNicknameDialog) } + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -214,7 +221,7 @@ fun ConversationSettings( val longPressLabel = stringResource(R.string.accountIDCopy) val onLongPress = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) - sendCommand(ConversationSettingsViewModel.Commands.CopyAccountId) + sendCommand(CopyAccountId) } Text( modifier = Modifier.qaTag(R.string.qa_conversation_settings_account_id) @@ -260,6 +267,12 @@ fun ConversationSettings( } // Dialogs + ConversationSettingsDialogs( + dialogsState = dialogsState, + sendCommand = sendCommand + ) + + //todo UCS move other dialogs in dialog composable if (data.showSimpleDialog != null) { AlertDialog( onDismissRequest = { @@ -506,7 +519,8 @@ private fun ConversationSettings1on1Preview() { ) ) ), - ) + ), + dialogsState = ConversationSettingsViewModel.DialogsState() ) } } @@ -595,7 +609,8 @@ private fun ConversationSettings1on1LongNamePreview() { ) ) ), - ) + ), + dialogsState = ConversationSettingsViewModel.DialogsState() ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 4971a1c003..8fd5abfcd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -897,6 +897,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( it.copy(nicknameDialog = null) } + is Commands.ShowNicknameDialog -> showNicknameDialog() + is Commands.HideGroupEditDialog -> _dialogState.update { it.copy(groupEditDialog = null) } @@ -908,6 +910,41 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.SetNickname -> { //todo UCS implement } + + is Commands.UpdateNickname -> { + //todo UCS handle error + _dialogState.update { + it.copy( + nicknameDialog = it.nicknameDialog?.copy( + inputNickname = command.nickname, + setEnabled = command.nickname.isNotEmpty() && // can save if we have an input + command.nickname != it.nicknameDialog.currentNickname // ... and it isn't the same as what is already saved + ) + ) + } + } + } + } + + fun showNicknameDialog(){ + val conversation = recipient ?: return + + val configContact = configFactory.withUserConfigs { configs -> + configs.contacts.get(conversation.address.toString()) + } + + _dialogState.update { + it.copy( + nicknameDialog = NicknameDialogData( + name = configContact?.name ?: "", + currentNickname = configContact?.nickname, + inputNickname = configContact?.nickname, + setEnabled = false, + removeEnabled = configContact?.nickname?.isEmpty() == false, // can only remove is we have a nickname already + error = null + ), + groupEditDialog = null + ) } } @@ -965,9 +1002,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( data object ClearMessagesGroupEveryone : Commands // dialogs + data object ShowNicknameDialog : Commands data object HideNicknameDialog : Commands data object RemoveNickname : Commands - data class SetNickname(val nickname: String): Commands + data object SetNickname: Commands + data class UpdateNickname(val nickname: String): Commands data object HideGroupEditDialog : Commands } @@ -1246,7 +1285,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( data class NicknameDialogData( val name: String, - val nickname: String?, + val currentNickname: String?, // the currently saved nickname, if any + val inputNickname: String?, // the nickname being inputted + val setEnabled: Boolean, + val removeEnabled: Boolean, + val error: String? ) data class GroupEditDialog( diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index b7de3488f8..c6fe963fd8 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -148,6 +148,9 @@ clear-all-messages-cancel-button clear-device-radio-option clear-everyone-radio-option + nickname-input + set-nickname-confirm-button + set-nickname-remove-button Accept name change Invite button From a5d07dd6f6614de745c0431979fbebd2855e9788 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 May 2025 17:06:44 +1000 Subject: [PATCH 276/867] Moved other dialogs into the new settings dialog file and their according state --- .../settings/ConversationSettingsDialogs.kt | 103 ++++++++++++++++++ .../v2/settings/ConversationSettingsScreen.kt | 92 ---------------- .../settings/ConversationSettingsViewModel.kt | 34 +++--- 3 files changed, 122 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 9e65b4f05d..da01388548 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -2,6 +2,10 @@ package org.thoughtcrime.securesms.conversation.v2.settings import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -9,14 +13,21 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupDeviceOnly +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupEveryone +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupAdminClearMessagesDialog import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideNicknameDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.RemoveNickname import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.SetNickname import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag @@ -31,6 +42,38 @@ fun ConversationSettingsDialogs( ){ val context = LocalContext.current + // Simple dialogs + if (dialogsState.showSimpleDialog != null) { + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideSimpleDialog) + }, + title = annotatedStringResource(dialogsState.showSimpleDialog.title), + text = annotatedStringResource(dialogsState.showSimpleDialog.message), + buttons = listOf( + DialogButtonModel( + text = GetString(dialogsState.showSimpleDialog.positiveText), + color = if(dialogsState.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger + else LocalColors.current.text, + onClick = dialogsState.showSimpleDialog.onPositive + ), + DialogButtonModel( + text = GetString(dialogsState.showSimpleDialog.negativeText), + onClick = dialogsState.showSimpleDialog.onNegative + ) + ) + ) + } + + // Group admin clear messages + if(dialogsState.groupAdminClearMessagesDialog != null) { + GroupAdminClearMessagesDialog( + groupName = dialogsState.groupAdminClearMessagesDialog.groupName, + sendCommand = sendCommand + ) + } + // Nickname if(dialogsState.nicknameDialog != null){ @@ -76,6 +119,66 @@ fun ConversationSettingsDialogs( } } +@Composable +fun GroupAdminClearMessagesDialog( + modifier: Modifier = Modifier, + groupName: String, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, +){ + var deleteForEveryone by remember { mutableStateOf(false) } + + val context = LocalContext.current + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(HideGroupAdminClearMessagesDialog) + }, + title = annotatedStringResource(R.string.groupLeave), + text = annotatedStringResource(Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) + .put(GROUP_NAME_KEY, groupName) + .format()), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearDeviceOnly)), + selected = !deleteForEveryone + ) + ) { + deleteForEveryone = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearMessagesForEveryone)), + selected = deleteForEveryone, + ) + ) { + deleteForEveryone = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.clear)), + color = LocalColors.current.danger, + onClick = { + // clear messages based on chosen option + sendCommand( + if(deleteForEveryone) ClearMessagesGroupEveryone + else ClearMessagesGroupDeviceOnly + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) +} + @Preview @Composable fun PreviewNicknameSetDialog() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index c099c2ab2f..20245dbfbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -272,38 +272,6 @@ fun ConversationSettings( sendCommand = sendCommand ) - //todo UCS move other dialogs in dialog composable - if (data.showSimpleDialog != null) { - AlertDialog( - onDismissRequest = { - // hide dialog - sendCommand(HideSimpleDialog) - }, - title = annotatedStringResource(data.showSimpleDialog.title), - text = annotatedStringResource(data.showSimpleDialog.message), - buttons = listOf( - DialogButtonModel( - text = GetString(data.showSimpleDialog.positiveText), - color = if(data.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger - else LocalColors.current.text, - onClick = data.showSimpleDialog.onPositive - ), - DialogButtonModel( - text = GetString(data.showSimpleDialog.negativeText), - onClick = data.showSimpleDialog.onNegative - ) - ) - ) - } - - // Group admin clear messages - if(data.showGroupAdminClearMessagesDialog) { - GroupAdminClearMessagesDialog( - groupName = data.name, - sendCommand = sendCommand - ) - } - // Loading if (data.showLoading) { LoadingDialog() @@ -377,66 +345,6 @@ fun ConversationSettingsSubCategory( } } -@Composable -fun GroupAdminClearMessagesDialog( - modifier: Modifier = Modifier, - groupName: String, - sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, -){ - var deleteForEveryone by remember { mutableStateOf(false) } - - val context = LocalContext.current - - AlertDialog( - modifier = modifier, - onDismissRequest = { - // hide dialog - sendCommand(HideGroupAdminClearMessagesDialog) - }, - title = annotatedStringResource(R.string.groupLeave), - text = annotatedStringResource(Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) - .put(GROUP_NAME_KEY, groupName) - .format()), - content = { - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(stringResource(R.string.clearDeviceOnly)), - selected = !deleteForEveryone - ) - ) { - deleteForEveryone = false - } - - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(stringResource(R.string.clearMessagesForEveryone)), - selected = deleteForEveryone, - ) - ) { - deleteForEveryone = true - } - }, - buttons = listOf( - DialogButtonModel( - text = GetString(stringResource(id = R.string.clear)), - color = LocalColors.current.danger, - onClick = { - // clear messages based on chosen option - sendCommand( - if(deleteForEveryone) ClearMessagesGroupEveryone - else ClearMessagesGroupDeviceOnly - ) - } - ), - DialogButtonModel( - GetString(stringResource(R.string.cancel)) - ) - ) - ) -} - @OptIn(ExperimentalSharedTransitionApi::class) @SuppressLint("UnusedContentLambdaTargetStateParameter") @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 8fd5abfcd7..66f7845be5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -492,7 +492,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmBlockUser(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.block), @@ -511,7 +511,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmUnblockUser(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.blockUnblock), @@ -550,7 +550,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmHideNTS(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.noteToSelfHide), @@ -567,7 +567,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmShowNTS(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.showNoteToSelf), @@ -607,7 +607,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmDeleteContact(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.contactDelete), @@ -640,7 +640,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmDeleteConversation(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.conversationsDelete), @@ -671,7 +671,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun confirmLeaveCommunity(){ - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.communityLeave), @@ -716,7 +716,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( conversation.isGroupV2Recipient -> { if(groupV2?.hasAdminKey() == true){ // group admin clearing messages have a dedicated custom dialog - _uiState.update { it.copy(showGroupAdminClearMessagesDialog = true) } + _dialogState.update { it.copy(groupAdminClearMessagesDialog = GroupAdminClearMessageDialog(getGroupName())) } return } else { @@ -737,7 +737,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } - _uiState.update { + _dialogState.update { it.copy( showSimpleDialog = Dialog( title = context.getString(R.string.clearMessages), @@ -792,7 +792,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun confirmLeaveGroup(){ val groupData = groupV2 ?: return - _uiState.update { + _dialogState.update { var title = R.string.groupDelete var message: CharSequence = "" @@ -882,12 +882,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( when (command) { is Commands.CopyAccountId -> copyAccountId() - is Commands.HideSimpleDialog -> _uiState.update { + is Commands.HideSimpleDialog -> _dialogState.update { it.copy(showSimpleDialog = null) } - is Commands.HideGroupAdminClearMessagesDialog -> _uiState.update { - it.copy(showGroupAdminClearMessagesDialog = false) + is Commands.HideGroupAdminClearMessagesDialog -> _dialogState.update { + it.copy(groupAdminClearMessagesDialog = null) } is Commands.ClearMessagesGroupDeviceOnly -> clearMessages(false) @@ -1238,8 +1238,6 @@ class ConversationSettingsViewModel @AssistedInject constructor( val description: String? = null, val descriptionQaTag: String? = null, val accountId: String? = null, - val showSimpleDialog: Dialog? = null, - val showGroupAdminClearMessagesDialog: Boolean = false, val showLoading: Boolean = false, val categories: List = emptyList() ) @@ -1279,8 +1277,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) data class DialogsState( + val showSimpleDialog: Dialog? = null, val nicknameDialog: NicknameDialogData? = null, val groupEditDialog: NicknameDialogData? = null, + val groupAdminClearMessagesDialog: GroupAdminClearMessageDialog? = null, ) data class NicknameDialogData( @@ -1295,4 +1295,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( data class GroupEditDialog( val name: String? ) + + data class GroupAdminClearMessageDialog( + val groupName: String + ) } From 92d0ac11a6a00de02461a3561ab8a70fdd95e894 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 10:43:38 +1000 Subject: [PATCH 277/867] Nickname edtit in UCS --- .../settings/ConversationSettingsDialogs.kt | 13 +++++- .../settings/ConversationSettingsViewModel.kt | 46 +++++++++++++++---- .../securesms/dependencies/ConfigFactory.kt | 2 + .../securesms/groups/CreateGroupViewModel.kt | 2 +- .../securesms/groups/EditGroupViewModel.kt | 1 - .../ui/components/ConversationAppBar.kt | 8 ---- .../securesms/ui/components/Text.kt | 26 ++++++----- .../src/main/res/values/strings.xml | 1 + 8 files changed, 69 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index da01388548..9162d9ae34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -2,11 +2,14 @@ package org.thoughtcrime.securesms.conversation.v2.settings import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -77,6 +80,11 @@ fun ConversationSettingsDialogs( // Nickname if(dialogsState.nicknameDialog != null){ + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + AlertDialog( onDismissRequest = { // hide dialog @@ -90,7 +98,8 @@ fun ConversationSettingsDialogs( content = { SessionOutlinedTextField( text = dialogsState.nicknameDialog.inputNickname ?: "", - modifier = Modifier.qaTag(R.string.AccessibilityId_sessionIdInput) + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_nickname_input) + .focusRequester(focusRequester) .padding(top = LocalDimensions.current.smallSpacing), placeholder = stringResource(R.string.accountIdOrOnsEnter), onChange = { updatedText -> @@ -104,12 +113,14 @@ fun ConversationSettingsDialogs( DialogButtonModel( text = GetString(stringResource(id = R.string.save)), enabled = dialogsState.nicknameDialog.setEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_nickname_set), onClick = { sendCommand(SetNickname) } ), DialogButtonModel( text = GetString(stringResource(R.string.remove)), color = LocalColors.current.danger, enabled = dialogsState.nicknameDialog.removeEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_nickname_remove), onClick = { sendCommand(RemoveNickname) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 66f7845be5..07f385c129 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -38,6 +38,7 @@ import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISI import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities @@ -55,10 +56,12 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_NAME_BYTES import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.repository.ConversationRepository @@ -893,9 +896,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ClearMessagesGroupDeviceOnly -> clearMessages(false) is Commands.ClearMessagesGroupEveryone -> clearMessages(true) - is Commands.HideNicknameDialog -> _dialogState.update { - it.copy(nicknameDialog = null) - } + is Commands.HideNicknameDialog -> hideNicknameDialog() is Commands.ShowNicknameDialog -> showNicknameDialog() @@ -904,21 +905,32 @@ class ConversationSettingsViewModel @AssistedInject constructor( } is Commands.RemoveNickname -> { - //todo UCS implement + setNickname(null) + + hideNicknameDialog() } is Commands.SetNickname -> { - //todo UCS implement + setNickname(_dialogState.value.nicknameDialog?.inputNickname) + + hideNicknameDialog() } is Commands.UpdateNickname -> { - //todo UCS handle error + val error: String? = when { + command.nickname.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.nicknameErrorShorter) + + else -> null + } + _dialogState.update { it.copy( nicknameDialog = it.nicknameDialog?.copy( inputNickname = command.nickname, - setEnabled = command.nickname.isNotEmpty() && // can save if we have an input - command.nickname != it.nicknameDialog.currentNickname // ... and it isn't the same as what is already saved + setEnabled = command.nickname.trim().isNotEmpty() && // can save if we have an input + command.nickname != it.nicknameDialog.currentNickname && // ... and it isn't the same as what is already saved + error == null, // ... and there are no errors + error = error ) ) } @@ -926,6 +938,18 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun setNickname(nickname: String?){ + val conversation = recipient ?: return + + viewModelScope.launch(Dispatchers.Default) { + val publicKey = conversation.address.toString() + + val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey) + contact.nickname = nickname + storage.setContact(contact) + } + } + fun showNicknameDialog(){ val conversation = recipient ?: return @@ -948,6 +972,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun hideNicknameDialog(){ + _dialogState.update { + it.copy(nicknameDialog = null) + } + } + private fun showLoading(){ _uiState.update { it.copy(showLoading = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 5604e06e3f..ff030a8423 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -82,6 +82,8 @@ class ConfigFactory @Inject constructor( // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have // it's changes applied (control text will still be added though) private const val CONFIG_CHANGE_BUFFER_PERIOD: Long = 2 * 60 * 1000L + + const val MAX_NAME_BYTES = 100 // max size in bytes for names } init { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt index e9133dad92..b7108adc4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -96,7 +96,7 @@ class CreateGroupViewModel @AssistedInject constructor( } // validate name length (needs to be less than 100 bytes) - if(groupName.textSizeInBytes() > MAX_GROUP_NAME_BYTES){ + if(groupName.textSizeInBytes() > ConfigFactory.MAX_NAME_BYTES){ mutableGroupNameError.value = appContext.getString(R.string.groupNameEnterShorter) return@launch } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index 2159d44358..6718bed70e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -26,7 +26,6 @@ import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.util.AvatarUtils -const val MAX_GROUP_NAME_BYTES = 100 @HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) class EditGroupViewModel @AssistedInject constructor( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index ca25a38b05..dfbd85ab4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -19,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -28,13 +26,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester @@ -42,15 +38,11 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min import network.loki.messenger.R import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.qaTag diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 9ed8e3bcfa..066b8fcd75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -177,17 +179,19 @@ fun SessionOutlinedTextField( } } - error?.let { - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - Text( - it, - modifier = Modifier - .fillMaxWidth() - .qaTag(R.string.AccessibilityId_theError), - textAlign = TextAlign.Center, - style = LocalType.current.base.bold(), - color = LocalColors.current.danger - ) + AnimatedContent (error) { errorText -> + if (errorText != null) { + Text( + errorText, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.xsSpacing) + .qaTag(R.string.AccessibilityId_theError), + textAlign = TextAlign.Center, + style = LocalType.current.base.bold(), + color = LocalColors.current.danger + ) + } } } } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index c6fe963fd8..12d9a6f871 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -151,6 +151,7 @@ nickname-input set-nickname-confirm-button set-nickname-remove-button + set-nickname-remove-button Accept name change Invite button From 2b26992182c87e6b5df0785e5ccee227e9cf6640 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 10:55:14 +1000 Subject: [PATCH 278/867] Updated tags --- .../v2/settings/ConversationSettingsDialogs.kt | 2 ++ .../conversation/v2/settings/ConversationSettingsScreen.kt | 1 + .../securesms/ui/components/ConversationAppBar.kt | 7 ++++--- .../java/org/thoughtcrime/securesms/ui/components/Text.kt | 2 +- content-descriptions/src/main/res/values/strings.xml | 7 +++++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 9162d9ae34..f5cd6626df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -155,6 +155,7 @@ fun GroupAdminClearMessagesDialog( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.clearDeviceOnly)), + qaTag = GetString(R.string.qa_conversation_settings_clear_messages_radio_device), selected = !deleteForEveryone ) ) { @@ -165,6 +166,7 @@ fun GroupAdminClearMessagesDialog( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.clearMessagesForEveryone)), + qaTag = GetString(R.string.qa_conversation_settings_clear_messages_radio_everyone), selected = deleteForEveryone, ) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 20245dbfbd..2a8b0a9e48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -191,6 +191,7 @@ fun ConversationSettings( if (data.canEditName) { Image( modifier = Modifier.padding(start = LocalDimensions.current.xxsSpacing) + .qaTag(R.string.qa_conversation_settings_edit_name) .size(LocalDimensions.current.iconSmall), painter = painterResource(R.drawable.ic_pencil), colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index dfbd85ab4a..ef246f8258 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -166,9 +166,10 @@ fun ConversationAppBar( Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) Text( - modifier = Modifier.clickable { - onSearchCanceled() - }, + modifier = Modifier.qaTag(R.string.qa_conversation_search_cancel) + .clickable { + onSearchCanceled() + }, text = stringResource(R.string.cancel), style = LocalType.current.large, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 066b8fcd75..c4fee8b1e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -186,7 +186,7 @@ fun SessionOutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(top = LocalDimensions.current.xsSpacing) - .qaTag(R.string.AccessibilityId_theError), + .qaTag(R.string.qa_input_error), textAlign = TextAlign.Center, style = LocalType.current.base.bold(), color = LocalColors.current.danger diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 12d9a6f871..7eaaad6ded 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -9,7 +9,6 @@ Confirm button Copy Account ID Copy button - Error message Hide recovery password button QR code Recovery password @@ -151,7 +150,10 @@ nickname-input set-nickname-confirm-button set-nickname-remove-button - set-nickname-remove-button + update-group-info-name-input + update-group-info-description-input + update-group-info-confirm-button + update-group-info-cancel-button Accept name change Invite button @@ -251,5 +253,6 @@ Media message Save Remove + error-message \ No newline at end of file From 542a1b752c556db83e2aef5bc42ecfc416d461aa Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 12:51:43 +1000 Subject: [PATCH 279/867] Group edit dialog and making sure the text fields place their cursor at the end when we start them with text --- .../settings/ConversationSettingsDialogs.kt | 93 +++++++++++++++++-- .../v2/settings/ConversationSettingsScreen.kt | 18 ++-- .../settings/ConversationSettingsViewModel.kt | 93 ++++++++++++++++--- .../securesms/ui/components/Text.kt | 27 +++++- 4 files changed, 199 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index f5cd6626df..2903951cf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.settings +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,14 +19,7 @@ import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupDeviceOnly -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupEveryone -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupAdminClearMessagesDialog -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideNicknameDialog -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.RemoveNickname -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.SetNickname -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString @@ -128,6 +122,68 @@ fun ConversationSettingsDialogs( ) ) } + + // Group Edit + if(dialogsState.groupEditDialog != null){ + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + //todo UCs have focus at the end of existing text + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideGroupEditDialog) + }, + title = stringResource(R.string.updateGroupInformation), + text = stringResource(R.string.updateGroupInformationDescription), + showCloseButton = true, + content = { + Column { + // group name + SessionOutlinedTextField( + text = dialogsState.groupEditDialog.inputName ?: "", + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_input) + .focusRequester(focusRequester) + .padding(top = LocalDimensions.current.smallSpacing), + placeholder = stringResource(R.string.groupNameEnter), + onChange = { updatedText -> + sendCommand(UpdateGroupName(updatedText)) + }, + error = dialogsState.groupEditDialog.errorName, + ) + + // group description + //todo UCS add max line rules + SessionOutlinedTextField( + text = dialogsState.groupEditDialog.inputtedDescription ?: "", + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_description_input) + .padding(top = LocalDimensions.current.xxsSpacing), + placeholder = stringResource(R.string.groupDescriptionEnter), + onChange = { updatedText -> + sendCommand(UpdateGroupDescription(updatedText)) + }, + error = dialogsState.groupEditDialog.errorDescription, + ) + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.save)), + enabled = dialogsState.groupEditDialog.saveEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_groupname_save), + onClick = { sendCommand(SetGroupText) } + ), + DialogButtonModel( + text = GetString(stringResource(R.string.cancel)), + color = LocalColors.current.danger, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_groupname_cancel), + ) + ) + ) + } } @Composable @@ -251,4 +307,25 @@ fun PreviewNicknameEmptyWithInputDialog() { sendCommand = {} ) } +} + +@Preview +@Composable +fun PreviewBaseGroupDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + groupEditDialog = ConversationSettingsViewModel.GroupEditDialog( + currentName = "the Crew", + inputName = null, + currentDescription = null, + inputtedDescription = null, + saveEnabled = true, + errorName = null, + errorDescription = null, + ) + ), + sendCommand = {} + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 2a8b0a9e48..dc1b2d8983 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -168,10 +168,14 @@ fun ConversationSettings( Row( modifier = Modifier.fillMaxWidth() .safeContentWidth() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = false), - onClick = { sendCommand(ShowNicknameDialog) } + .then( + // make the component clickable is there is an edit action + if (data.editCommand != null) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = { sendCommand(data.editCommand) } + ) + else Modifier ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center @@ -188,7 +192,7 @@ fun ConversationSettings( color = LocalColors.current.text ) - if (data.canEditName) { + if (data.editCommand != null) { Image( modifier = Modifier.padding(start = LocalDimensions.current.xxsSpacing) .qaTag(R.string.qa_conversation_settings_edit_name) @@ -362,7 +366,7 @@ private fun ConversationSettings1on1Preview() { animatedContentScope = this@AnimatedContent, data = ConversationSettingsViewModel.UIState( name = "Nickname", - canEditName = true, + editCommand = ShowGroupEditDialog, description = "(Real name)", accountId = "05000000000000000000000000000000000000000000000000000000000000000", avatarUIData = AvatarUIData( @@ -453,7 +457,7 @@ private fun ConversationSettings1on1LongNamePreview() { animatedContentScope = this@AnimatedContent, data = ConversationSettingsViewModel.UIState( name = "Nickname that is very long but the text shouldn't be cut off because there is no limit to the display here so it should show the whole thing", - canEditName = true, + editCommand = ShowGroupEditDialog, description = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", accountId = "05000000000000000000000000000000000000000000000000000000000000000", avatarUIData = AvatarUIData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 07f385c129..5fe97b7584 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -155,10 +155,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( } // edit name - Can edit name for 1on1, or if admin of a groupV2 - val canEditName = when { - conversation.is1on1 -> true - conversation.isGroupV2Recipient && isAdmin -> true - else -> false + val editCommand = when { + conversation.is1on1 -> Commands.ShowNicknameDialog + conversation.isGroupV2Recipient && isAdmin -> Commands.ShowGroupEditDialog + else -> null } // description / display name with QA tags @@ -437,7 +437,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( conversation.isCommunityRecipient -> context.getString(R.string.qa_conversation_settings_display_name_community) else -> null }, - canEditName = canEditName, + editCommand = editCommand, description = description, descriptionQaTag = descriptionQaTag, accountId = accountId, @@ -900,9 +900,9 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ShowNicknameDialog -> showNicknameDialog() - is Commands.HideGroupEditDialog -> _dialogState.update { - it.copy(groupEditDialog = null) - } + is Commands.ShowGroupEditDialog -> showGroupEditDialog() + + is Commands.HideGroupEditDialog -> hideGroupEditDialog() is Commands.RemoveNickname -> { setNickname(null) @@ -911,14 +911,16 @@ class ConversationSettingsViewModel @AssistedInject constructor( } is Commands.SetNickname -> { - setNickname(_dialogState.value.nicknameDialog?.inputNickname) + setNickname(_dialogState.value.nicknameDialog?.inputNickname?.trim()) hideNicknameDialog() } is Commands.UpdateNickname -> { + val trimmedName = command.nickname.trim() + val error: String? = when { - command.nickname.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.nicknameErrorShorter) + trimmedName.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.nicknameErrorShorter) else -> null } @@ -927,14 +929,45 @@ class ConversationSettingsViewModel @AssistedInject constructor( it.copy( nicknameDialog = it.nicknameDialog?.copy( inputNickname = command.nickname, - setEnabled = command.nickname.trim().isNotEmpty() && // can save if we have an input - command.nickname != it.nicknameDialog.currentNickname && // ... and it isn't the same as what is already saved + setEnabled = trimmedName.isNotEmpty() && // can save if we have an input + trimmedName != it.nicknameDialog.currentNickname && // ... and it isn't the same as what is already saved error == null, // ... and there are no errors error = error ) ) } } + + is Commands.UpdateGroupName -> { + val trimmedName = command.name.trim() + + val error: String? = when { + trimmedName.length > 200 -> context.getString(R.string.groupNameEnterShorter) + + else -> null + } + + _dialogState.update { + it.copy( + groupEditDialog = it.groupEditDialog?.copy( + inputName = command.name, + saveEnabled = trimmedName.isNotEmpty() && // can save if we have an input + trimmedName != it.groupEditDialog.currentName && // ... and it isn't the same as what is already saved + error == null, // ... and there are no errors + errorName = error + ) + ) + } + } + + is Commands.UpdateGroupDescription -> { + + } + + is Commands.SetGroupText -> { + + hideGroupEditDialog() + } } } @@ -972,12 +1005,32 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun showGroupEditDialog(){ + _dialogState.update { + it.copy(groupEditDialog = GroupEditDialog( + currentName = getGroupName(), + inputName = getGroupName(), + currentDescription = "", + inputtedDescription = "", + saveEnabled = false, + errorName = null, + errorDescription = null + )) + } + } + private fun hideNicknameDialog(){ _dialogState.update { it.copy(nicknameDialog = null) } } + private fun hideGroupEditDialog(){ + _dialogState.update { + it.copy(groupEditDialog = null) + } + } + private fun showLoading(){ _uiState.update { it.copy(showLoading = true) @@ -1037,7 +1090,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( data object RemoveNickname : Commands data object SetNickname: Commands data class UpdateNickname(val nickname: String): Commands + data class UpdateGroupName(val name: String): Commands + data class UpdateGroupDescription(val description: String): Commands + data object SetGroupText: Commands + data object ShowGroupEditDialog : Commands data object HideGroupEditDialog : Commands } @@ -1264,11 +1321,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( val avatarUIData: AvatarUIData, val name: String = "", val nameQaTag: String? = null, - val canEditName: Boolean = false, val description: String? = null, val descriptionQaTag: String? = null, val accountId: String? = null, val showLoading: Boolean = false, + val editCommand: Commands? = null, val categories: List = emptyList() ) @@ -1309,7 +1366,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( data class DialogsState( val showSimpleDialog: Dialog? = null, val nicknameDialog: NicknameDialogData? = null, - val groupEditDialog: NicknameDialogData? = null, + val groupEditDialog: GroupEditDialog? = null, val groupAdminClearMessagesDialog: GroupAdminClearMessageDialog? = null, ) @@ -1323,7 +1380,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) data class GroupEditDialog( - val name: String? + val currentName: String, // the currently saved name + val inputName: String?, // the name being inputted + val currentDescription: String?, + val inputtedDescription: String?, + val saveEnabled: Boolean, + val errorName: String?, + val errorDescription: String?, ) data class GroupAdminClearMessageDialog( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index c4fee8b1e6..19ba39c1bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -28,6 +28,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,9 +42,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit @@ -116,9 +123,25 @@ fun SessionOutlinedTextField( singleLine: Boolean = false, showClear: Boolean = false, ) { + // in order to allow the cursor to be at the end of the text by default + // we need o handle the TextFieldValue manually here + var fieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(text, TextRange(text.length))) + } + + // If caller changes 'text', mirror it and move the caret to end + LaunchedEffect(text) { + if (text != fieldValue.text) { + fieldValue = TextFieldValue(text, TextRange(text.length)) + } + } + BasicTextField( - value = text, - onValueChange = onChange, + value = fieldValue, + onValueChange = { newValue -> + fieldValue = newValue + onChange(newValue.text) // propagate only the text outward + }, modifier = modifier, textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), From 78de8df15ac1b601256b8c368eb7169baa42c419 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 13:10:39 +1000 Subject: [PATCH 280/867] min and max line management in input fields --- .../settings/ConversationSettingsDialogs.kt | 4 +- .../settings/ConversationSettingsViewModel.kt | 24 +++++++- .../thoughtcrime/securesms/ui/Components.kt | 3 +- .../securesms/ui/components/Text.kt | 60 ++++++++++--------- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 2903951cf4..378848542f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -130,7 +130,6 @@ fun ConversationSettingsDialogs( LaunchedEffect (Unit) { focusRequester.requestFocus() } - //todo UCs have focus at the end of existing text AlertDialog( onDismissRequest = { @@ -156,12 +155,13 @@ fun ConversationSettingsDialogs( ) // group description - //todo UCS add max line rules SessionOutlinedTextField( text = dialogsState.groupEditDialog.inputtedDescription ?: "", modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_description_input) .padding(top = LocalDimensions.current.xxsSpacing), placeholder = stringResource(R.string.groupDescriptionEnter), + minLines = 3, + maxLines = 12, onChange = { updatedText -> sendCommand(UpdateGroupDescription(updatedText)) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 5fe97b7584..40d96d8009 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -942,7 +942,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val trimmedName = command.name.trim() val error: String? = when { - trimmedName.length > 200 -> context.getString(R.string.groupNameEnterShorter) + trimmedName.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.groupNameEnterShorter) else -> null } @@ -953,7 +953,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( inputName = command.name, saveEnabled = trimmedName.isNotEmpty() && // can save if we have an input trimmedName != it.groupEditDialog.currentName && // ... and it isn't the same as what is already saved - error == null, // ... and there are no errors + error == null && // ... and there are no name errors + it.groupEditDialog.errorDescription == null, // ... and there are no description errors errorName = error ) ) @@ -961,7 +962,26 @@ class ConversationSettingsViewModel @AssistedInject constructor( } is Commands.UpdateGroupDescription -> { + val trimmedDescription = command.description.trim() + val error: String? = when { + trimmedDescription.length > 200 -> context.getString(R.string.updateGroupInformationEnterShorterDescription) + + else -> null + } + + _dialogState.update { + it.copy( + groupEditDialog = it.groupEditDialog?.copy( + inputtedDescription = command.description, + saveEnabled = trimmedDescription.isNotEmpty() && // can save if we have an input + trimmedDescription != it.groupEditDialog.currentName && // ... and it isn't the same as what is already saved + error == null && // ... and there are no description errors + it.groupEditDialog.errorName == null, // ... and there are no name errors + errorName = error + ) + ) + } } is Commands.SetGroupText -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 4266752d41..e7b3ab2c1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -700,6 +700,7 @@ fun SearchBar( ) Box(modifier = Modifier.weight(1f)) { + innerTextField() if (query.isEmpty() && placeholder != null) { Text( modifier = Modifier.qaTag(R.string.qa_conversation_search_input), @@ -707,8 +708,6 @@ fun SearchBar( color = LocalColors.current.textSecondary, style = LocalType.current.xl ) - } else { - innerTextField() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 19ba39c1bd..8e44320037 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import androidx.compose.ui.unit.sp import network.loki.messenger.R import org.thoughtcrime.securesms.ui.qaTag @@ -121,6 +122,8 @@ fun SessionOutlinedTextField( isTextErrorColor: Boolean = error != null, enabled: Boolean = true, singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, showClear: Boolean = false, ) { // in order to allow the cursor to be at the end of the text by default @@ -147,6 +150,7 @@ fun SessionOutlinedTextField( cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), enabled = enabled, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( onDone = { onContinue() }, onGo = { onContinue() }, @@ -154,6 +158,8 @@ fun SessionOutlinedTextField( onSend = { onContinue() }, ), singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, decorationBox = { innerTextField -> Column(modifier = Modifier.animateContentSize()) { Box( @@ -167,38 +173,38 @@ fun SessionOutlinedTextField( .wrapContentHeight() .padding(innerPadding), ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + ) { + innerTextField() + } + + if(showClear && text.isNotEmpty()){ + Image( + painterResource(id = R.drawable.ic_x), + contentDescription = stringResource(R.string.clear), + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier.qaTag(R.string.qa_conversation_search_clear) + .padding(start = LocalDimensions.current.smallSpacing) + .size(LocalDimensions.current.iconSmall) + .clickable { + onChange("") + } + ) + } + } + if (placeholder.isNotEmpty() && text.isEmpty()) { Text( text = placeholder, style = textStyle.copy(color = LocalColors.current.textSecondary), ) - } else { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f), - ) { - innerTextField() - } - - if(showClear){ - Image( - painterResource(id = R.drawable.ic_x), - contentDescription = stringResource(R.string.clear), - colorFilter = ColorFilter.tint( - LocalColors.current.textSecondary - ), - modifier = Modifier.qaTag(R.string.qa_conversation_search_clear) - .padding(start = LocalDimensions.current.smallSpacing) - .size(LocalDimensions.current.iconSmall) - .clickable { - onChange("") - } - ) - } - } } } From 5365713966e9d0777c1afcc5b33d1dd2559ebf1d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 13:42:56 +1000 Subject: [PATCH 281/867] Saving group name edit --- .../settings/ConversationSettingsViewModel.kt | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 40d96d8009..4bae3e3540 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -10,6 +10,7 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity.CLIPBOARD_SERVICE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import app.cash.copper.flow.observeQuery import com.bumptech.glide.Glide import com.squareup.phrase.Phrase @@ -89,6 +90,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val groupManagerV2: GroupManagerV2, private val prefs: TextSecurePreferences, private val lokiThreadDatabase: LokiThreadDatabase, + private val groupManager: GroupManagerV2, ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow( @@ -196,6 +198,15 @@ class ConversationSettingsViewModel @AssistedInject constructor( else -> (null to null) } + // name + val name = when { + conversation.isLocalNumber -> context.getString(R.string.noteToSelf) + + conversation.isGroupV2Recipient -> getGroupName() + + else -> conversation.name + } + // account ID val accountId = when{ conversation.is1on1 || conversation.isLocalNumber -> conversation.address.toString() @@ -428,8 +439,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( val avatarData = avatarUtils.getUIDataFromRecipient(conversation) _uiState.update { _uiState.value.copy( - name = conversation.takeUnless { it.isLocalNumber }?.name ?: context.getString( - R.string.noteToSelf), + name = name, nameQaTag = when { conversation.isLocalNumber -> context.getString(R.string.qa_conversation_settings_display_name_nts) conversation.is1on1 -> context.getString(R.string.qa_conversation_settings_display_name_1on1) @@ -785,7 +795,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } - private fun getGroupName(): String{ + private fun getGroupName(): String { val conversation = recipient ?: return "" val accountId = AccountId(conversation.address.toString()) return configFactory.withGroupConfigs(accountId) { @@ -803,7 +813,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel - val groupName = getGroupName() + val groupName = _uiState.value.name if(!groupData.shouldPoll){ message = Phrase.from(context, R.string.groupDeleteDescriptionMember) @@ -985,8 +995,29 @@ class ConversationSettingsViewModel @AssistedInject constructor( } is Commands.SetGroupText -> { + val groupData = groupV2 ?: return + val dialogData = _dialogState.value.groupEditDialog ?: return + showLoading() hideGroupEditDialog() + viewModelScope.launch { + // save name if needed + if(dialogData.inputName != dialogData.currentName) { + groupManager.setName( + AccountId(groupData.groupAccountId), + dialogData.inputName ?: dialogData.currentName + ) + } + + // save description if needed + /* if(dialogData.inputtedDescription != dialogData.currentDescription) + groupManager.setDescription( + AccountId(groupData.groupAccountId), + dialogData.inputtedDescription ?: dialogData.currentDescription + )*/ + + hideLoading() + } } } } @@ -1026,10 +1057,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( } private fun showGroupEditDialog(){ + val groupName = _uiState.value.name _dialogState.update { it.copy(groupEditDialog = GroupEditDialog( - currentName = getGroupName(), - inputName = getGroupName(), + currentName = groupName, + inputName = groupName, currentDescription = "", inputtedDescription = "", saveEnabled = false, From 807f86896bf6b1c157ff6c0322eeb486fe57f74d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 15 May 2025 14:10:51 +1000 Subject: [PATCH 282/867] Saving description --- .../v2/settings/ConversationSettingsScreen.kt | 2 +- .../settings/ConversationSettingsViewModel.kt | 24 +++++++++++++------ .../securesms/groups/GroupManagerV2Impl.kt | 11 +++++++++ .../messaging/groups/GroupManagerV2.kt | 1 + 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index dc1b2d8983..98f9ba36d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -211,7 +211,7 @@ fun ConversationSettings( modifier = Modifier.safeContentWidth() .qaTag(data.descriptionQaTag), text = data.description, - textStyle = LocalType.current.small, + textStyle = LocalType.current.base, textColor = LocalColors.current.textSecondary, buttonTextStyle = LocalType.current.base.bold(), buttonTextColor = LocalColors.current.textSecondary, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 4bae3e3540..7ebd21a773 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -26,6 +26,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -45,6 +47,7 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY @@ -124,7 +127,9 @@ class ConversationSettingsViewModel @AssistedInject constructor( .observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI), // recipient updates (context.contentResolver.observeChanges( DatabaseContentProviders.Conversation.getUriForThread(threadId) - ) as Flow<*>) // thread updates + ) as Flow<*>), // thread updates + configFactory.configUpdateNotifications.filterIsInstance() + .filter { it.groupId.hexString == recipient?.address?.toString() } ).map { recipient // return the recipient } @@ -1010,11 +1015,12 @@ class ConversationSettingsViewModel @AssistedInject constructor( } // save description if needed - /* if(dialogData.inputtedDescription != dialogData.currentDescription) + if(dialogData.inputtedDescription != dialogData.currentDescription && dialogData.inputtedDescription?.isNotEmpty() == true) { groupManager.setDescription( AccountId(groupData.groupAccountId), - dialogData.inputtedDescription ?: dialogData.currentDescription - )*/ + dialogData.inputtedDescription + ) + } hideLoading() } @@ -1034,7 +1040,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } - fun showNicknameDialog(){ + private fun showNicknameDialog(){ val conversation = recipient ?: return val configContact = configFactory.withUserConfigs { configs -> @@ -1058,17 +1064,21 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun showGroupEditDialog(){ val groupName = _uiState.value.name + val groupDescription = _uiState.value.description + _dialogState.update { it.copy(groupEditDialog = GroupEditDialog( currentName = groupName, inputName = groupName, - currentDescription = "", - inputtedDescription = "", + currentDescription = groupDescription, + inputtedDescription = groupDescription, saveEnabled = false, errorName = null, errorDescription = null )) } + + //todo UCS description is too narrow } private fun hideNicknameDialog(){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index a139a220e0..f84d9e60e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -1001,6 +1001,17 @@ class GroupManagerV2Impl @Inject constructor( MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } + override suspend fun setDescription(groupId: AccountId, newDescription: String): Unit = + scope.launchAndWait(groupId, "Set group description") { + requireAdminAccess(groupId) + + configFactory.withMutableGroupConfigs(groupId) { configs -> + if (configs.groupInfo.getDescription() != newDescription) { + configs.groupInfo.setDescription(newDescription) + } + } + } + override suspend fun requestMessageDeletion( groupId: AccountId, messageHashes: Set diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 38ce4501a9..dff1063ee3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -81,6 +81,7 @@ interface GroupManagerV2 { suspend fun handleKicked(groupId: AccountId) suspend fun setName(groupId: AccountId, newName: String) + suspend fun setDescription(groupId: AccountId, newName: String) /** * Send a request to the group to delete the given messages. From 53aeb184c77b3159d2b66e6ad9beab79e1417dc8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 16 May 2025 08:52:27 +0930 Subject: [PATCH 283/867] Feature/network page (#8) * Feature/pre network page (#6) Network Page UI and Logic * Updated tests * Removing address validation * Removed uneeded dependency * Using the crowdin strings * Latest strings --- app/src/main/AndroidManifest.xml | 19 +- .../securesms/ApplicationContext.kt | 3 + .../securesms/MediaPreviewActivity.java | 7 +- .../start/newmessage/NewMessageViewModel.kt | 1 - .../conversation/v2/ConversationActivityV2.kt | 7 +- .../v2/ConversationReactionOverlay.kt | 15 +- .../conversation/v2/ConversationViewModel.kt | 5 +- .../v2/messages/ControlMessageView.kt | 4 +- .../v2/messages/MessageUtilities.kt | 16 +- .../v2/messages/VisibleMessageView.kt | 10 +- .../securesms/database/Database.java | 1 - .../database/model/DisplayRecord.java | 5 - .../securesms/debugmenu/DebugActivity.kt | 3 +- .../securesms/debugmenu/DebugMenu.kt | 36 + .../securesms/debugmenu/DebugMenuViewModel.kt | 89 +- .../securesms/dependencies/AppModule.kt | 5 + .../securesms/dependencies/NetworkModule.kt | 20 + .../securesms/groups/compose/Components.kt | 1 - .../securesms/home/ConversationView.kt | 30 +- .../securesms/home/HomeActivity.kt | 91 +- .../securesms/home/HomeViewModel.kt | 2 +- .../home/search/GlobalSearchAdapter.kt | 20 +- .../home/search/GlobalSearchAdapterUtils.kt | 14 +- .../securesms/media/FixedTimeBuckets.kt | 13 +- .../securesms/media/MediaOverviewScreen.kt | 2 +- .../securesms/media/MediaOverviewViewModel.kt | 66 +- .../messagerequests/MessageRequestView.kt | 8 +- .../MessageRequestsActivity.kt | 10 +- .../messagerequests/MessageRequestsAdapter.kt | 4 +- .../notifications/DefaultMessageNotifier.kt | 3 - .../DeleteNotificationReceiver.java | 1 + .../MessageNotifications.kt | 3 +- .../CorrectedPreferenceFragment.java | 4 - .../PrivacySettingsPreferenceFragment.kt | 2 - .../securesms/preferences/SettingsActivity.kt | 37 +- .../tokenpage/BigDecimalSerializer.kt | 73 ++ .../securesms/tokenpage/TokenDataManager.kt | 146 +++ .../tokenpage/TokenDropNotificationWorker.kt | 140 +++ .../securesms/tokenpage/TokenPage.kt | 1021 +++++++++++++++++ .../securesms/tokenpage/TokenPageActivity.kt | 22 + .../securesms/tokenpage/TokenPageCommand.kt | 8 + .../securesms/tokenpage/TokenPageDataTypes.kt | 87 ++ .../tokenpage/TokenPageNotificationManager.kt | 72 ++ .../securesms/tokenpage/TokenPageScreen.kt | 28 + .../securesms/tokenpage/TokenPageUIState.kt | 55 + .../securesms/tokenpage/TokenPageViewModel.kt | 318 +++++ .../securesms/tokenpage/TokenRepository.kt | 117 ++ .../thoughtcrime/securesms/ui/AlertDialog.kt | 6 +- .../thoughtcrime/securesms/ui/Components.kt | 229 +++- .../org/thoughtcrime/securesms/ui/Util.kt | 38 +- .../ui/components/AnnotatedString.kt | 308 +++-- .../securesms/ui/components/BlurredImage.kt | 104 ++ .../securesms/ui/components/Border.kt | 7 +- .../securesms/ui/components/Button.kt | 27 +- .../securesms/ui/components/ButtonType.kt | 2 +- .../components/CircularProgressIndicator.kt | 3 +- .../securesms/ui/components/Text.kt | 24 +- .../securesms/ui/theme/Dimensions.kt | 2 + .../securesms/ui/theme/SessionTypography.kt | 7 +- .../securesms/ui/theme/ThemeColors.kt | 15 +- .../thoughtcrime/securesms/ui/theme/Themes.kt | 2 + .../thoughtcrime/securesms/util/DateUtils.kt | 260 +++-- .../thoughtcrime/securesms/util/NumberUtil.kt | 92 +- .../securesms/util/ScreenDensity.java | 3 - .../securesms/util/ViewUtilities.kt | 2 +- .../res/drawable/session_network_logo.xml | 12 + .../res/drawable/session_node_lines_1.xml | 11 + .../res/drawable/session_node_lines_10.xml | 11 + .../res/drawable/session_node_lines_2.xml | 11 + .../res/drawable/session_node_lines_3.xml | 11 + .../res/drawable/session_node_lines_4.xml | 11 + .../res/drawable/session_node_lines_5.xml | 11 + .../res/drawable/session_node_lines_6.xml | 11 + .../res/drawable/session_node_lines_7.xml | 11 + .../res/drawable/session_node_lines_8.xml | 11 + .../res/drawable/session_node_lines_9.xml | 11 + app/src/main/res/drawable/session_nodes_1.xml | 13 + .../main/res/drawable/session_nodes_10.xml | 86 ++ app/src/main/res/drawable/session_nodes_2.xml | 20 + app/src/main/res/drawable/session_nodes_3.xml | 28 + app/src/main/res/drawable/session_nodes_4.xml | 35 + app/src/main/res/drawable/session_nodes_5.xml | 46 + app/src/main/res/drawable/session_nodes_6.xml | 54 + app/src/main/res/drawable/session_nodes_7.xml | 62 + app/src/main/res/drawable/session_nodes_8.xml | 70 ++ app/src/main/res/drawable/session_nodes_9.xml | 78 ++ app/src/main/res/font/space_mono_bold.ttf | Bin 89020 -> 0 bytes app/src/main/res/font/space_mono_regular.ttf | Bin 93252 -> 0 bytes app/src/main/res/layout/activity_settings.xml | 2 +- app/src/main/res/values/styles.xml | 8 +- .../xml/network_security_configuration.xml | 5 + app/src/main/res/xml/preferences_privacy.xml | 2 +- .../v2/ConversationViewModelTest.kt | 1 + .../securesms/util/DateUtilsTimeSpanTests.kt | 306 +++++ .../securesms/util/NumberAbbreviatorTests.kt | 95 ++ .../securesms/util/NumberFormatterTests.kt | 68 ++ .../src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 2 +- .../messaging/file_server/FileServerApi.kt | 16 +- .../messaging/jobs/AttachmentUploadJob.kt | 2 +- .../libsession/snode/OnionRequestAPI.kt | 5 +- .../org/session/libsession/snode/SnodeAPI.kt | 1 - .../libsession/utilities/LocalisedTimeUtil.kt | 14 + .../NonTranslatableStringConstants.kt | 17 +- .../utilities/ProfilePictureUtilities.kt | 2 +- .../libsession/utilities/StringSubKeys.kt | 18 +- .../utilities/TextSecurePreferences.kt | 39 +- libsession/src/main/res/values/strings.xml | 17 +- 108 files changed, 4560 insertions(+), 451 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt create mode 100644 app/src/main/res/drawable/session_network_logo.xml create mode 100644 app/src/main/res/drawable/session_node_lines_1.xml create mode 100644 app/src/main/res/drawable/session_node_lines_10.xml create mode 100644 app/src/main/res/drawable/session_node_lines_2.xml create mode 100644 app/src/main/res/drawable/session_node_lines_3.xml create mode 100644 app/src/main/res/drawable/session_node_lines_4.xml create mode 100644 app/src/main/res/drawable/session_node_lines_5.xml create mode 100644 app/src/main/res/drawable/session_node_lines_6.xml create mode 100644 app/src/main/res/drawable/session_node_lines_7.xml create mode 100644 app/src/main/res/drawable/session_node_lines_8.xml create mode 100644 app/src/main/res/drawable/session_node_lines_9.xml create mode 100644 app/src/main/res/drawable/session_nodes_1.xml create mode 100644 app/src/main/res/drawable/session_nodes_10.xml create mode 100644 app/src/main/res/drawable/session_nodes_2.xml create mode 100644 app/src/main/res/drawable/session_nodes_3.xml create mode 100644 app/src/main/res/drawable/session_nodes_4.xml create mode 100644 app/src/main/res/drawable/session_nodes_5.xml create mode 100644 app/src/main/res/drawable/session_nodes_6.xml create mode 100644 app/src/main/res/drawable/session_nodes_7.xml create mode 100644 app/src/main/res/drawable/session_nodes_8.xml create mode 100644 app/src/main/res/drawable/session_nodes_9.xml delete mode 100644 app/src/main/res/font/space_mono_bold.ttf delete mode 100644 app/src/main/res/font/space_mono_regular.ttf create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/DateUtilsTimeSpanTests.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/NumberAbbreviatorTests.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/NumberFormatterTests.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6244803d77..b36f876881 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,7 +81,7 @@ android:networkSecurityConfig="@xml/network_security_configuration" android:supportsRtl="true" android:theme="@style/Theme.Session.DayNight" - tools:replace="android:allowBackup,android:label" > + tools:replace="android:allowBackup,android:label"> + + android:label="@string/sessionSettings" > + + + + + + + @Inject lateinit var destroyedGroupSync: Lazy @Inject lateinit var removeGroupMemberHandler: Lazy // Exists here only to start upon app starts + @Inject lateinit var tokenDataManager: Lazy // Exists here only to start upon app starts @Inject lateinit var snodeClock: Lazy @Inject lateinit var migrationManager: Lazy @Inject lateinit var appDisguiseManager: Lazy @@ -318,6 +320,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, destroyedGroupSync.get().start() adminStateSync.get().start() cleanupInvitationHandler.get().start() + tokenDataManager.get().getTokenDataWhenLoggedIn() // Start our migration process as early as possible so we can show the user a progress UI migrationManager.get().requestMigration(fromRetry = false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 795c83b43c..9fcfb07f64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -95,6 +95,7 @@ import network.loki.messenger.databinding.MediaPreviewActivityBinding; import network.loki.messenger.databinding.MediaViewPageBinding; + /** * Activity for displaying media attachments in-app */ @@ -103,7 +104,6 @@ public class MediaPreviewActivity extends ScreenLockActionBarActivity implements LoaderManager.LoaderCallbacks>, MediaRailAdapter.RailItemListener { - private final static String TAG = MediaPreviewActivity.class.getSimpleName(); private static final int UI_ANIMATION_DELAY = 300; @@ -132,6 +132,9 @@ public class MediaPreviewActivity extends ScreenLockActionBarActivity implements private boolean isFullscreen = false; private final Handler hideHandler = new Handler(Looper.myLooper()); + + @Inject DateUtils dateUtils; + private final Runnable showRunnable = () -> { getSupportActionBar().show(); }; @@ -229,7 +232,7 @@ private void updateActionBar() { CharSequence relativeTimeSpan; if (mediaItem.date > 0) { - relativeTimeSpan = DateUtils.INSTANCE.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), mediaItem.date); + relativeTimeSpan = dateUtils.getDisplayFormattedTimeSpanString(Locale.getDefault(), mediaItem.date); } else { relativeTimeSpan = getString(R.string.draft); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt index ced477ab32..d9e2dc374f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.concurrent.TimeoutException import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 2825d3958d..95b589788f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -251,6 +251,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var configFactory: ConfigFactory @Inject lateinit var groupManagerV2: GroupManagerV2 @Inject lateinit var typingStatusRepository: TypingStatusRepository @@ -2244,7 +2245,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - override fun sendVoiceMessage() { Log.i(TAG, "Sending voice message at: ${System.currentTimeMillis()}") @@ -2368,7 +2368,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { - val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) + val formattedTimestamp = dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + message.timestamp + ) builder.append("$formattedTimestamp: ") } builder.append(body) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 0e66f2e229..1f93001113 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -4,6 +4,7 @@ import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.graphics.PointF @@ -25,6 +26,9 @@ import androidx.core.view.isVisible import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -55,9 +59,6 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.DateUtils -import java.util.Locale -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class ConversationReactionOverlay : FrameLayout { @@ -99,6 +100,7 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var repository: ConversationRepository + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var lokiThreadDatabase: LokiThreadDatabase @Inject lateinit var threadDatabase: ThreadDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @@ -154,7 +156,10 @@ class ConversationReactionOverlay : FrameLayout { val conversationItemSnapshot = selectedConversationModel.bitmap conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height) conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot) - conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp) + conversationTimestamp.text = dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + messageRecord.timestamp + ) updateConversationTimestamp(messageRecord) val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this) conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR @@ -365,7 +370,6 @@ class ConversationReactionOverlay : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() - hide() } @@ -638,6 +642,7 @@ class ConversationReactionOverlay : FrameLayout { }) } + @SuppressLint("RestrictedApi") private fun initAnimators() { val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) val revealOffset = context.resources.getInteger(R.integer.reaction_scrubber_reveal_offset) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 3267dfe313..bfe393365c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -91,6 +91,7 @@ class ConversationViewModel( private val groupManagerV2: GroupManagerV2, private val callManager: CallManager, val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + val dateUtils: DateUtils, private val expiredGroupManager: ExpiredGroupManager, private val usernameUtils: UsernameUtils @@ -241,7 +242,7 @@ class ConversationViewModel( Phrase.from(application, if (admin) R.string.legacyGroupBeforeDeprecationAdmin else R.string.legacyGroupBeforeDeprecationMember) .put(DATE_KEY, time.withZoneSameInstant(ZoneId.systemDefault()) - .format(DateUtils.getMediumDateTimeFormatter()) + .format(dateUtils.getMediumDateTimeFormatter()) ) .format() @@ -1148,6 +1149,7 @@ class ConversationViewModel( private val groupManagerV2: GroupManagerV2, private val callManager: CallManager, private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + private val dateUtils: DateUtils, private val expiredGroupManager: ExpiredGroupManager, private val usernameUtils: UsernameUtils, ) : ViewModelProvider.Factory { @@ -1169,6 +1171,7 @@ class ConversationViewModel( groupManagerV2 = groupManagerV2, callManager = callManager, legacyGroupDeprecationManager = legacyGroupDeprecationManager, + dateUtils = dateUtils, expiredGroupManager = expiredGroupManager, usernameUtils = usernameUtils ) as T diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 3d605e06be..d0f2b6d47f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.findActivity import org.thoughtcrime.securesms.ui.getSubbedCharSequence import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.DateUtils import javax.inject.Inject @@ -66,6 +67,7 @@ class ControlMessageView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) @Inject lateinit var disappearingMessages: DisappearingMessages + @Inject lateinit var dateUtils: DateUtils val controlContentView: View get() = binding.controlContentView @@ -74,7 +76,7 @@ class ControlMessageView : LinearLayout { } fun bind(message: MessageRecord, previous: MessageRecord?, longPress: (() -> Unit)? = null) { - binding.dateBreakTextView.showDateBreak(message, previous) + binding.dateBreakTextView.showDateBreak(message, previous, dateUtils) binding.iconImageView.isGone = true binding.expirationTimerView.isGone = true binding.followSetting.isGone = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt index 08793d8c9a..a6068886c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt @@ -2,14 +2,18 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.widget.TextView import androidx.core.view.isVisible +import java.util.Locale +import kotlin.time.Duration.Companion.minutes import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.DateUtils -import java.util.Locale -private const val maxTimeBetweenBreaks = 5 * 60 * 1000L // 5 minutes +private val maxTimeBetweenBreaksMS = 5.minutes.inWholeMilliseconds -fun TextView.showDateBreak(message: MessageRecord, previous: MessageRecord?) { - val showDateBreak = previous == null || message.timestamp - previous.timestamp > maxTimeBetweenBreaks +fun TextView.showDateBreak(message: MessageRecord, previous: MessageRecord?, dateUtils: DateUtils) { + val showDateBreak = (previous == null || message.timestamp - previous.timestamp > maxTimeBetweenBreaksMS) isVisible = showDateBreak - text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else "" -} + text = if (showDateBreak) dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + message.timestamp + ) else "" +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 51aa593561..3a6a542f17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -84,6 +84,7 @@ class VisibleMessageView : FrameLayout { @Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var configFactory: ConfigFactoryProtocol @Inject lateinit var usernameUtils: UsernameUtils @@ -267,7 +268,10 @@ class VisibleMessageView : FrameLayout { // Date break val showDateBreak = isStartOfMessageCluster || snIsSelected - binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null + binding.dateBreakTextView.text = if (showDateBreak) dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + message.timestamp + ) else null binding.dateBreakTextView.isVisible = showDateBreak // Update message status indicator @@ -403,14 +407,14 @@ class VisibleMessageView : FrameLayout { } private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = - previous == null || previous.isControlMessage || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { + previous == null || previous.isControlMessage || !dateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { current.recipient.address != previous.recipient.address } else { current.isOutgoing != previous.isOutgoing } private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = - next == null || next.isControlMessage || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { + next == null || next.isControlMessage || !dateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { current.recipient.address != next.recipient.address } else { current.isOutgoing != next.isOutgoing diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index d592db3548..82b5b2319e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -29,7 +29,6 @@ import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.WindowDebouncer; -import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index f3bb6f8bc7..7eeec84d07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -47,9 +47,6 @@ public abstract class DisplayRecord { long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, long type, int readReceiptCount) { - // TODO: This gets hit very, very often and it likely shouldn't - place a Log.d statement in it to see. - //Log.d("[ACL]", "Creating a display record with delivery status of: " + deliveryStatus); - this.threadId = threadId; this.recipient = recipient; this.dateSent = dateSent; @@ -78,8 +75,6 @@ public boolean isDelivered() { deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; } - - public boolean isFailed() { return MmsSmsColumns.Types.isFailedMessageType(type) || MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt index b814f45c47..d89c3b78c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeActivity - @AndroidEntryPoint class DebugActivity : FullComposeActivity() { @@ -14,4 +13,4 @@ class DebugActivity : FullComposeActivity() { onClose = { finish() } ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 315043ab57..07b0264951 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -47,9 +47,12 @@ import network.loki.messenger.R import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ChangeEnvironment import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ClearTrustedDownloads +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.Copy07PrefixedBlindedPublicKey +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.CopyAccountId import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideDeprecationChangeDialog import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideEnvironmentWarningDialog import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.OverrideDeprecationState +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ScheduleTokenNotification import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowDeprecationChangeDialog import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowEnvironmentWarningDialog import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.GenerateContacts @@ -200,6 +203,7 @@ fun DebugMenu( ) } + // Fake contacts DebugCell("Generate fake contacts") { var prefix by remember { mutableStateOf("User-") } var count by remember { mutableStateOf("2000") } @@ -231,6 +235,38 @@ fun DebugMenu( } } + // Session Token + DebugCell("Session Token") { + // Schedule a test token-drop notification for 10 seconds from now + SlimOutlineButton( + modifier = Modifier.fillMaxWidth(), + text = "Schedule Token Page Notification (10s)", + onClick = { sendCommand(ScheduleTokenNotification) } + ) + } + + // Keys + DebugCell("User Details") { + + SlimOutlineButton ( + text = "Copy Account ID", + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(CopyAccountId) + } + ) + + SlimOutlineButton( + text = "Copy 07-prefixed Version Blinded Public Key", + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(Copy07PrefixedBlindedPublicKey) + } + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + // Flags DebugCell("Flags") { DebugSwitchRow( diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 779dad1d06..7f5aabd4f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.debugmenu -import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context +import android.os.Build import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -16,29 +18,35 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE -import network.loki.messenger.libsession_util.util.Sodium -import org.session.libsession.utilities.Environment -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.ConfigFactory +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.utilities.Environment +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.upsertContact +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.util.ClearDataUtils import java.time.ZonedDateTime import javax.inject.Inject + @HiltViewModel class DebugMenuViewModel @Inject constructor( @ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, + private val tokenPageNotificationManager: TokenPageNotificationManager, private val configFactory: ConfigFactory, + private val storage: StorageProtocol, private val deprecationManager: LegacyGroupDeprecationManager, private val clearDataUtils: ClearDataUtils, private val threadDb: ThreadDatabase, @@ -68,8 +76,11 @@ class DebugMenuViewModel @Inject constructor( private var temporaryEnv: Environment? = null + private val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private var temporaryDeprecatedState: LegacyGroupDeprecationManager.DeprecationState? = null + @OptIn(ExperimentalStdlibApi::class) fun onCommand(command: Commands) { when (command) { is Commands.ChangeEnvironment -> changeEnvironment() @@ -80,6 +91,41 @@ class DebugMenuViewModel @Inject constructor( is Commands.ShowEnvironmentWarningDialog -> showEnvironmentWarningDialog(command.environment) + is Commands.ScheduleTokenNotification -> { + tokenPageNotificationManager.scheduleTokenPageNotification( true) + Toast.makeText(context, "Scheduled a notification for 10s from now", Toast.LENGTH_LONG).show() + } + + is Commands.Copy07PrefixedBlindedPublicKey -> { + val secretKey = storage.getUserED25519KeyPair()?.secretKey?.asBytes + ?: throw (FileServerApi.Error.NoEd25519KeyPair) + val userBlindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + + val clip = ClipData.newPlainText("07-prefixed Version Blinded Public Key", + "07" + userBlindedKeys.pubKey.data.toHexString()) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, "Copied key to clipboard", Toast.LENGTH_SHORT).show() + } + } + + is Commands.CopyAccountId -> { + val accountId = textSecurePreferences.getLocalNumber() + val clip = ClipData.newPlainText("Account ID", accountId) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied account ID to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + is Commands.HideMessageRequest -> { textSecurePreferences.setHasHiddenMessageRequests(command.hide) _uiState.value = _uiState.value.copy(hideMessageRequests = command.hide) @@ -251,18 +297,21 @@ class DebugMenuViewModel @Inject constructor( val deprecatingStartTime: ZonedDateTime, ) - sealed interface Commands { - data object ChangeEnvironment : Commands - data class ShowEnvironmentWarningDialog(val environment: String) : Commands - data object HideEnvironmentWarningDialog : Commands - data class HideMessageRequest(val hide: Boolean) : Commands - data class HideNoteToSelf(val hide: Boolean) : Commands - data class ShowDeprecationChangeDialog(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands - data object HideDeprecationChangeDialog : Commands - data object OverrideDeprecationState : Commands - data class OverrideDeprecatedTime(val time: ZonedDateTime) : Commands - data class OverrideDeprecatingStartTime(val time: ZonedDateTime) : Commands - data object ClearTrustedDownloads: Commands - data class GenerateContacts(val prefix: String, val count: Int): Commands + sealed class Commands { + object ChangeEnvironment : Commands() + data class ShowEnvironmentWarningDialog(val environment: String) : Commands() + object HideEnvironmentWarningDialog : Commands() + object ScheduleTokenNotification : Commands() + object Copy07PrefixedBlindedPublicKey : Commands() + object CopyAccountId : Commands() + data class HideMessageRequest(val hide: Boolean) : Commands() + data class HideNoteToSelf(val hide: Boolean) : Commands() + data class ShowDeprecationChangeDialog(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands() + object HideDeprecationChangeDialog : Commands() + object OverrideDeprecationState : Commands() + data class OverrideDeprecatedTime(val time: ZonedDateTime) : Commands() + data class OverrideDeprecatingStartTime(val time: ZonedDateTime) : Commands() + object ClearTrustedDownloads: Commands() + data class GenerateContacts(val prefix: String, val count: Int): Commands() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index bfe21c0304..f49704b416 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -20,6 +20,8 @@ import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository import org.thoughtcrime.securesms.sskenvironment.ProfileManager +import org.thoughtcrime.securesms.tokenpage.TokenRepository +import org.thoughtcrime.securesms.tokenpage.TokenRepositoryImpl import javax.inject.Singleton @Module @@ -43,6 +45,9 @@ abstract class AppBindings { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + @Binds + abstract fun bindTokenRepository(repository: TokenRepositoryImpl): TokenRepository + @Binds abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2 diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt new file mode 100644 index 0000000000..e31b73e96f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import org.thoughtcrime.securesms.tokenpage.TokenRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient{ + return OkHttpClient() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index b90d342ed6..949d2e4c4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.groups.compose import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 0fdf6cdbe1..e434523d0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -13,6 +13,8 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint +import java.util.Locale +import javax.inject.Inject import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.ThemeUtil @@ -25,13 +27,12 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getConversationUnread -import java.util.Locale -import javax.inject.Inject @AndroidEntryPoint class ConversationView : LinearLayout { @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var dateUtils: DateUtils private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels @@ -56,11 +57,13 @@ class ConversationView : LinearLayout { } else { binding.iconPinned.isVisible = false } + binding.root.background = if (thread.unreadCount > 0) { ContextCompat.getDrawable(context, R.drawable.conversation_unread_background) } else { ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } + val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { binding.accentView.setBackgroundColor(ThemeUtil.getThemedColor(context, R.attr.danger)) @@ -73,28 +76,36 @@ class ConversationView : LinearLayout { // This would also not trigger the disappearing message timer which may or may not be desirable binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE } + val formattedUnreadCount = if (unreadCount == 0) { null } else { if (unreadCount < 10000) unreadCount.toString() else "9999+" } + binding.unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 1000) 12.0f else 10.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) - || (configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) + binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) || (configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroupOrCommunity) + val senderDisplayName = getTitle(thread.recipient) binding.conversationViewDisplayNameTextView.text = senderDisplayName - binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) } + binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + it + ) } + val recipient = thread.recipient binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL + val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { R.drawable.ic_volume_off } else { R.drawable.ic_notifications_mentions } + binding.muteIndicatorImageView.setImageResource(drawableRes) binding.snippetTextView.text = highlightMentions( @@ -111,11 +122,13 @@ class ConversationView : LinearLayout { } else { binding.typingIndicatorView.root.stopAnimation() } + binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE binding.statusIndicatorImageView.visibility = View.VISIBLE binding.statusIndicatorImageView.imageTintList = ColorStateList.valueOf(ThemeUtil.getThemedColor(context, android.R.attr.textColorTertiary)) // tertiary in the current xml styling is actually what figma uses as secondary text color... + when { - !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE + !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = GONE thread.isFailed -> { val drawable = ContextCompat.getDrawable(context, R.drawable.ic_triangle_alert)?.mutate() binding.statusIndicatorImageView.setImageDrawable(drawable) @@ -125,12 +138,11 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } + binding.profilePictureView.update(thread.recipient) } - fun recycle() { - binding.profilePictureView.recycle() - } + fun recycle() { binding.profilePictureView.recycle() } private fun getTitle(recipient: Recipient): String = when { recipient.isLocalNumber -> context.getString(R.string.noteToSelf) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 1f75b7e280..2613a2e761 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus @@ -77,7 +78,9 @@ import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut @@ -109,12 +112,14 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var tokenPageNotificationManager: TokenPageNotificationManager @Inject lateinit var groupManagerV2: GroupManagerV2 @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var lokiThreadDatabase: LokiThreadDatabase @Inject lateinit var sessionJobDatabase: SessionJobDatabase @Inject lateinit var clock: SnodeClock @Inject lateinit var messageNotifier: MessageNotifier + @Inject lateinit var dateUtils: DateUtils private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -125,40 +130,57 @@ class HomeActivity : ScreenLockActionBarActivity(), HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) } - private val globalSearchAdapter = GlobalSearchAdapter( - onContactClicked = { model -> - when (model) { - is GlobalSearchAdapter.Model.Message -> push { - model.messageResult.run { - putExtra(ConversationActivityV2.THREAD_ID, threadId) - putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs) - putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address) + private val globalSearchAdapter by lazy { + GlobalSearchAdapter( + dateUtils = dateUtils, + onContactClicked = { model -> + when (model) { + is GlobalSearchAdapter.Model.Message -> push { + model.messageResult.run { + putExtra(ConversationActivityV2.THREAD_ID, threadId) + putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs) + putExtra( + ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, + messageRecipient.address + ) + } } - } - is GlobalSearchAdapter.Model.SavedMessages -> push { - putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) - } - is GlobalSearchAdapter.Model.Contact -> push { - putExtra( - ConversationActivityV2.ADDRESS, - model.contact.hexString.let(Address::fromSerialized) - ) - } - is GlobalSearchAdapter.Model.GroupConversation -> model.groupId - .let { Recipient.from(this, Address.fromSerialized(it), false) } - .let(threadDb::getThreadIdIfExistsFor) - .takeIf { it >= 0 } - ?.let { - push { putExtra(ConversationActivityV2.THREAD_ID, it) } + is GlobalSearchAdapter.Model.SavedMessages -> push { + putExtra( + ConversationActivityV2.ADDRESS, + Address.fromSerialized(model.currentUserPublicKey) + ) } - else -> Log.d("Loki", "callback with model: $model") + + is GlobalSearchAdapter.Model.Contact -> push { + putExtra( + ConversationActivityV2.ADDRESS, + model.contact.hexString.let(Address::fromSerialized) + ) + } + + is GlobalSearchAdapter.Model.GroupConversation -> model.groupId + .let { Recipient.from(this, Address.fromSerialized(it), false) } + .let(threadDb::getThreadIdIfExistsFor) + .takeIf { it >= 0 } + ?.let { + push { + putExtra( + ConversationActivityV2.THREAD_ID, + it + ) + } + } + + else -> Log.d("Loki", "callback with model: $model") + } + }, + onContactLongPressed = { model -> + onSearchContactLongPress(model.contact.hexString, model.name) } - }, - onContactLongPressed = { model -> - onSearchContactLongPress(model.contact.hexString, model.name) - } - ) + ) + } private fun onSearchContactLongPress(accountId: String, contactName: String) { val bottomSheet = SearchContactActionBottomSheet.newInstance(accountId, contactName) @@ -319,6 +341,12 @@ class HomeActivity : ScreenLockActionBarActivity(), } } + // Schedule a notification about the new Token Page for 1 hour after running the updated app for the first time. + // Note: We do NOT schedule a debug notification on startup - but one may be triggered from the Debug Menu. + if (!BuildConfig.DEBUG) { + tokenPageNotificationManager.scheduleTokenPageNotification(constructDebugNotification = false) + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { homeViewModel.callBanner.collect { callBanner -> @@ -373,8 +401,7 @@ class HomeActivity : ScreenLockActionBarActivity(), private val GlobalSearchResult.groupedContacts: List get() { class NamedValue(val name: String?, val value: T) - // Unknown is temporarily to be grouped together with numbers title. - // https://optf.atlassian.net/browse/SES-2287 + // Unknown is temporarily to be grouped together with numbers title - see: SES-2287 val numbersTitle = "#" val unknownTitle = numbersTitle diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index e46c83a039..eb4c71377a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -201,7 +201,7 @@ class HomeViewModel @Inject constructor( private fun createMessageRequests( count: Int, - hidden: Boolean, + hidden: Boolean ) = if (count > 0 && !hidden) Item.MessageRequests(count) else null diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 5e0823a6a7..437751e66b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -16,9 +16,12 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.DateUtils import java.security.InvalidParameterException + class GlobalSearchAdapter( + private val dateUtils: DateUtils, private val onContactClicked: (Model) -> Unit, private val onContactLongPressed: (Model.Contact) -> Unit, ): RecyclerView.Adapter() { @@ -67,10 +70,11 @@ class GlobalSearchAdapter( LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_subheader, parent, false) ) else -> ContentView( - LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false), - onContactClicked, - onContactLongPressed - ) + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false), + dateUtils, + onContactClicked, + onContactLongPressed + ) } override fun onBindViewHolder( @@ -116,6 +120,7 @@ class GlobalSearchAdapter( class ContentView( view: View, + private val dateUtils: DateUtils, private val onContactClicked: (Model) -> Unit, private val onContactLongPressed: (Model.Contact) -> Unit, ) : RecyclerView.ViewHolder(view) { @@ -130,9 +135,10 @@ class GlobalSearchAdapter( binding.searchResultProfilePicture.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) - is Model.Contact -> bindModel(query, model) - is Model.Message -> bindModel(query, model) - is Model.SavedMessages -> bindModel(model) + is Model.Contact -> bindModel(query, model) + is Model.Message -> bindModel(query, model, dateUtils) + is Model.SavedMessages -> bindModel(model) + else -> throw InvalidParameterException("Can't display as ContentView") } binding.root.setOnClickListener { onContactClicked(model) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 48d4d11c5b..b03e255732 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -6,14 +6,12 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import java.util.Locale import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView -import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message @@ -21,6 +19,8 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMes import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SearchUtil +import java.util.Locale +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel class GlobalSearchDiff( private val oldQuery: String?, @@ -28,6 +28,7 @@ class GlobalSearchDiff( private val oldData: List, private val newData: List ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldData.size override fun getNewListSize(): Int = newData.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = @@ -118,10 +119,15 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultProfilePicture.isVisible = true } -fun ContentView.bindModel(query: String?, model: Message) = binding.apply { +fun ContentView.bindModel(query: String?, model: Message, dateUtils: DateUtils) = binding.apply { searchResultProfilePicture.isVisible = true searchResultTimestamp.isVisible = true - searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) + + searchResultTimestamp.text = dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + model.messageResult.sentTimestampMs + ) + searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt index ecfab34aab..31074b65d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt @@ -1,13 +1,12 @@ package org.thoughtcrime.securesms.media import android.content.Context -import androidx.annotation.StringRes -import network.loki.messenger.R -import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.RelativeDay import java.time.ZonedDateTime import java.time.temporal.WeekFields import java.util.Locale +import network.loki.messenger.R +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.RelativeDay /** * A data structure that describes a series of time points in the past. It's primarily @@ -35,10 +34,10 @@ class FixedTimeBuckets( * Test the given time against the buckets and return the appropriate string the time * bucket. If no bucket is appropriate, it will return null. */ - fun getBucketText(context: Context, time: ZonedDateTime): String? { + fun getBucketText(context: Context, dateUtils: DateUtils, time: ZonedDateTime): String? { return when { - time >= startOfToday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY) - time >= startOfYesterday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY) + time >= startOfToday -> dateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY) + time >= startOfYesterday -> dateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY) time >= startOfThisWeek -> context.getString(R.string.attachmentsThisWeek) time >= startOfThisMonth -> context.getString(R.string.attachmentsThisMonth) else -> null diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index cf8d51c6a8..c062931813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -46,6 +45,7 @@ import network.loki.messenger.R import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index d1cb98bdd5..9ae75e468a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -10,6 +10,12 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -41,22 +47,39 @@ import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.asSequence import org.thoughtcrime.securesms.util.observeChanges -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale class MediaOverviewViewModel( private val address: Address, private val application: Application, private val threadDatabase: ThreadDatabase, - private val mediaDatabase: MediaDatabase + private val mediaDatabase: MediaDatabase, + private val dateUtils: DateUtils ) : AndroidViewModel(application) { + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(address: Address): Factory + } + + class Factory @AssistedInject constructor( + @Assisted private val address: Address, + private val application: Application, + private val threadDatabase: ThreadDatabase, + private val mediaDatabase: MediaDatabase, + private val dateUtils: DateUtils + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MediaOverviewViewModel( + address, + application, + threadDatabase, + mediaDatabase, + dateUtils + ) as T + } + private val timeBuckets by lazy { FixedTimeBuckets() } - private val monthTimeBucketFormatter = - DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault()) + private val monthTimeBucketFormatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault()) private val recipient: SharedFlow = application.contentResolver .observeChanges(DatabaseContentProviders.Attachment.CONTENT_URI) @@ -126,7 +149,7 @@ class MediaOverviewViewModel( .groupBy { record -> val time = ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC")) - timeBuckets.getBucketText(application, time) + timeBuckets.getBucketText(application, dateUtils, time) ?: time.toLocalDate().withDayOfMonth(1) } .map { (bucket, records) -> @@ -150,7 +173,7 @@ class MediaOverviewViewModel( private fun Sequence.groupRecordsByRelativeTime(): List>> { return this .groupBy { record -> - DateUtils.getRelativeDate(application, Locale.getDefault(), record.date) + dateUtils.getRelativeDate(Locale.getDefault(), record.date) } .map { (bucket, records) -> bucket to records.map { record -> @@ -164,7 +187,6 @@ class MediaOverviewViewModel( } } - fun onItemClicked(item: MediaOverviewItem) { if (inSelectionMode.value) { if (item.slide.hasDocument()) { @@ -351,26 +373,6 @@ class MediaOverviewViewModel( } } } - - @dagger.assisted.AssistedFactory - interface AssistedFactory { - fun create(address: Address): Factory - } - - class Factory @AssistedInject constructor( - @Assisted private val address: Address, - private val application: Application, - private val threadDatabase: ThreadDatabase, - private val mediaDatabase: MediaDatabase - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = MediaOverviewViewModel( - address, - application, - threadDatabase, - mediaDatabase - ) as T - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 2faac58487..7a2397c8ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.util.DateUtils import java.util.Locale +import javax.inject.Inject class MessageRequestView : LinearLayout { private lateinit var binding: ViewMessageRequestBinding @@ -32,13 +33,16 @@ class MessageRequestView : LinearLayout { // endregion // region Updating - fun bind(thread: ThreadRecord, glide: RequestManager) { + fun bind(thread: ThreadRecord, dateUtils: DateUtils) { this.thread = thread val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.displayNameTextView.text = senderDisplayName - binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) + binding.timestampTextView.text = dateUtils.getDisplayFormattedTimeSpanString( + Locale.getDefault(), + thread.date + ) val snippet = highlightMentions( text = thread.getDisplayBody(context), formatOnly = true, // no styling here, only text formatting diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 0a5150d4f9..e835297a93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -7,21 +7,22 @@ import androidx.activity.viewModels import androidx.core.view.isVisible import androidx.loader.app.LoaderManager import androidx.loader.content.Loader +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import network.loki.messenger.R import network.loki.messenger.databinding.ActivityMessageRequestsBinding -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.push @AndroidEntryPoint @@ -31,11 +32,12 @@ class MessageRequestsActivity : ScreenLockActionBarActivity(), ConversationClick private lateinit var glide: RequestManager @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var dateUtils: DateUtils private val viewModel: MessageRequestsViewModel by viewModels() private val adapter: MessageRequestsAdapter by lazy { - MessageRequestsAdapter(context = this, cursor = null, listener = this) + MessageRequestsAdapter(context = this, cursor = null, dateUtils = dateUtils, listener = this) } override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index a9e699dcec..409dfa43e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -16,10 +16,12 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import com.bumptech.glide.RequestManager +import org.thoughtcrime.securesms.util.DateUtils class MessageRequestsAdapter( context: Context, cursor: Cursor?, + val dateUtils: DateUtils, val listener: ConversationClickListener ) : CursorRecyclerViewAdapter(context, cursor) { private val threadDatabase = DatabaseComponent.get(context).threadDatabase() @@ -44,7 +46,7 @@ class MessageRequestsAdapter( override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) { val thread = getThread(cursor)!! - viewHolder.view.bind(thread, glide) + viewHolder.view.bind(thread, dateUtils) } override fun onItemViewRecycled(holder: ViewHolder?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index adb0944826..78d97a44e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -625,9 +625,6 @@ class DefaultMessageNotifier : MessageNotifier { } } - // ACL: What is the concept behind delayed notifications? Why would we ever want this? To batch them up so - // that we get a bunch of notifications once per minute or something rather than a constant stream of them - // if that's what was incoming?!? private class DelayedNotification(private val context: Context, private val threadId: Long) : Runnable { private val canceled = AtomicBoolean(false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java index 7fb29b9bd7..e95bbbb6f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.os.AsyncTask; + import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 2ce667cc57..5b2178138b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -146,7 +147,7 @@ private fun NotificationRadioButton( .border( LocalDimensions.current.borderStroke, LocalColors.current.borders, - RoundedCornerShape(8.dp) + MaterialTheme.shapes.extraSmall ), ) { Column( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index fcaba9093b..931d5e43aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.preferences; - import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Typeface; @@ -9,12 +8,10 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; -import androidx.fragment.app.DialogFragment; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceFragmentCompat; @@ -24,7 +21,6 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - import network.loki.messenger.R; public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index a4e255408b..f0a75557ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -146,8 +146,6 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (!keyguardManager.isKeyguardSecure) { findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false - - // TODO: Ticket SES-2182 raised to investigate & fix app lock / unlock functionality -ACL 2024/06/20 findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 010c75f54c..a533b52479 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -57,6 +57,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.core.content.ContextCompat @@ -69,11 +73,14 @@ import com.canhub.cropper.CropImageOptions import com.canhub.cropper.CropImageView import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences @@ -89,6 +96,7 @@ import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogStat import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity +import org.thoughtcrime.securesms.tokenpage.TokenPageActivity import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Cell @@ -115,8 +123,6 @@ import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.applyCommonWindowInsetsOnViews import org.thoughtcrime.securesms.util.push -import java.io.File -import javax.inject.Inject @AndroidEntryPoint class SettingsActivity : ScreenLockActionBarActivity() { @@ -502,7 +508,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { Cell { Column { - // add the debug menu in non release builds + // Add the debug menu in non release builds if (BuildConfig.BUILD_TYPE != "release") { LargeItemButton( "Debug Menu", @@ -544,6 +550,31 @@ class SettingsActivity : ScreenLockActionBarActivity() { ) { sendInvitationToUseSession() } Divider() + // Add the token page option. + // Note: We can't do this all-in-one via `annotatedStringResource` because the font sizes vary. + val sessionNetworkAS = buildAnnotatedString { + // "Session Network" part styled with normal theme color + withStyle(style = SpanStyle(color = LocalColors.current.text)) { + append(NETWORK_NAME) + } + // " • New" part styled with theme accent color, small font size, and normal (not bold) weight + withStyle( + style = SpanStyle( + color = LocalColors.current.primaryText, + fontSize = LocalType.current.extraSmall.fontSize, + fontWeight = FontWeight.Normal + ) + ) { + append(" "+applicationContext.getString(R.string.sessionNew)) + } + } + LargeItemButton( + modifier = Modifier.qaTag(R.string.qa_settings_item_session_network), + annotatedStringText = sessionNetworkAS, + icon = R.drawable.session_network_logo + ) { push() } + Divider() + // Only show the recovery password option if the user has not chosen to permanently hide it if (!recoveryHidden) { LargeItemButton( diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt new file mode 100644 index 0000000000..ad407e8d67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.tokenpage + +import java.math.BigDecimal +import java.math.BigInteger +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonUnquotedLiteral +import kotlinx.serialization.json.jsonPrimitive + +// We can't serialize the BigDecimal data type by default so we have to wrap it. +// Note: Code adapted from aSemy's StackOverflow solution at: https://stackoverflow.com/a/75257763/1868200 +@OptIn(ExperimentalSerializationApi::class) +object BigDecimalSerializer : KSerializer { + + override val descriptor = PrimitiveSerialDescriptor("java.math.BigDecimal", PrimitiveKind.DOUBLE) + + /** + * If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content, + * otherwise decodes using [Decoder.decodeString]. + */ + override fun deserialize(decoder: Decoder): BigDecimal = + when (decoder) { + is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigDecimal() + else -> decoder.decodeString().toBigDecimal() + } + + /** + * If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigDecimal] value. + * + * Otherwise, [value] is encoded using encodes using [Encoder.encodeString]. + */ + override fun serialize(encoder: Encoder, value: BigDecimal) = + when (encoder) { + is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString())) + else -> encoder.encodeString(value.toPlainString()) + } +} + +typealias BigIntegerJson = @Serializable(with = BigIntegerSerializer::class) BigInteger + +@OptIn(ExperimentalSerializationApi::class) +private object BigIntegerSerializer : KSerializer { + + override val descriptor = PrimitiveSerialDescriptor("java.math.BigInteger", PrimitiveKind.LONG) + + /** + * If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content, + * otherwise decodes using [Decoder.decodeString]. + */ + override fun deserialize(decoder: Decoder): BigInteger = + when (decoder) { + is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigInteger() + else -> decoder.decodeString().toBigInteger() + } + + /** + * If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigInteger] value. + * + * Otherwise, [value] is encoded using encodes using [Encoder.encodeString]. + */ + override fun serialize(encoder: Encoder, value: BigInteger) = + when (encoder) { + is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toString())) + else -> encoder.encodeString(value.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt new file mode 100644 index 0000000000..d0aa4edb07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.tokenpage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenDataManager @Inject constructor( + private val textSecurePreferences: TextSecurePreferences, + private val tokenRepository: TokenRepository +) { + private val TAG = "TokenDataManager" + + // Cached infoResponse in memory + private val _infoResponse = MutableStateFlow(InfoResponseState.Loading) + val infoResponse: StateFlow get() = _infoResponse + + // Store the reference update time separately from UI state + private var _lastUpdateTimeMillis: MutableStateFlow = MutableStateFlow(System.currentTimeMillis()) + val lastUpdateTimeMillis: StateFlow get() = _lastUpdateTimeMillis + + // Even if the server responds back to us faster than this we'll wait until at least a total + // duration of this value milliseconds has elapsed before updating the UI - otherwise it looks + // jank when the UI switches to "Loading..." and then near-instantly updates to the given values. + private val MINIMUM_SERVER_RESPONSE_DELAY_MS = 500L + + fun getTokenDataWhenLoggedIn() { + // we want to preload the data as soon as the user is logged in + GlobalScope.launch { + textSecurePreferences.watchLocalNumber() + .map { it != null } + .distinctUntilChanged() + .collect { + fetchInfoResponse() + } + } + } + + fun getLastUpdateTimeMillis() = _lastUpdateTimeMillis.value + + + /** + * Fetches the InfoResponse from the tokenRepository, delays if needed, + * and then updates the MutableStateFlow. + * + * @return The fetched InfoResponse, or null if there was an error. + */ + private suspend fun fetchInfoResponse() { + _infoResponse.value = InfoResponseState.Loading + + val requestStartTimestamp = System.currentTimeMillis() + return try { + // Fetch the InfoResponse on an IO dispatcher + val response = withContext(Dispatchers.IO) { + tokenRepository.getInfoResponse() + } + // Ensure the minimum delay to avoid janky UI updates + forceWaitAtLeast500ms(requestStartTimestamp) + // Update the state flow so observers can react + _infoResponse.value = if(response != null ) + InfoResponseState.Data(response) + else InfoResponseState.Failure(Exception("InfoResponse was null")) + + updateLastUpdateTimeMillis() + Log.w(TAG, "Fetched infoResponse: $response") + } catch (e: Exception) { + Log.w(TAG, "InfoResponse error: $e") + _infoResponse.value =InfoResponseState.Failure(e) + } + } + + fun updateLastUpdateTimeMillis() { + _lastUpdateTimeMillis.value = System.currentTimeMillis() + } + + + // Method to ensure we wait for at least the `MINIMUM_SERVER_RESPONSE_DELAY_MS` milliseconds + // when requesting data from the server so that the UI doesn't blink to "Loading..." and then + // near-instantly back to the correct values. + private suspend fun forceWaitAtLeast500ms(requestStartTimestampMS: Long) { + val requestEndTimestamp = System.currentTimeMillis() + val requestDuration = requestEndTimestamp - requestStartTimestampMS + if (requestDuration < MINIMUM_SERVER_RESPONSE_DELAY_MS) { + val fillerDelayMS = MINIMUM_SERVER_RESPONSE_DELAY_MS - requestDuration + delay(fillerDelayMS) + } + } + + /** + * Fetches the info data if it's considered stale. + * + * This function checks if the current data is stale using [dataIsStale]. If the data is + * stale, it fetches new data using [fetchInfoResponse] and returns `true`. Otherwise, it + * logs that the data is not stale and returns `false`. + * + * @return `true` if new data was fetched, `false` otherwise. + */ + suspend fun fetchInfoDataIfNeeded(): Boolean{ + return if(dataIsStale()) { + Log.i(TAG, "Data is stale, fetch new data") + fetchInfoResponse() + true + } else{ + Log.i(TAG, "Data is not stale...") + updateLastUpdateTimeMillis() + false + } + } + + // If the server data is considered stale then 'timestamp minus now' is negative and we should refresh data from the server + private fun dataIsStale() = if (getInfoResponse() != null) { + val nowInSeconds = System.currentTimeMillis() / 1000L + + // If this value is negative it means that the server should have fresh data we can grab + val secondsUntilThereWillBeFreshData = + getInfoResponse()!!.priceData.staleTimestampSecs - nowInSeconds + + val freshDataExists = secondsUntilThereWillBeFreshData < 0 + freshDataExists + } else { + // If we don't have a previous infoResponse object then we should refresh the data + true + } + + /** + * Returns the cached InfoResponse if available. + */ + fun getInfoResponse(): InfoResponse? = (_infoResponse.value as? InfoResponseState.Data)?.data + + sealed class InfoResponseState { + data object Loading : InfoResponseState() + data class Data(val data: InfoResponse) : InfoResponseState() + data class Failure(val exception: Exception) : InfoResponseState() + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt new file mode 100644 index 0000000000..4741c9555e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat.getString +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_LONG +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_LONG_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.preferences.SettingsActivity + +@HiltWorker +class TokenDropNotificationWorker +@AssistedInject constructor( + @Assisted private val context: Context, + @Assisted private val workerParams: WorkerParameters, + private val prefs: TextSecurePreferences, + private val tokenDataManager: TokenDataManager +) : CoroutineWorker(context, workerParams) { + + private val NOTIFICATION_CHANNEL_ID = "SessionTokenNotifications" + private val NOTIFICATION_CHANNEL_NAME = "Session Token Notifications" + private val TOKEN_NOTIFICATION_ID = 777 + + override suspend fun doWork(): Result { + val isDebugNotification = + workerParams.tags.contains(TokenPageNotificationManager.debugNotificationWorkName) + val alreadyShownTokenPageNotification = prefs.hasSeenTokenPageNotification() + + tokenDataManager.fetchInfoDataIfNeeded() + + // If this is a proper notification (not a debug one) and we've already + // shown it then early exit rather than attempting to schedule another + if ( + !isDebugNotification && + (alreadyShownTokenPageNotification) + ) { + return Result.success() + } + + // Create the notification + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create a notification channel + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Session Token Page Notification Channel" + + // Typically Android will not allow a notification to display if the app is open in the foreground. To get around that and + // show a "heads-up" notification that WILL display in the foreground we need to specifically add either a sound or a + // vibration to it. In this case, we're specifically adding the default notification sound as a sound, which allows the + // heads-up notification to show even when the app is already open. + val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + setSound( + soundUri, AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } + notificationManager.createNotificationChannel(channel) + + // Create an intent to open the TokenPageActivity when the notification is clicked + val tokenPageActivityIntent = Intent(context, TokenPageActivity::class.java).apply { + // Add flags to handle existing activity instances + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or // Necessary when starting an activity from a non-activity context (e.g. from a notification) + Intent.FLAG_ACTIVITY_CLEAR_TOP or // If the activity is already running then bring it to the front and clear all other activities on top of it + Intent.FLAG_ACTIVITY_SINGLE_TOP // Prevent the creation of a new instance if the activity is already at the top of the stack + } + + // Use TaskStackBuilder to build the back stack - without this if we schedule a notification and then + // with the app closed we click on it it takes us directly to the Token Page, but when we click the back + // button it closed the app because we don't have a back-stack! + // Note: I did try adding PARENT_ACTIVITY meta-data to the manifest to auto-generate the back-stack but + // it didn't work so I've just hard-coded the back-stack here as there's only a single path to the token + // page activity. + val stackBuilder = TaskStackBuilder.create(context).apply { + addNextIntent(Intent(context, HomeActivity::class.java)) + addNextIntent(Intent(context, SettingsActivity::class.java)) + addNextIntent(tokenPageActivityIntent) + } + + val pendingIntent = stackBuilder.getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notificationTxt = Phrase.from(applicationContext, R.string.sessionNetworkNotificationLive) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .format().toString() + val builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getColor(R.color.textsecure_primary)) + .setContentTitle(getString(context, R.string.app_name)) + .setContentText(notificationTxt) + + // Without setting a `BigTextStyle` on the notification we only see a single line that gets ellipsized at the edge of the screen + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(notificationTxt) + ) + + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) // Automatically dismiss the notification when tapped + + // Show the notification & update the shared preference to record the fact that we've done so + notificationManager.notify(TOKEN_NOTIFICATION_ID, builder.build()) + + // Update our preference data to indicate we've now shown the notification if this isn't a debug / test notification + if (!isDebugNotification) { + prefs.setHasSeenTokenPageNotification(true) + } + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt new file mode 100644 index 0000000000..5bd680338c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt @@ -0,0 +1,1021 @@ +package org.thoughtcrime.securesms.tokenpage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.TopCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME +import org.session.libsession.utilities.NonTranslatableStringConstants.STAKING_REWARD_POOL +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_LONG +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.STAKING_REWARD_POOL_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_LONG_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_SHORT_KEY +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.SimplePopup +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.BlurredImage +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButtonRect +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.components.iconExternalLink +import org.thoughtcrime.securesms.ui.components.inlineContentMap +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.verticalScrollbar +import org.thoughtcrime.securesms.util.NumberUtil.formatAbbreviated + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TokenPage( + uiState: TokenPageUIState, + sendCommand: (TokenPageCommand) -> Unit, + modifier: Modifier = Modifier, + onClose: () -> Unit +) { + val snackbarHostState = remember { SnackbarHostState() } + + val scrollState = rememberScrollState() + + // Details for the pull-to-refresh & limit-refresh-to-when-we-have-fresh-data mechanisms + val pullToRefreshState = rememberPullToRefreshState() + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = LocalColors.current.background, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + BackAppBar( + title = NETWORK_NAME, + onBack = onClose, + modifier = Modifier + .qaTag("Page heading") + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { contentPadding -> + + PullToRefreshBox( + modifier = Modifier.padding(contentPadding), + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + onRefresh = { sendCommand(TokenPageCommand.RefreshData) }, + indicator = { + // Colour the "spinning arrow" indicator to match our theme + Indicator( + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + containerColor = LocalColors.current.backgroundSecondary, + color = LocalColors.current.primary, + modifier = Modifier.align(TopCenter) + ) + } + ) { + // This is the main column that contains all elements of the Token Page. + // It reaches the entire width of the screen and scrolls if there is sufficient content to allow it. + Column( + modifier = Modifier + .fillMaxSize() + .background(color = LocalColors.current.background) + // IMPORTANT: Add this `verticalScrollbar` modifier property BEFORE `.verticalScroll(scrollState)`! + .verticalScrollbar( + state = scrollState + ) + .verticalScroll(scrollState) + ) { + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + ) { + + // The Session Network section is just some text with a link to "Learn More" - this does NOT contain the stats section - that comes next. + SessionNetworkInfoSection() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Stats section - this outlines the number of nodes in our swarm amongst other details + StatsSection( + currentSessionNodesInSwarm = uiState.currentSessionNodesInSwarm, + currentSessionNodesSecuringMessages = uiState.currentSessionNodesSecuringMessages, + showNodeCountsAsRefreshing = uiState.showNodeCountsAsRefreshing, + priceDataPopupText = uiState.priceDataPopupText, + currentSentPriceUSDString = uiState.currentSentPriceUSDString, + networkSecuredByUSDString = uiState.networkSecuredByUSDString, + networkSecuredBySENTString = uiState.networkSecuredBySENTString + ) + + // Token section that lists the staking pool size, market cap, and a button to learn more about staking + SessionTokenSection( + currentStakingRewardPoolString = uiState.currentStakingRewardPoolString, + showNodeCountsAsRefreshing = uiState.showNodeCountsAsRefreshing, + currentMarketCapUSDString = uiState.currentMarketCapUSDString + ) + } + + // There is a design idiosyncrasy where the "last updated" text needs to be at the very bottom of the screen + // when there is no scroll, otherwise it should be docked under the "learn about staking" button with padding + val hasNoScroll = scrollState.maxValue == 0 || scrollState.maxValue == Int.MAX_VALUE + + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .then( + if (hasNoScroll) { + Modifier.weight(1f) + } else Modifier + ) + ) { + // Last updated indicator ("Last updated 17m ago" etc.) + if (uiState.infoResponseData != null) { + // the last updated section should be docked at the bottom when there is no scrolling + // or below the button if the content is scrolling + if (hasNoScroll) { + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + text = uiState.lastUpdatedString, + textAlign = TextAlign.Center, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary, + modifier = modifier + .fillMaxWidth() + .qaTag("Last updated timestamp") + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + } + + } + + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } +} + +@Composable +fun SessionNetworkInfoSection(modifier: Modifier = Modifier) { + val context = LocalContext.current + + Column( + modifier = modifier + ) { + // 1.) "Session Network" small heading + Text( + text = NETWORK_NAME, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // 2.) Session network description + val sessionNetworkDetailsAnnotatedString = annotatedStringResource( + highlightColor = LocalColors.current.primaryText, + text = Phrase.from(context.getText(R.string.sessionNetworkDescription)) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(ICON_KEY, iconExternalLink) + .format() + ) + + // Note: We apply the link to the entire box so the user doesn't have to click exactly on the highlighted text. + var showTheOpenUrlModal by remember { mutableStateOf(false) } + Text( + modifier = Modifier + .clickable { showTheOpenUrlModal = true } + .qaTag("Learn more link"), // The entire clickable box acts as the link, so that's what I've put the qaTag on + text = sessionNetworkDetailsAnnotatedString, + inlineContent = inlineContentMap(LocalType.current.large.fontSize), + style = LocalType.current.large + ) + + if (showTheOpenUrlModal) { + OpenURLAlertDialog( + url = "https://docs.getsession.org/session-network", + onDismissRequest = { showTheOpenUrlModal = false } + ) + } + } +} + +// Composable to stack to transparent images on top of each other to create the session nodes display +@Composable +fun StatsImageBox( + showNodeCountsAsRefreshing: Boolean, + lineDrawableId: Int, + circlesDrawableId: Int, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1.15f) + .border( + width = 1.dp, + color = LocalColors.current.primary, + shape = MaterialTheme.shapes.extraSmall + ) + ) { + // Draw the waiting dots animation if we're refreshing the node counts.. + if (showNodeCountsAsRefreshing) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = LocalColors.current.text + ) + } else { + // ..otherwise draw the correct image for the number of nodes in our swarm. + + // We draw the white connecting lines first.. + Image( + painter = painterResource(id = lineDrawableId), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, // Note: `Fit` keeps the image aspect ratio - `FillBounds` will distort it + colorFilter = ColorFilter.tint(LocalColors.current.text), + ) + + // ..and THEN we draw the colored circles on top and tint them to the theme's accent colour - BUT + // we have to cheat if we want a glow effect - so we'll draw a blurred version first, and then draw + // the non-blurred version on top. + // + // Also: On Android API 31 and higher we can just call `.blur` on the modifier. While I did attempt to use an android.renderscript + // blur for older Android versions it was problematic so has been removed. + + // If we're on a dark theme then provide a blurred version of the node circles drawn beneath our upcoming non-blurred version + if (!LocalColors.current.isLight) { + BlurredImage( + drawableId = circlesDrawableId, + blurRadiusDp = 25f, + modifier = Modifier.matchParentSize() + ) + } + + // Final non-blurred copy of our node circles, tinted to match our theme accent colour + Image( + painter = painterResource(id = circlesDrawableId), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalColors.current.primary) + ) + } + } +} + +// This box shows "Session nodes in your swarm" and "Session Nodes securing your messages" details. +@Composable +fun NodeDetailsBox( + showNodeCountsAsRefreshing: Boolean, + numNodesInSwarm: String, + numNodesSecuringMessages: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val appName = context.getString(R.string.app_name) + + val nodesInSwarmAS = annotatedStringResource( + highlightColor = LocalColors.current.primaryText, + text = Phrase.from(context, R.string.sessionNetworkNodesSwarm) + .put(APP_NAME_KEY, appName) + .format() + ) + + val nodesSecuringMessagesAS = annotatedStringResource( + highlightColor = LocalColors.current.primaryText, + text = Phrase.from(context, R.string.sessionNetworkNodesSecuring) + .put(APP_NAME_KEY, appName) + .format() + ) + + // This Node Details Box consists of a single column.. + Column( + modifier = modifier.fillMaxWidth() + ) { + // ..with two rows inside it. + NodeDetailRow( + label = nodesInSwarmAS, + amount = numNodesInSwarm, + isLoading = showNodeCountsAsRefreshing, + qaTag = "Your swarm amount" + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + NodeDetailRow( + label = nodesSecuringMessagesAS, + amount = numNodesSecuringMessages, + isLoading = showNodeCountsAsRefreshing, + qaTag = "Nodes securing amount" + ) + } +} + +@Composable +fun NodeDetailRow( + label: AnnotatedString, + amount: String, + isLoading: Boolean, + qaTag: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // Each row consists of the text on the left (e.g., "Session Nodes in your swarm").. + Text( + text = label, + style = LocalType.current.h8, + color = LocalColors.current.text, + modifier = Modifier.fillMaxWidth(0.62f) + ) + + // ..add a spacer with a weight to push the last element to the far right.. + Spacer(modifier = Modifier.weight(1f)) + + // ..and then the actual number of nodes in the swarm on the right. + if (isLoading) { + SmallCircularProgressIndicator( + modifier = Modifier.size(LocalDimensions.current.iconMedium), + color = LocalColors.current.text + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.xxsSpacing)) + } else { + + // logic to determine if we should use the short hand version + var useShort by remember(amount) { mutableStateOf(false) } + var maxLines by remember(amount) { mutableStateOf(1) } + val display = if (useShort) amount.toBigDecimal().formatAbbreviated(maxFractionDigits = 0) + else amount + + Text( + text = display, + style = LocalType.current.h3, + color = LocalColors.current.primaryText, + maxLines = maxLines, + onTextLayout = { result -> + if (result.hasVisualOverflow && !useShort) { + useShort = true // trigger recomposition with short text + } else if(result.hasVisualOverflow && useShort) { + // if we still overflow with the shorthand, break into multiple lines... + maxLines = 2 + } + }, + textAlign = TextAlign.End, + modifier = Modifier.qaTag(qaTag) + ) + } + } +} + +// Method to grab the relevant pair of images for the StatsImageBox showing the number of nodes in your swarm +fun getNodeImageForSwarmSize(numNodesInOurSwarm: Int): Pair { + when (numNodesInOurSwarm) { + 1 -> return Pair(R.drawable.session_node_lines_1, R.drawable.session_nodes_1) + 2 -> return Pair(R.drawable.session_node_lines_2, R.drawable.session_nodes_2) + 3 -> return Pair(R.drawable.session_node_lines_3, R.drawable.session_nodes_3) + 4 -> return Pair(R.drawable.session_node_lines_4, R.drawable.session_nodes_4) + 5 -> return Pair(R.drawable.session_node_lines_5, R.drawable.session_nodes_5) + 6 -> return Pair(R.drawable.session_node_lines_6, R.drawable.session_nodes_6) + 7 -> return Pair(R.drawable.session_node_lines_7, R.drawable.session_nodes_7) + 8 -> return Pair(R.drawable.session_node_lines_8, R.drawable.session_nodes_8) + 9 -> return Pair(R.drawable.session_node_lines_9, R.drawable.session_nodes_9) + 10 -> return Pair(R.drawable.session_node_lines_10, R.drawable.session_nodes_10) + else -> { + Log.w( + "TokenPage", + "Somehow got an illegal numNodesInOurSwarm value: $numNodesInOurSwarm - using 5 as a fallback" + ) + return Pair(R.drawable.session_node_lines_5, R.drawable.session_nodes_5) + } + } +} + +// Stats section that shows the number of nodes in your swarm (along with a visual representation), the +// number of nodes securing your messages, the current SENT token price, and the total USD value securing +// the network. +@Composable +fun StatsSection( + currentSessionNodesInSwarm: Int, + currentSessionNodesSecuringMessages: Int, + showNodeCountsAsRefreshing: Boolean, + currentSentPriceUSDString: String, + networkSecuredBySENTString: String, + networkSecuredByUSDString: String, + priceDataPopupText: String, + modifier: Modifier = Modifier +) { + // First row contains the `StatsImageBox` with the number of nodes in your swap and the text + // details with that number and the number of nodes securing your messages. + Row(modifier = modifier.fillMaxWidth()) { + + // On the left we have the node image showing how many nodes are in the user's swarm.. + val (linesDrawable, circlesDrawable) = getNodeImageForSwarmSize(currentSessionNodesInSwarm) + StatsImageBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + lineDrawableId = linesDrawable, + circlesDrawableId = circlesDrawable, + modifier = Modifier + .fillMaxWidth(0.45f) + .qaTag("Swarm image") + ) + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. + NodeDetailsBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + numNodesInSwarm = currentSessionNodesInSwarm.toString(), + numNodesSecuringMessages = currentSessionNodesSecuringMessages.toString(), + modifier = Modifier + .fillMaxWidth(1.0f) + .align(Alignment.CenterVertically) + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Row(modifier = Modifier.fillMaxWidth()) { + var cellHeight by remember( + currentSentPriceUSDString, + networkSecuredBySENTString, + networkSecuredByUSDString + ) { mutableStateOf(0.dp) } + + val density = LocalDensity.current + + // On the left we have the node image showing how many nodes are in the user's swarm.. + val currentPriceString = + Phrase.from(LocalContext.current, R.string.sessionNetworkCurrentPrice) + .put(TOKEN_NAME_SHORT_KEY, TOKEN_NAME_SHORT) + .format().toString() + val setOneLineOne = currentPriceString + val setOneLineTwo = currentSentPriceUSDString + val setOneLineThree = TOKEN_NAME_LONG + + ThreeLineTextCell( + setOneLineOne, + setOneLineTwo, + setOneLineThree, + qaTag = "SESH price", + modifier = Modifier + .fillMaxWidth(0.45f) // 45% width + .onGloballyPositioned { coordinates -> + // Calculate this cell's height in dp + val heightInDp = with(density) { coordinates.size.height.toDp() } + // Update cellHeight if this cell is taller + if (heightInDp > cellHeight) { + cellHeight = heightInDp + } + } + .height(if (cellHeight > 0.dp) cellHeight else androidx.compose.ui.unit.Dp.Unspecified) + ) { + // Mutable state to keep track of whether we should display the "Price data powered by CoinGecko" popup + var shouldDisplayPopup by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .padding(LocalDimensions.current.xxxsSpacing) + .size(15.dp) + .align(Alignment.TopEnd) + ) { + Image( + painter = painterResource(id = R.drawable.ic_circle_help), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text), // Tint the question mark icon to be our text colour (typically white) + modifier = Modifier + .size(15.dp) + .clickable { shouldDisplayPopup = true } + .qaTag("Tooltip") + ) + + if (shouldDisplayPopup) { + PriceDataSourcePopup(priceDataPopupText, onDismiss = { shouldDisplayPopup = false }) + } + } + } + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. + val setTwoLineOne = LocalContext.current.getString(R.string.sessionNetworkSecuredBy) + val setTwoLineTwo = networkSecuredBySENTString + val setTwoLineThree = networkSecuredByUSDString + ThreeLineTextCell( + setTwoLineOne, + setTwoLineTwo, + setTwoLineThree, + qaTag = "Network secured amount", + modifier = Modifier.fillMaxWidth(1.0f) + .onGloballyPositioned { coordinates -> + // Calculate this cell's height in dp + val heightInDp = with(density) { coordinates.size.height.toDp() } + // Update cellHeight if this cell is taller + if (heightInDp > cellHeight) { + cellHeight = heightInDp + } + } + .height(if (cellHeight > 0.dp) cellHeight else androidx.compose.ui.unit.Dp.Unspecified) + ) + } +} + +@Composable +fun RewardPoolAndMarketCapRows( + showNodeCountsAsRefreshing: Boolean, + currentStakingRewardPoolString: String, + currentMarketCapUSDString: String, + modifier: Modifier = Modifier +) { + val valueTextColour = + if (showNodeCountsAsRefreshing) LocalColors.current.textSecondary else LocalColors.current.text + + Column( + modifier = modifier + .background(LocalColors.current.background) + ) { + // Staking reward pool row + Row(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing)) { + Text( + text = STAKING_REWARD_POOL, + style = LocalType.current.sessionNetworkHeading.bold(), + color = LocalColors.current.text, + modifier = Modifier + .fillMaxWidth(0.45f) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + + Text( + text = currentStakingRewardPoolString, + style = LocalType.current.sessionNetworkHeading, + color = valueTextColour, + modifier = Modifier + .weight(1f) + .qaTag("Staking reward pool amount") + ) + } + + // Thin separator line + HorizontalDivider( + thickness = 1.dp, + color = LocalColors.current.borders + ) + + // Market cap row + Row(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing)) { + Text( + text = LocalContext.current.getString(R.string.sessionNetworkMarketCap), + color = LocalColors.current.text, + style = LocalType.current.sessionNetworkHeading.bold(), + modifier = Modifier + .fillMaxWidth(0.45f) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + Text( + text = currentMarketCapUSDString, + style = LocalType.current.sessionNetworkHeading, + color = valueTextColour, + modifier = Modifier + .weight(1f) + .qaTag("Market cap amount") + ) + } + } +} + +// Section that shows the current size of the staking reward pool & the market cap +@Composable +fun SessionTokenSection( + showNodeCountsAsRefreshing: Boolean, + currentStakingRewardPoolString: String, + currentMarketCapUSDString: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // 1.) "Session Token" small heading + Text( + text = TOKEN_NAME_LONG, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + val sessionTokenDescription = + Phrase.from(LocalContext.current, R.string.sessionNetworkTokenDescription) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(TOKEN_NAME_SHORT_KEY, TOKEN_NAME_SHORT) + .put(STAKING_REWARD_POOL_KEY, STAKING_REWARD_POOL) + .format().toString() + + // Session token description text + Text( + text = sessionTokenDescription, + style = LocalType.current.large, + color = LocalColors.current.text + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // Display the rows that show "Staking Reward Pool" and "Market Cap" + RewardPoolAndMarketCapRows( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + currentStakingRewardPoolString = currentStakingRewardPoolString, + currentMarketCapUSDString = currentMarketCapUSDString + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // Finally, add a button that links us to the staging page to learn more + var showTheOpenUrlModal by remember { mutableStateOf(false) } + PrimaryOutlineButtonRect( + text = LocalContext.current.getString(R.string.sessionNetworkLearnAboutStaking), + modifier = Modifier + .fillMaxWidth() + .qaTag("Learn about staking link"), + onClick = { showTheOpenUrlModal = true } + ) + + if (showTheOpenUrlModal) { + OpenURLAlertDialog( + url = "https://docs.getsession.org/session-network/staking", + onDismissRequest = { showTheOpenUrlModal = false } + ) + } + } +} + +// Pop-up that displays the source of the price data and when we obtained that data +@Composable +fun PriceDataSourcePopup( + priceDataPopupText: String, + onDismiss: () -> Unit +) { + SimplePopup( + onDismiss = onDismiss + ) { + Text( + text = priceDataPopupText, + textAlign = TextAlign.Center, + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + .qaTag("Tooltip info"), + style = LocalType.current.small + ) + } +} + +// A cell that contains 3 lines of text, such as "Current SESH Price:", then the string with the price such as "$2.57 USD", and +// finally some footer text like "Session Token ("SESH"). It also has an optional question-mark button which will display a pop-up +// saying "Price information provided by CoinGecko" +@Composable +fun ThreeLineTextCell( + firstLine: String, + secondLine: String, + thirdLine: String, + qaTag: String, + modifier: Modifier = Modifier, + extraContent: @Composable BoxScope.() -> Unit = {}, +) { + // Box that contains everything (text and optional question mark) + Box( + modifier = modifier + .background( + color = LocalColors.current.backgroundSecondary, + shape = RoundedCornerShape(LocalDimensions.current.xsSpacing) + ) + ) { + extraContent() + + Column( + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.xsSpacing, + vertical = LocalDimensions.current.smallSpacing + ) + ) { + // Display string 1 of 3 + Text( + text = firstLine, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.text, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + ) + + // Display string 2 of 3 + Text( + text = secondLine, + style = LocalType.current.h5, + color = LocalColors.current.primaryText, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = LocalDimensions.current.xxxsSpacing) + .qaTag(qaTag) // QA tag goes directly on the value line + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Display string 3 of 3 + Text( + text = thirdLine, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + ) + } + } +} + +// ---------- PREVIEWS ONLY BELOW THIS POINT ---------- + +@Preview +@Composable +fun PreviewTokenPage() { + PreviewTheme { + TokenPage( + uiState = TokenPageUIState( + currentSessionNodesInSwarm = 5, + currentSessionNodesSecuringMessages = 125349, + currentSentPriceUSD = SerializableBigDecimal(1.23), + currentSentPriceUSDString = "$1,472.22 USD", + networkSecuredBySENTString = "12M SENT", + networkSecuredByUSDString = "$1,234,567 USD", + currentMarketCapUSD = SerializableBigDecimal(420_000_000), + currentStakingRewardPool = SerializableBigDecimal(40_000_000), + currentMarketCapUSDString = "$20,456,259 USD", + currentStakingRewardPoolString = "40,567,789,654,789 SESH", + lastUpdatedString = "Last updated 1min ago" + ), + sendCommand = { }, + modifier = Modifier, + onClose = { } + ) + } +} + +@Preview +@Composable +fun PreviewTokenPageLoading() { + PreviewTheme { + TokenPage( + uiState = TokenPageUIState( + currentSessionNodesInSwarm = 5, + currentSessionNodesSecuringMessages = 123, + currentSentPriceUSD = SerializableBigDecimal(1.23), + networkSecuredBySENTString = "12M SENT", + networkSecuredByUSDString = "$1,234,567 USD", + currentMarketCapUSD = SerializableBigDecimal(420_000_000), + currentStakingRewardPool = SerializableBigDecimal(40_000_000), + showNodeCountsAsRefreshing = true + ), + sendCommand = { }, + modifier = Modifier, + onClose = { } + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox1() { + PreviewTheme { + val data = TokenPageUIState() + StatsImageBox( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + R.drawable.session_node_lines_1, + R.drawable.session_nodes_1 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox2() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_2, + R.drawable.session_nodes_2 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox3() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_3, + R.drawable.session_nodes_3 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox4() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_4, + R.drawable.session_nodes_4 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox5() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_5, + R.drawable.session_nodes_5 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox6() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_6, + R.drawable.session_nodes_6 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox7() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_7, + R.drawable.session_nodes_7 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox8() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_8, + R.drawable.session_nodes_8 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox9() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_9, + R.drawable.session_nodes_9 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox10() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_10, + R.drawable.session_nodes_10 + ) + } +} + +@Preview +@Composable +fun PreviewNodeDetailsBox() { + // Note: The entire text for both entries shows up in white in the preview, + // but the "your swarm" and "your messages" parts are displayed in the accent + // colour in-app. + PreviewTheme { + val data = TokenPageUIState() + NodeDetailsBox( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + numNodesInSwarm = "5", + numNodesSecuringMessages = "115", + ) + } +} + +@Preview +@Composable +fun PreviewSessionTokenSection() { + PreviewTheme { + val data = TokenPageUIState() + SessionTokenSection( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + currentStakingRewardPoolString = data.currentStakingRewardPoolString, + currentMarketCapUSDString = data.currentMarketCapUSDString + ) + } +} + +@Preview +@Composable +fun PreviewCurrentSentPriceCell() { + val firstLine = "Current SENT Price:" + val secondLine = "\$1.23 USD" + val thirdLine = "Session Token (SENT)" + PreviewTheme { + ThreeLineTextCell(firstLine, secondLine, thirdLine, qaTag = "Some QA tag") + } +} + +@Preview +@Composable +fun PreviewSessionNetworkSection() { + PreviewTheme { + SessionNetworkInfoSection() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt new file mode 100644 index 0000000000..3905eda8e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.ui.setComposeContent + +@AndroidEntryPoint +class TokenPageActivity : FullComposeActivity() { + private val viewModel: TokenPageViewModel by viewModels() + + @Composable + override fun ComposeContent() { + TokenPageScreen( + tokenPageViewModel = viewModel, + onClose = { finish() } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt new file mode 100644 index 0000000000..7c039a544d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.tokenpage + +// Commands that we can ask the Token Page to perform +sealed class TokenPageCommand { + + // Refresh current data / or pretend to + data object RefreshData: TokenPageCommand() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt new file mode 100644 index 0000000000..0254e5e767 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.tokenpage + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.util.NumberUtil.formatWithDecimalPlaces +import java.math.BigDecimal + +/*** + * IMPORTANT: THe server gives us timestamps in SECONDS rather than milliseconds (and expects any + * timestamps we provide to be in seconds) - so if we want to compare a server timestamp to now (e.g, + * `System.currentTimeMillis()`) then we must first either multiply the server's timestamp by 1000, + * or use our millisecond timestamp `inWholeSeconds` to get everything aligned. + */ + +// Note: We use BigDecimal in these classes for numeric values so that there are no rounding errors +// and we can guarantee 9 decimals of precision at all times. +// Also: We can't serialize BigDecimals natively so I've wrapped it (see BigDecimalSerializer.kt) +typealias SerializableBigDecimal = @Serializable(with = BigDecimalSerializer::class) BigDecimal + +// Data class to hold details provided by the `GET /info` endpoint. +@Serializable +data class InfoResponse( + @SerialName("t") val infoResponseTimestampSecs: Long, + @SerialName("price") val priceData: PriceData, + @SerialName("token") val tokenData: TokenData, + @SerialName("network") val networkData: NetworkData +) + +// Data class to wrap up details regarding the current SENT token price +@Serializable +data class PriceData( + // The token price in US dollars + @SerialName("usd") val tokenPriceUSD: SerializableBigDecimal, + + // Current market cap value in US dollars + @SerialName("usd_market_cap") val marketCapUSD: SerializableBigDecimal, + + // The timestamp (in seconds) of when the server's CoinGecko-sourced token price data was last updated + @SerialName("t_price") val priceTimestampSecs: Long, + + // The timestamp (in seconds) of when the server's CoinGecko-sourced token price data will be + // considered stale and we'll allow the user to poll the server for fresh data. The server only + // polls CoinGecko once every 5 mins - so what we can do on the client side is check if the + // current time is lower than `t_stale`, and if it is then we don't poll the server again as + // we'd just be getting the same data. + @SerialName("t_stale") val staleTimestampSecs: Long +) { + // Get the token price in USD to 2 decimal places in a locale-aware manner + fun getLocaleFormattedTokenPriceString(): String { + return "\$" + tokenPriceUSD.formatWithDecimalPlaces(2) + " USD" + } + + // Get the token price in USD to ZERO decimal places in a locale-aware manner + fun getLocaleFormattedMarketCapString(): String { + return "\$" + marketCapUSD.formatWithDecimalPlaces( 0) + " USD" + } +} + +// Data class to hold details provided in a InfoResponse or via the `GET /token` endpoint +@Serializable +data class TokenData( + // How many tokens must be staked to run a Session Node + @SerialName("staking_requirement") val nodeStakingRequirement: SerializableBigDecimal, + + // The number of tokens currently in the staking reward pool. While this value starts + // at 40,000,000 it will decrease as tokens are handed out as rewards, and will + // increase when we (Session) top up the pool. + @SerialName("staking_reward_pool") val stakingRewardPool: SerializableBigDecimal, + + // The ethereum contract address for the SENT token. This is 42 chars in length - being + // "0x" followed by 40 hexadecimal chars. + @SerialName("contract_address") val tokenContractAddress: String +) { + // Get staking reward pool in a locale-aware manner to ZERO decimal places (while the reward pool may not be a + // a whole number, we don't care about fractions when the staking pool is in the range of millions of tokens). + fun getLocaleFormattedStakingRewardPool(): String { + return stakingRewardPool.formatWithDecimalPlaces(0) + } +} + +// Small data class included as part of an InfoResponse +@Serializable +data class NetworkData( + @SerialName("network_size") val networkSize: Int, + @SerialName("network_staked_tokens") val networkTokens: SerializableBigDecimal, + @SerialName("network_staked_usd") val networkUSD: SerializableBigDecimal, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt new file mode 100644 index 0000000000..78822c41eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import org.session.libsession.utilities.TextSecurePreferences +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +@Singleton +class TokenPageNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val tokenDataManager: TokenDataManager, + private val prefs: TextSecurePreferences +) { + + companion object { + // WorkManager tags for the local-origin notification about network page + const val productionNotificationWorkName = "SESSION_TOKEN_DROP_INITIAL_NOTIFICATION" + const val debugNotificationWorkName = "SESSION_TOKEN_DROP_DEBUG_NOTIFICATION" + } + + // Method to schedule a notification to be shown at a specific time in the future. + // IMPORTANT: If `constructDebugNotification` is true then we can schedule the notification over + // and over (and we do so with a 10 second delay), however if it's not then we can only schedule + // the notification once - which is what we want for production. + fun scheduleTokenPageNotification(constructDebugNotification: Boolean) { + // Bail early if we are this isn't a debug notification and we've already shown the notification + if (prefs.hasSeenTokenPageNotification() && !constructDebugNotification) return + + // The notification is scheduled for 10 seconds after opening for debug notifications & 1 hour after opening for production + val scheduleDelayMS = if (constructDebugNotification) { + 10.seconds.inWholeMilliseconds + } else { + 1.hours.inWholeMilliseconds + } + + // Create the one-time work request for our notification. If we are constructing a debug + // notification we set the delay for 10 seconds and we DO NOT tag the notification.. + val notificationWork: OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setInitialDelay(scheduleDelayMS, TimeUnit.MILLISECONDS) + .addTag(if (constructDebugNotification) debugNotificationWorkName else productionNotificationWorkName) // Add the tag to differentiate between a debug and a production notification! + .build() + + // Either enqueue a debug notification if asked to (this can be shown multiple times).. + if (constructDebugNotification) { + WorkManager.getInstance(context).enqueueUniqueWork( + debugNotificationWorkName, + ExistingWorkPolicy.REPLACE, + notificationWork + ) + } else { + // ..or enqueue a production notification which is one-time only (i.e., it will not be updated should one already be enqueued) - and + // ONLY do this if we haven't already shown the token page notification (there's no point in asking the work manager to show it again + // - it won't because the ExistingWorkPolicy is KEEP). + // Note: Should the device be powered off when the scheduled notification is due to fire then WorkManager will fire + // the notification immediately on next boot (i.e., it won't get lost or forgotten). + WorkManager.getInstance(context).enqueueUniqueWork( + productionNotificationWorkName, + ExistingWorkPolicy.KEEP, + notificationWork + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt new file mode 100644 index 0000000000..fa57ec3abd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.tokenpage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun TokenPageScreen( + modifier: Modifier = Modifier, + tokenPageViewModel: TokenPageViewModel = viewModel(), + onClose: () -> Unit +) { + val uiState by tokenPageViewModel.uiState.collectAsState() + + // Remember callbacks to prevent recomposition when functions change + val rememberedOnCommand = remember { tokenPageViewModel::onCommand } + val rememberedOnClose = remember { onClose } + + TokenPage( + modifier = modifier, + uiState = uiState, + sendCommand = rememberedOnCommand, + onClose = rememberedOnClose + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt new file mode 100644 index 0000000000..6dec45ff6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.tokenpage + +import org.session.libsession.utilities.NonTranslatableStringConstants +import java.math.BigDecimal + +// Data class to hold a collection of variables used in our UI state +data class TokenPageUIState( + + // true during 'pull to refresh' + val isRefreshing: Boolean = false, + + // Details for how many nodes are in our swarm, and how many nodes are securing our messages. + // See: `TokenPageViewModel.populateNodeData()` for calculation details. + val currentSessionNodesInSwarm: Int = 0, + val currentSessionNodesSecuringMessages: Int = 0, + + // info response data + val infoResponseData: InfoResponseStateData? = null, + + // When we get a new InfoResponse we update the session node counts - and during the refresh we + // show the loading animation rather than the nodes in swarm & securing-messages values. + val showNodeCountsAsRefreshing: Boolean = false, + + // ----- PriceResponse / PriceData UI representations ----- + + // Number so we can perform calculation (this value is obtained from PriceData.usd) + var currentSentPriceUSD: SerializableBigDecimal = BigDecimal.ZERO, + var currentSentPriceUSDString: String = "", + + // Number so we can perform calculations (this value is obtained from PriceData.usd_market_cap) + val currentMarketCapUSD: SerializableBigDecimal = BigDecimal.ZERO, + val currentMarketCapUSDString: String = "", + + // ----- TokenResponse / TokenData UI representations ----- + + // At the time of the token-generation event this is 40 million (this value is obtained from TokenData.staking_reward_pool) + val currentStakingRewardPool: SerializableBigDecimal = SerializableBigDecimal(0), + val currentStakingRewardPoolString: String = "", + + // The amount of SENT securing the network is the total number of nodes multiplied by the staking requirement per node.. + val networkSecuredBySENTString: String = "", + + // ..and the total amount of USD securing the network is the SENT count multiplied by the current token price. + val networkSecuredByUSDString: String = "\$- ${NonTranslatableStringConstants.USD_NAME_SHORT}", + + val priceDataPopupText: String = "", + + // string for the tooltip + val lastUpdatedString: String = "", +) + +data class InfoResponseStateData( + val tokenContractAddress: String, + val canCopyTokenContractAddress: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt new file mode 100644 index 0000000000..f9a34e35aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -0,0 +1,318 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import nl.komponents.kovenant.Promise +import org.session.libsession.LocalisedTimeUtil.toShortSinglePartString +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.NonTranslatableStringConstants.SESSION_NETWORK_DATA_PRICE +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT +import org.session.libsession.utilities.NonTranslatableStringConstants.USD_NAME_SHORT +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.RELATIVE_TIME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.NetworkConnectivity +import org.thoughtcrime.securesms.util.NumberUtil.formatAbbreviated +import org.thoughtcrime.securesms.util.NumberUtil.formatWithDecimalPlaces +import javax.inject.Inject +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +@HiltViewModel +class TokenPageViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val tokenRepository: TokenRepository, + private val tokenDataManager: TokenDataManager, + private val dateUtils: DateUtils, + private val prefs: TextSecurePreferences +) : ViewModel() { + private val TAG = "TokenPageVM" + + @Inject + lateinit var internetConnectivity: NetworkConnectivity + + private val _uiState: MutableStateFlow = MutableStateFlow(TokenPageUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val infoResponse: InfoResponse? + get() = tokenDataManager.getInfoResponse() + + private val unavailableString = context.getString(R.string.unavailable) + + init { + // grab info data from manager + viewModelScope.launch { + tokenDataManager.infoResponse.collect { infoResponseResult -> + when(infoResponseResult){ + is TokenDataManager.InfoResponseState.Loading -> showLoading() + + is TokenDataManager.InfoResponseState.Data -> handleInfoResponse(infoResponseResult.data) + + is TokenDataManager.InfoResponseState.Failure -> resetDisplayedValuesToDefault() + } + } + } + + // on launch of the token page, check if we have new info data, and acquire node data + viewModelScope.launch { + // get node data + getNodeData() + + tokenDataManager.fetchInfoDataIfNeeded() + } + + // update label when update time changes + viewModelScope.launch { + tokenDataManager.lastUpdateTimeMillis.collect { + updateLastUpdatedText() + } + } + + startLastUpdateTimer() + } + + private fun startLastUpdateTimer() { + viewModelScope.launch { + while (true) { + updateLastUpdatedText() + delay(1.minutes) + } + } + } + + private fun updateLastUpdatedText() { + val currentTimeMillis = System.currentTimeMillis() + val durationSinceLastUpdate = (currentTimeMillis - tokenDataManager.getLastUpdateTimeMillis()).milliseconds + val shortLastUpdateString = durationSinceLastUpdate.toShortSinglePartString() + + val lastUpdatedTxt = Phrase.from(context, R.string.updated) + .put(RELATIVE_TIME_KEY, shortLastUpdateString) + .format().toString() + + // Update only the lastUpdatedString field in the UI state + _uiState.update { currentState -> + currentState.copy(lastUpdatedString = lastUpdatedTxt) + } + } + + // Handler to instigate certain actions when we receive a TokenPageCommand + fun onCommand(command: TokenPageCommand) { + + when (command) { + is TokenPageCommand.RefreshData -> refreshData() + } + } + + private fun showLoading() { + _uiState.update { state -> + val loadingString = context.getString(R.string.loading) + state.copy( + showNodeCountsAsRefreshing = true, + currentSentPriceUSDString = loadingString, + currentMarketCapUSDString = loadingString, + currentStakingRewardPoolString = loadingString, + networkSecuredBySENTString = loadingString, + networkSecuredByUSDString = "\$- ${USD_NAME_SHORT}" + ) + } + } + + private fun handleInfoResponse(infoResponse: InfoResponse?) { + // update the rest of the UI with details like token price, market cap etc. + if (infoResponse != null) { + // Calculate price data time text + val priceTimeMS = + infoResponse.priceData.priceTimestampSecs * 1000L // Multiply by 1000 to get timestamp in milliseconds + + // Note: If we do not have data then `lastPriceUpdateDate" will be "-" and `lastPriceUpdateTime` will be "" + val priceDataText = Phrase.from(SESSION_NETWORK_DATA_PRICE) + .put(DATE_TIME_KEY, dateUtils.getLocaleFormattedDateTime(priceTimeMS)) + .format().toString() + + _uiState.update { state -> + state.copy( + infoResponseData = InfoResponseStateData( + tokenContractAddress = infoResponse.tokenData.tokenContractAddress, + canCopyTokenContractAddress = infoResponse.tokenData.tokenContractAddress.isNotEmpty(), + ), + + priceDataPopupText = priceDataText, + + showNodeCountsAsRefreshing = false, + + currentSentPriceUSD = infoResponse.priceData.tokenPriceUSD, // Raw token price value "1.23" etc. + currentSentPriceUSDString = infoResponse.priceData.getLocaleFormattedTokenPriceString(), // Formatted token price value "$1.23 USD" etc. + currentMarketCapUSD = infoResponse.priceData.marketCapUSD, // Raw market cap value "1,234,567" etc. + currentMarketCapUSDString = infoResponse.priceData.getLocaleFormattedMarketCapString(), // Formatted market cap value "$1,234,567 USD" etc. + + currentStakingRewardPool = infoResponse.tokenData.stakingRewardPool, + currentStakingRewardPoolString = infoResponse.tokenData.getLocaleFormattedStakingRewardPool() + " $TOKEN_NAME_SHORT", + + currentSessionNodesSecuringMessages = min(infoResponse.networkData.networkSize, state.currentSessionNodesSecuringMessages), // we now apply the 'min' from the formula defined in getNodeData + networkSecuredBySENTString = infoResponse.networkData.networkTokens + .formatAbbreviated( + minFractionDigits = 0, + maxFractionDigits = 0 + ) + " " + TOKEN_NAME_SHORT, + + networkSecuredByUSDString = "\$" + infoResponse.networkData.networkUSD + .formatWithDecimalPlaces(0) + " ${USD_NAME_SHORT}" + ) + } + } else { + Log.w(TAG, "Received null InfoResponse - unable to proceed.") + resetDisplayedValuesToDefault() + } + } + + // sets the data back to its default, likely due to a null info response + private fun resetDisplayedValuesToDefault() { + _uiState.update { state -> + state.copy( + showNodeCountsAsRefreshing = true, + currentSentPriceUSDString = unavailableString, + currentMarketCapUSDString = unavailableString, + currentStakingRewardPoolString = unavailableString, + networkSecuredBySENTString = unavailableString, + networkSecuredByUSDString = "\$- ${USD_NAME_SHORT}", + infoResponseData = null, + priceDataPopupText = Phrase.from(SESSION_NETWORK_DATA_PRICE) + .put(DATE_KEY, "-") + .put(TIME_KEY, "") + .format().toString() + ) + } + } + + private fun refreshData() { + _uiState.update { + it.copy(isRefreshing = true) + } + + viewModelScope.launch { + // if the data isn't stale then we don't need to refresh it, instead we fake a small wait + if (!tokenDataManager.fetchInfoDataIfNeeded()) { + // If there is no fresh server data then we'll update the UI elements to show their loading + // state for half a second then put them back as they were. + showLoading() + delay(timeMillis = 500) + handleInfoResponse(infoResponse) + } + + // Reset the refreshing state when done + _uiState.update { state -> + state.copy( + isRefreshing = false + ) + } + } + } + + // Method to populate both the number of nodes in our swarm and the number of nodes protecting our messages. + // Note: We pass this in to the token page so we can call it when we refresh the page. + private suspend fun getNodeData() { + withContext(Dispatchers.Default) { + val myPublicKey = getLocalNumber(context) + val getSwarmSetPromise: Promise, Exception> = + SnodeAPI.getSwarm(myPublicKey!!) + + val numSessionNodesInOurSwarm = try { + // Get the count of Session nodes in our swarm (technically in the range 1..10, but + // even a new account seems to start with a nodes-in-swarm count of 4). + getSwarmSetPromise.await().size + } catch (e: Exception) { + Log.w(TAG, "Couldn't get nodes in swarm count.", e) + 5 // Pick a sane middle-ground should we error for any reason + } + + // 2.) Session nodes protecting our messages + var num1to1Convos = 0 + var numLegacyGroupConvos = 0 + var numGroupV2Convos = 0 + + // Grab the database and reader details we need to count the conversations / groups + val threadDatabase = DatabaseComponent.get(context).threadDatabase() + val cursor = threadDatabase.approvedConversationList + val result = mutableSetOf() + + // Look through the database to build up our conversation & group counts (still on Dispatchers.IO not the main thread) + threadDatabase.readerFor(cursor).use { reader -> + while (reader.next != null) { + val thread = reader.current + val recipient = thread.recipient + result.add(recipient) + + if (recipient.is1on1) { + num1to1Convos += 1 + } else if (recipient.isGroupV2Recipient) { + numGroupV2Convos += 1 + } else if (recipient.isLegacyGroupRecipient) { + numLegacyGroupConvos += 1 + } + } + } + + // This is hard-coded to 2 on Android but may vary on other platforms + val pathCount = OnionRequestAPI.paths.value.size + + /* + Note: Num session nodes securing you messages formula is: + min( + total_service_node_cache_size, << this part comes from the networkData in infoResponse: networkSize + ( + num_swarm_nodes + + (num_paths * 3) + + ( + (num_1_to_1_convos * 6) + + (num_legacy_group_convos * 6) + + (num_group_v2_convos * 6) + ) + ) + ) + */ + var nodeFormula = numSessionNodesInOurSwarm + + (pathCount * 3) + + (num1to1Convos * 6) + + (numLegacyGroupConvos * 6) + + (numGroupV2Convos * 6) + + // if we already have some server data though, we should apply the cap + // if not this cap will be applied once we get the server data + if(infoResponse?.networkData?.networkSize != null){ + nodeFormula = min(infoResponse!!.networkData.networkSize, nodeFormula) + } + + _uiState.update { state -> + state.copy( + currentSessionNodesInSwarm = numSessionNodesInOurSwarm, + currentSessionNodesSecuringMessages = nodeFormula + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt new file mode 100644 index 0000000000..1ab9b16547 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.Headers.Companion.toHeaders +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +interface TokenRepository { + suspend fun getInfoResponse(): InfoResponse? +} + +@Singleton +class TokenRepositoryImpl @Inject constructor( + @ApplicationContext val context: Context, + private val storage: StorageProtocol +): TokenRepository { + private val TAG = "TokenRepository" + + private val TOKEN_SERVER_URL = "http://networkv1.getsession.org" + private val TOKEN_SERVER_INFO_ENDPOINT = "$TOKEN_SERVER_URL/info" + private val SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" + + private val secretKey by lazy { + storage.getUserED25519KeyPair()?.secretKey?.asBytes + ?: throw (FileServerApi.Error.NoEd25519KeyPair) + } + + private val userBlindedKeys by lazy { + BlindKeyAPI.blindVersionKeyPair(secretKey) + } + + private fun defaultErrorHandling(e: Exception): T? { + Log.e("TokenRepo", "Server error getting data: $e") + return null + } + + // Method to access the /info endpoint and retrieve a InfoResponse via onion-routing. + override suspend fun getInfoResponse(): InfoResponse? { + return sendOnionRequest( + path = "info", + url = TOKEN_SERVER_INFO_ENDPOINT + ) + } + + private val json = Json { ignoreUnknownKeys = true } + + private suspend inline fun sendOnionRequest( + path: String, url: String, body: ByteArray? = null, + customCatch: (Exception) -> T? = { e -> defaultErrorHandling(e) } + ): T? { + val timestampSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds + val signature = BlindKeyAPI.blindVersionSignRequest( + ed25519SecretKey = secretKey, // Important: Use the ED25519 secret key here and NOT the blinded secret key! + timestamp = timestampSeconds, + path = ("/$path"), + body = body, + method = if (body == null) "GET" else "POST" + ) + + val headersMap = mapOf( + "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), + "X-FS-Timestamp" to timestampSeconds.toString(), + "X-FS-Signature" to Base64.encodeBytes(signature) // Careful: Do NOT add `android.util.Base64.NO_WRAP` to this - it breaks it. + ) + + var requestBuilder = Request.Builder() + requestBuilder = if (body == null) { + requestBuilder.get() + } else { + requestBuilder.post(body.toRequestBody()) + } + val request = requestBuilder + .url(url) + .headers(headersMap.toHeaders()) + .build() + + var response: T? = null + try { + val rawResponse = OnionRequestAPI.sendOnionRequest( + request = request, + server = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit + x25519PublicKey = SERVER_PUBLIC_KEY + ).await() + + val resultJsonString = rawResponse.body?.decodeToString() + if (resultJsonString == null) { + Log.w(TAG, "${T::class.java} decoded to null") + } else { + response = json.decodeFromString(resultJsonString) + } + } + catch (se: SerializationException) { + Log.e(TAG, "Got a serialization exception attempting to decode ${T::class.java}", se) + } + catch (e: Exception) { + val catchResponse = customCatch(e) + Log.e(TAG, "Got an error: $catchResponse") + return catchResponse + } + + return response + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 380764cb62..0d1e2d0188 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -34,11 +34,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import androidx.compose.ui.unit.times import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY @@ -52,7 +49,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.bold - class DialogButtonModel( val text: GetString, val color: Color = Color.Unspecified, @@ -383,4 +379,4 @@ fun PreviewLoadingDialog() { title = stringResource(R.string.warning) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 7432e92317..650c9a4348 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,13 +1,18 @@ package org.thoughtcrime.securesms.ui +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -30,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.ButtonColors import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor @@ -38,20 +44,27 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -59,11 +72,19 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -156,7 +177,7 @@ fun ItemButtonWithDrawable( val context = LocalContext.current ItemButton( - text = stringResource(textId), + annotatedStringText = AnnotatedString(stringResource(textId)), modifier = modifier, icon = { Image( @@ -214,10 +235,31 @@ fun LargeItemButton( ) } +@Composable +fun LargeItemButton( + annotatedStringText: AnnotatedString, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, + onClick: () -> Unit +) { + ItemButton( + modifier = modifier, + annotatedStringText = annotatedStringText, + icon = icon, + minHeight = LocalDimensions.current.minLargeItemButtonHeight, + textStyle = LocalType.current.h8, + colors = colors, + shape = shape, + onClick = onClick + ) +} + @Composable fun ItemButton( text: String, - icon: Int, + @DrawableRes icon: Int, modifier: Modifier, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, @@ -226,15 +268,9 @@ fun ItemButton( onClick: () -> Unit ) { ItemButton( - text = text, + annotatedStringText = AnnotatedString(text), modifier = modifier, - icon = { - Icon( - painter = painterResource(id = icon), - contentDescription = null, - modifier = Modifier.align(Alignment.Center) - ) - }, + icon = icon, minHeight = minHeight, textStyle = textStyle, colors = colors, @@ -258,7 +294,30 @@ fun ItemButton( onClick: () -> Unit ) { ItemButton( - text = stringResource(textId), + annotatedStringText = AnnotatedString(stringResource(textId)), + modifier = modifier, + icon = icon, + minHeight = minHeight, + textStyle = textStyle, + shape = shape, + colors = colors, + onClick = onClick + ) +} + +@Composable +fun ItemButton( + annotatedStringText: AnnotatedString, + icon: Int, + modifier: Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, + textStyle: TextStyle = LocalType.current.xl, + colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, + onClick: () -> Unit +) { + ItemButton( + annotatedStringText = annotatedStringText, modifier = modifier, icon = { Icon( @@ -276,13 +335,14 @@ fun ItemButton( } /** -* Base [ItemButton] implementation. + * Base [ItemButton] implementation using an AnnotatedString rather than a plain String. * * A button to be used in a list of buttons, usually in a [Cell] or [Card] -*/ + */ +// THIS IS THE FINAL DEEP LEVEL ANNOTATED STRING BUTTON @Composable fun ItemButton( - text: String, + annotatedStringText: AnnotatedString, icon: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, @@ -307,7 +367,7 @@ fun ItemButton( ) Text( - text, + annotatedStringText, Modifier .fillMaxWidth() .align(Alignment.CenterVertically), @@ -634,6 +694,143 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { } } +// Permanently visible vertical scrollbar. +// Note: This scrollbar modifier was adapted from Mardann's fantastic solution at: https://stackoverflow.com/a/78453760/24337669 +@Composable +fun Modifier.verticalScrollbar( + state: ScrollState, + scrollbarWidth: Dp = 6.dp, + barColour: Color = LocalColors.current.textSecondary, + backgroundColour: Color = LocalColors.current.borders, + edgePadding: Dp = LocalDimensions.current.xxsSpacing +): Modifier { + // Calculate the viewport and content heights + val viewHeight = state.viewportSize.toFloat() + val contentHeight = state.maxValue + viewHeight + + // Determine if the scrollbar is needed + val isScrollbarNeeded = contentHeight > viewHeight + + // Set the target alpha based on whether scrolling is possible + val alphaTarget = when { + !isScrollbarNeeded -> 0f // No scrollbar needed, set alpha to 0f + state.isScrollInProgress -> 1f + else -> 0.2f + } + + // Animate the alpha value smoothly + val alpha by animateFloatAsState( + targetValue = alphaTarget, + animationSpec = tween(400, delayMillis = if (state.isScrollInProgress) 0 else 700), + label = "VerticalScrollbarAnimation" + ) + + return this.then(Modifier.drawWithContent { + drawContent() + + // Only proceed if the scrollbar is needed + if (isScrollbarNeeded) { + val minScrollBarHeight = 10.dp.toPx() + val maxScrollBarHeight = viewHeight + val scrollbarHeight = (viewHeight * (viewHeight / contentHeight)).coerceIn( + minOf(minScrollBarHeight, maxScrollBarHeight)..maxOf(minScrollBarHeight, maxScrollBarHeight) + ) + val variableZone = viewHeight - scrollbarHeight + val scrollbarYoffset = (state.value.toFloat() / state.maxValue) * variableZone + + // Calculate the horizontal offset with padding + val scrollbarXOffset = size.width - scrollbarWidth.toPx() - edgePadding.toPx() + + // Draw the missing section of the scrollbar track + drawRoundRect( + color = backgroundColour, + topLeft = Offset(scrollbarXOffset, 0f), + size = Size(scrollbarWidth.toPx(), viewHeight), + cornerRadius = CornerRadius(scrollbarWidth.toPx() / 2), + alpha = alpha + ) + + // Draw the scrollbar thumb + drawRoundRect( + color = barColour, + topLeft = Offset(scrollbarXOffset, scrollbarYoffset), + size = Size(scrollbarWidth.toPx(), scrollbarHeight), + cornerRadius = CornerRadius(scrollbarWidth.toPx() / 2), + alpha = alpha + ) + } + }) +} + +@Composable +fun SimplePopup( + arrowSize: DpSize = DpSize( + LocalDimensions.current.smallSpacing, + LocalDimensions.current.xsSpacing + ), + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + val popupBackgroundColour = LocalColors.current.backgroundBubbleReceived + + Popup( + popupPositionProvider = AboveCenterPositionProvider(), + onDismissRequest = onDismiss + ) { + Box( + modifier = Modifier.clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = CenterHorizontally + ) { + // Speech bubble card + Card( + shape = RoundedCornerShape(LocalDimensions.current.spacing), + colors = CardDefaults.cardColors( + containerColor = popupBackgroundColour + ), + elevation = CardDefaults.elevatedCardElevation(4.dp) + ) { + content() + } + + // Triangle below the card to make it look like a speech bubble + Canvas( + modifier = Modifier.size(arrowSize) + ) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2, size.height) + close() + } + drawPath( + path = path, + color = popupBackgroundColour + ) + } + } + } + } +} + +/** + * Positions the popup above/centered from its parent + */ +class AboveCenterPositionProvider() : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + return IntOffset( + anchorBounds.topCenter.x - (popupContentSize.width / 2), + anchorBounds.topCenter.y - popupContentSize.height + ) + } +} @Composable fun SearchBar( @@ -686,4 +883,4 @@ fun SearchBar( modifier = modifier, cursorBrush = SolidColor(LocalColors.current.text) ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index de2271162d..f5a28da7b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -5,11 +5,20 @@ import android.content.Context import android.content.ContextWrapper import android.view.View import android.view.ViewTreeObserver +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.fragment.app.Fragment @@ -85,6 +94,31 @@ inline fun T.afterMeasured(crossinline block: T.() -> Unit) { * As such we need to repeat it for every component that wants to use testTag, until such * a time as we have one root composable */ -@OptIn(ExperimentalComposeUiApi::class) @Composable -fun Modifier.qaTag(tag: String) = semantics { testTagsAsResourceId = true }.testTag(tag) \ No newline at end of file +fun Modifier.qaTag(tag: String?): Modifier { + if (tag == null) return this + return this.semantics { testTagsAsResourceId = true }.testTag(tag) +} + +@Composable +fun Modifier.qaTag(@StringRes tagResId: Int) = semantics { testTagsAsResourceId = true }.testTag( + stringResource(tagResId) +) + +@Composable +fun AnimateFade( + visible: Boolean, + modifier: Modifier = Modifier, + fadeInAnimationSpec: FiniteAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + fadeOutAnimationSpec: FiniteAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + content: @Composable() AnimatedVisibilityScope.() -> Unit +){ + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = fadeIn(animationSpec = fadeInAnimationSpec), + exit = fadeOut(animationSpec = fadeOutAnimationSpec) + ) { + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt index 68587bc9e1..aabbcae6e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt @@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.ui.components import android.content.res.Resources import android.graphics.Typeface +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.AbsoluteSizeSpan import android.text.style.BulletSpan +import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan import android.text.style.StrikethroughSpan @@ -15,27 +17,40 @@ import android.text.style.TypefaceSpan import android.text.style.UnderlineSpan import android.util.Log import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors -// Utilities for AnnotatedStrings, -// like converting the old view system's SpannableString to AnnotatedString +private val TAG = AnnotatedString::class.java.simpleName + +// Utilities for AnnotatedStrings, like converting the old view system's SpannableString to AnnotatedString @Composable @ReadOnlyComposable private fun resources(): Resources { @@ -44,127 +59,210 @@ private fun resources(): Resources { } @Composable -fun annotatedStringResource(@StringRes id: Int): AnnotatedString { +fun annotatedStringResource( + @StringRes id: Int, + highlightColor: Color = LocalColors.current.primary +): AnnotatedString { val resources = resources() val density = LocalDensity.current return remember(id) { val text = resources.getText(id) - spannableStringToAnnotatedString(text, density) + spannableStringToAnnotatedString(text, density, highlightColor) } } @Composable -fun annotatedStringResource(text: CharSequence): AnnotatedString { +fun annotatedStringResource( + text: CharSequence, + highlightColor: Color = LocalColors.current.primary +): AnnotatedString { val density = LocalDensity.current return remember(text.hashCode()) { - spannableStringToAnnotatedString(text, density) + spannableStringToAnnotatedString(text, density, highlightColor) } } private fun spannableStringToAnnotatedString( text: CharSequence, - density: Density + density: Density, + highlightColor: Color ): AnnotatedString { - return if (text is Spanned) { - with(density) { - buildAnnotatedString { - append((text.toString())) - text.getSpans(0, text.length, Any::class.java).forEach { - val start = text.getSpanStart(it) - val end = text.getSpanEnd(it) - when (it) { - is StyleSpan -> when (it.style) { - Typeface.NORMAL -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Normal - ), - start, - end - ) - Typeface.BOLD -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Normal - ), - start, - end - ) - Typeface.ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Italic - ), - start, - end - ) - Typeface.BOLD_ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Italic - ), - start, - end - ) - } - is TypefaceSpan -> addStyle( - SpanStyle( - fontFamily = when (it.family) { - FontFamily.SansSerif.name -> FontFamily.SansSerif - FontFamily.Serif.name -> FontFamily.Serif - FontFamily.Monospace.name -> FontFamily.Monospace - FontFamily.Cursive.name -> FontFamily.Cursive - else -> FontFamily.Default - } - ), - start, - end - ) - is BulletSpan -> { - Log.d("StringResources", "BulletSpan not supported yet") - addStyle(SpanStyle(), start, end) + val annotatedStringBuilder = AnnotatedString.Builder() + + // make sure we have a Spanned (a string without html tag but with the icon wouldn't be considered a Spanned) + val spannedText: Spanned = if (text is Spanned) text else SpannableStringBuilder.valueOf(text) + + var currentIndex = 0 + + // Build a regex pattern to match any of the placeholders + val placeholderPattern = Regex(inlineContentMap().keys.joinToString("|") { Regex.escape(it) }) + val matches = placeholderPattern.findAll(spannedText) + val matchIterator = matches.iterator() + + while (currentIndex < spannedText.length) { + val nextMatch = if (matchIterator.hasNext()) matchIterator.next() else null + val startOfPlaceholder = nextMatch?.range?.first ?: spannedText.length + val endOfPlaceholder = nextMatch?.range?.last?.plus(1) ?: spannedText.length + + // Append text before the placeholder + if (currentIndex < startOfPlaceholder) { + val textSegment = spannedText.subSequence(currentIndex, startOfPlaceholder) + appendAnnotatedTextSegment(annotatedStringBuilder, spannedText, textSegment, currentIndex, density, highlightColor) + } + + // Append inline content instead of the placeholder + if (nextMatch != null) { + val placeholderText = nextMatch.value + + if (inlineContentMap().containsKey(placeholderText)) { + // Use the placeholder text as the ID + annotatedStringBuilder.appendInlineContent(placeholderText, placeholderText) + } else { + // If no matching inline content, append the placeholder text as is + annotatedStringBuilder.append(placeholderText) + } + currentIndex = endOfPlaceholder + } else { + currentIndex = spannedText.length + } + } + + return annotatedStringBuilder.toAnnotatedString() +} + +private fun appendAnnotatedTextSegment( + builder: AnnotatedString.Builder, + spannedText: Spanned, + textSegment: CharSequence, + segmentStartIndex: Int, + density: Density, + highlightColor: Color +) { + val segmentLength = textSegment.length + val segmentEndIndex = segmentStartIndex + segmentLength + + builder.append(textSegment) + + // Process spans in the segment + val spans = spannedText.getSpans(segmentStartIndex, segmentEndIndex, CharacterStyle::class.java) + + for (span in spans) { + val spanStart = maxOf(spannedText.getSpanStart(span), segmentStartIndex) + val spanEnd = minOf(spannedText.getSpanEnd(span), segmentEndIndex) + + val start = builder.length - segmentLength + (spanStart - segmentStartIndex) + val end = builder.length - segmentLength + (spanEnd - segmentStartIndex) + + when (span) { + is StyleSpan -> { + val spanStyle = when (span.style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) + else -> SpanStyle() + } + builder.addStyle(spanStyle, start, end) + } + + is TypefaceSpan -> { + builder.addStyle( + SpanStyle( + fontFamily = when (span.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default } - is AbsoluteSizeSpan -> addStyle( - SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), - start, - end - ) - is RelativeSizeSpan -> addStyle( - SpanStyle(fontSize = it.sizeChange.em), - start, - end - ) - is StrikethroughSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.LineThrough), - start, - end - ) - is UnderlineSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.Underline), - start, - end - ) - is SuperscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Superscript), - start, - end - ) - is SubscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Subscript), - start, - end - ) - is ForegroundColorSpan -> addStyle( - SpanStyle(color = Color(it.foregroundColor)), - start, - end - ) - else -> addStyle(SpanStyle(), start, end) + ), + start, + end + ) + } + + is BulletSpan -> { + Log.d("StringResources", "BulletSpan not supported yet") + builder.addStyle(SpanStyle(), start, end) + } + + is AbsoluteSizeSpan -> { + val fontSize = with (density) { + if (span.dip) { + // Convert Dp to Sp + (span.size.dp.toPx() / fontScale).sp + + //dpToSp(span.size.dp.value) + + } else { + // Size is already in pixels; convert pixels to Sp + (span.size / fontScale).sp } } + // if (span.dip) span.size.dp.toSp() else it.size.toSp()), + builder.addStyle(SpanStyle(fontSize = fontSize), start, end) + } + + is RelativeSizeSpan -> builder.addStyle( + SpanStyle(fontSize = span.sizeChange.em), + start, + end + ) + + is StrikethroughSpan -> builder.addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> builder.addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> builder.addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + + is SubscriptSpan -> builder.addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + + // Note: We take anything like `foo` and use the current + // theme accent colour for it (the colour specified in the font tag is ignored). + is ForegroundColorSpan -> { + builder.addStyle(SpanStyle(color = highlightColor), start, end) + } + + else -> { + Log.w(TAG, "Unrecognised span: " + span + " - using default style.") + builder.addStyle(SpanStyle(), start, end) } } - } else { - AnnotatedString(text.toString()) } -} \ No newline at end of file +} + +// External link icon ID & inline content. +// When we see "{icon}" in the string we substitute with the external link icon, or +// whichever icon is suitable for the given string. +val iconExternalLink = "[external-icon]" + +// Add any additional mappings between a given tag and an icon or image here. +fun inlineContentMap(textSize: TextUnit = 15.sp) = mapOf( + iconExternalLink to InlineTextContent( + Placeholder( + width = textSize, + height = textSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Image( + painter = painterResource(id = R.drawable.ic_square_arrow_up_right), + colorFilter = ColorFilter.tint(LocalColors.current.primaryText), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt new file mode 100644 index 0000000000..be0546e8b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.ui.components + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a blurred image, including with legacy compatibility + * for devices running android < 12 + */ +@Composable +fun BlurredImage( + drawableId: Int, + blurRadiusDp: Float, + modifier: Modifier = Modifier, + alpha: Float = 0.8f +) { + val context = LocalContext.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Use Compose's built-in blur for newer devices. + Image( + painter = painterResource(id = drawableId), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.blur(blurRadiusDp.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), + alpha = alpha + ) + } else { + // Convert and blur vector drawable for legacy devices. + val bitmap = getBlurredBitmapFromVector(context, drawableId, blurRadiusDp, applyBlur = true) + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier, + alpha = alpha + ) + } else { + // Fallback: show unblurred image. + Image( + painter = painterResource(id = drawableId), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier, + alpha = alpha + ) + } + } +} + +private fun getBlurredBitmapFromVector( + context: Context, + drawableId: Int, + blurRadius: Float, + applyBlur: Boolean +): Bitmap? { + val drawable = AppCompatResources.getDrawable(context, drawableId) ?: return null + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + Canvas(bitmap).apply { + drawable.setBounds(0, 0, width, height) + drawable.draw(this) + } + return if (applyBlur) { + blurBitmapLegacy(context, bitmap, blurRadius) + } else { + bitmap + } +} + +private fun blurBitmapLegacy(context: Context, bitmap: Bitmap, blurRadius: Float): Bitmap { + val mutableBitmap = if (bitmap.isMutable) bitmap else bitmap.copy(Bitmap.Config.ARGB_8888, true) + val renderScript = RenderScript.create(context) ?: return mutableBitmap + try { + val allocation = Allocation.createFromBitmap(renderScript, mutableBitmap) + val blurScript = ScriptIntrinsicBlur.create(renderScript, allocation.element) + blurScript.setRadius(blurRadius) + blurScript.setInput(allocation) + blurScript.forEach(allocation) + allocation.copyTo(mutableBitmap) + } finally { + renderScript.destroy() + } + return mutableBitmap +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt index 4420e91e78..87e1aae271 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt @@ -4,13 +4,16 @@ import androidx.compose.foundation.border import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalColors @Composable -fun Modifier.border() = this.border( +fun Modifier.border( + shape: Shape = MaterialTheme.shapes.small +) = this.border( width = LocalDimensions.current.borderStroke, brush = SolidColor(LocalColors.current.borders), - shape = MaterialTheme.shapes.small + shape = shape ) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index d06c5a94d9..0d18fbc1d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.buttonShape +import org.thoughtcrime.securesms.ui.theme.sessionShapes import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -112,10 +113,22 @@ fun Button( Button(text, onClick, ButtonType.PrimaryFill, modifier, enabled) } +@Composable fun PrimaryFillButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.PrimaryFill, modifier, enabled, shape = sessionShapes().extraSmall) +} + +@Composable fun PrimaryFillButtonRect(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button(onClick = onClick, ButtonType.PrimaryFill, modifier = modifier, enabled = enabled, shape = sessionShapes().extraSmall, content = content) +} + @Composable fun OutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { Button(text, onClick, ButtonType.Outline(color), modifier, enabled) } +@Composable fun PrimaryOutlineButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryText), modifier, enabled, shape = sessionShapes().extraSmall) +} + @Composable fun OutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { Button( onClick = onClick, @@ -127,11 +140,11 @@ fun Button( } @Composable fun PrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { - Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled) + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryText), modifier, enabled) } @Composable fun PrimaryOutlineButton(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { - Button(onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, content = content) + Button(onClick, ButtonType.Outline(LocalColors.current.primaryText), modifier, enabled, content = content) } @Composable fun SlimOutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { @@ -146,7 +159,7 @@ fun Button( } @Composable fun SlimPrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { - Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, ButtonStyle.Slim) + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryText), modifier, enabled, ButtonStyle.Slim) } @Composable @@ -155,7 +168,7 @@ fun PrimaryOutlineCopyButton( style: ButtonStyle = ButtonStyle.Large, onClick: () -> Unit ) { - OutlineCopyButton(modifier, style, LocalColors.current.primaryButtonFill, onClick) + OutlineCopyButton(modifier, style, LocalColors.current.primaryText, onClick) } @Composable @@ -321,6 +334,10 @@ private fun VariousButtons( SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} BorderlessButton("Borderless Button") {} BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} + PrimaryFillButtonRect("Primary Fill Rect") {} + PrimaryFillButtonRect("Primary Fill Rect Disabled", enabled = false) {} + PrimaryOutlineButtonRect("Outline Button Rect") {} + PrimaryOutlineButtonRect("Outline ButtonDisabled", enabled = false) {} } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt index 54478e69b5..f267aa9466 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -59,7 +59,7 @@ interface ButtonType { @Composable override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.primaryButtonFillText, - containerColor = LocalColors.current.primaryButtonFill, + containerColor = LocalColors.current.primary, disabledContentColor = LocalColors.current.disabled, disabledContainerColor = Color.Transparent ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt index f555c82426..7852a407cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -14,8 +14,7 @@ fun CircularProgressIndicator( ) { androidx.compose.material3.CircularProgressIndicator( modifier = modifier.size(40.dp), - color = color, - strokeWidth = 2.dp + color = color ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 9f3994ce75..2943fb892b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.Placeholder @@ -44,6 +45,7 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.borders import org.thoughtcrime.securesms.ui.theme.text +import org.thoughtcrime.securesms.ui.theme.textSecondary @Preview @Composable @@ -75,6 +77,13 @@ fun PreviewSessionOutlinedTextField() { error = "error", isTextErrorColor = false ) + + SessionOutlinedTextField( + text = "Disabled", + placeholder = "", + isTextErrorColor = false, + enabled = false + ) } } } @@ -85,19 +94,22 @@ fun SessionOutlinedTextField( modifier: Modifier = Modifier, onChange: (String) -> Unit = {}, textStyle: TextStyle = LocalType.current.base, + singleLine: Boolean = false, innerPadding: PaddingValues = PaddingValues(LocalDimensions.current.spacing), + borderShape: Shape = MaterialTheme.shapes.small, placeholder: String = "", onContinue: () -> Unit = {}, error: String? = null, isTextErrorColor: Boolean = error != null, - enabled: Boolean = true, - singleLine: Boolean = false, + enabled: Boolean = true ) { BasicTextField( value = text, onValueChange = onChange, modifier = modifier, - textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + textStyle = textStyle.copy( + color = if (enabled) LocalColors.current.text(isTextErrorColor) else LocalColors.current.textSecondary), + singleLine = singleLine, cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), enabled = enabled, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), @@ -107,7 +119,6 @@ fun SessionOutlinedTextField( onSearch = { onContinue() }, onSend = { onContinue() }, ), - singleLine = singleLine, decorationBox = { innerTextField -> Column(modifier = Modifier.animateContentSize()) { Box( @@ -115,7 +126,7 @@ fun SessionOutlinedTextField( .border( width = LocalDimensions.current.borderStroke, color = LocalColors.current.borders(error != null), - shape = MaterialTheme.shapes.small + shape = borderShape ) .fillMaxWidth() .wrapContentHeight() @@ -126,7 +137,8 @@ fun SessionOutlinedTextField( if (placeholder.isNotEmpty() && text.isEmpty()) { Text( text = placeholder, - style = textStyle.copy(color = LocalColors.current.textSecondary), + style = textStyle.copy(fontFamily = null), + color = LocalColors.current.textSecondary(isTextErrorColor) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 8798337ed5..df672a599b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -21,6 +21,7 @@ data class Dimensions( val minButtonWidth: Dp = 160.dp, val indicatorHeight: Dp = 4.dp, + val borderStroke: Dp = 1.dp, val badgeSize: Dp = 20.dp, @@ -30,6 +31,7 @@ data class Dimensions( val iconXLarge: Dp = 60.dp, val iconXXLarge: Dp = 80.dp, + val shapeExtraSmall: Dp = 8.dp, val shapeSmall: Dp = 12.dp, val shapeMedium: Dp = 16.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt index 50c3957cbd..cbfc642992 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp - fun TextStyle.bold() = copy( fontWeight = FontWeight.Bold ) @@ -109,6 +108,12 @@ data class SessionTypography( fontSize = 14.sp, lineHeight = 16.8.sp, fontWeight = FontWeight.Bold + ), + + val sessionNetworkHeading: TextStyle = TextStyle( + fontSize = 15.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Normal ) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 273f66f79a..593905db92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -33,8 +33,8 @@ interface ThemeColors { val textBubbleReceived: Color val qrCodeContent: Color val qrCodeBackground: Color - val primaryButtonFill: Color val primaryButtonFillText: Color + val primaryText: Color } // extra functions and properties that work for all themes @@ -98,7 +98,6 @@ fun dangerButtonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.danger ) - // Our themes data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = false @@ -116,8 +115,8 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors override val textBubbleReceived = Color.White override val qrCodeContent = background override val qrCodeBackground = text - override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val primaryText = primary override val textAlert: Color = classicDark0 } @@ -137,8 +136,8 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor override val textBubbleReceived = classicLight4 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val primaryButtonFill = text - override val primaryButtonFillText = Color.White + override val primaryButtonFillText = Color.Black + override val primaryText = text override val textAlert: Color = classicLight0 } @@ -158,8 +157,8 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanDark4 override val qrCodeContent = background override val qrCodeBackground = text - override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val primaryText = primary override val textAlert: Color = oceanDark0 } @@ -179,8 +178,8 @@ data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanLight1 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val primaryButtonFill = text - override val primaryButtonFillText = Color.White + override val primaryButtonFillText = Color.Black + override val primaryText = text override val textAlert: Color = oceanLight0 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 1726953784..6c1395d1be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -74,8 +74,10 @@ val buttonShape = pillShape @Composable fun sessionShapes() = Shapes( + extraSmall = RoundedCornerShape(LocalDimensions.current.shapeExtraSmall), small = RoundedCornerShape(LocalDimensions.current.shapeSmall), medium = RoundedCornerShape(LocalDimensions.current.shapeMedium) + ) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index a67bc7718b..4fdb70fa6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -1,142 +1,214 @@ -/* - * Copyright (C) 2014 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ package org.thoughtcrime.securesms.util import android.content.Context import android.text.format.DateFormat +import android.text.format.DateUtils as AndroidxDateUtils +import dagger.hilt.android.qualifiers.ApplicationContext import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.Date -import java.util.Locale +import java.util.* import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.DATE_FORMAT_PREF +import org.session.libsession.utilities.TextSecurePreferences.Companion.TIME_FORMAT_PREF +import org.session.libsignal.utilities.Log -// Enums used to get the locale-aware String for one of the three relative days enum class RelativeDay { TODAY, YESTERDAY, TOMORROW } /** - * Utility methods to help display dates in a nice, easily readable way. + * Utility methods to help display dates in a user-friendly way. */ -object DateUtils : android.text.format.DateUtils() { - - @Suppress("unused") - private val TAG: String = DateUtils::class.java.simpleName - private val DAY_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMdd") - private val HOUR_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMddHH") - - private fun isWithin(millis: Long, span: Long, unit: TimeUnit): Boolean { - return System.currentTimeMillis() - millis <= unit.toMillis(span) +@Singleton +class DateUtils @Inject constructor( + @ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences +) { + private val tag = "DateUtils" + + // Default formats + private val defaultDateFormat = "dd/MM/yyyy" + private val defaultTimeFormat = "HH:mm" + private val twelveHourFormat = "h:mm a" + private val defaultDateTimeFormat = "h:mm a, d MMM yyyy" + + // System defaults and patterns + private val systemDefaultPattern by lazy { + DateFormat.getBestDateTimePattern(Locale.getDefault(), "yyyyMMdd") } - private fun isYesterday(`when`: Long): Boolean { - return isToday(`when` + TimeUnit.DAYS.toMillis(1)) + private val validDatePatterns by lazy { + listOf( + systemDefaultPattern, + "M/d/yy", "d/M/yy", "dd/MM/yyyy", "dd.MM.yyyy", + "dd-MM-yyyy", "yyyy/M/d", "yyyy.M.d", "yyyy-M-d" + ) } + private val validTimePatterns = listOf("HH:mm", "h:mm") - // Method to get the String for a relative day in a locale-aware fashion - public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { + // User preferences with property accessors + private var userDateFormat: String + get() = textSecurePreferences.getStringPreference(DATE_FORMAT_PREF, defaultDateFormat)!! + private set(value) { + textSecurePreferences.setStringPreference(DATE_FORMAT_PREF, value) + } - val now = Calendar.getInstance() + private var userTimeFormat: String + get() = textSecurePreferences.getStringPreference(TIME_FORMAT_PREF, defaultTimeFormat)!! + private set(value) { + textSecurePreferences.setStringPreference(TIME_FORMAT_PREF, value) + } - // To compare a time to 'now' we need to use get a date relative it, so plus or minus a day, or not - val dayAddition = when (relativeDay) { - RelativeDay.TOMORROW -> { 1 } - RelativeDay.YESTERDAY -> { -1 } - else -> 0 // Today + // Public getters + fun getDateFormat(): String = userDateFormat + fun getTimeFormat(): String = userTimeFormat + + // TODO: This is presently unused but it WILL be used when we tie the ability to choose a date format into the UI for SES-360 + fun getUiPrintableDatePatterns(): List = + validDatePatterns.mapIndexed { index, pattern -> + if (index == 0) "$pattern (${context.getString(R.string.theDefault)})" else pattern } - val comparisonTime = Calendar.getInstance().apply { - add(Calendar.DAY_OF_YEAR, dayAddition) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) + // TODO: This is presently unused but it WILL be used when we tie the ability to choose a time format into the UI for SES-360 + fun getUiPrintableTimePatterns(): List = + validTimePatterns.mapIndexed { index, pattern -> + if (index == 0) "$pattern (${context.getString(R.string.theDefault)})" else pattern } - val temp = getRelativeTimeSpanString( - comparisonTime.timeInMillis, - now.timeInMillis, - DAY_IN_MILLIS, - FORMAT_SHOW_DATE).toString() - return temp - } + // Method to get the String for a relative day in a locale-aware fashion + fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { + val now = System.currentTimeMillis() + + // To compare a time to 'now' we need to get a date relative to it, so plus or minus a day, or not + val offset = when (relativeDay) { + RelativeDay.TOMORROW -> 1 + RelativeDay.YESTERDAY -> -1 + else -> 0 // Today + } - fun getFormattedDateTime(time: Long, template: String, locale: Locale): String { - val localizedPattern = getLocalizedPattern(template, locale) - return SimpleDateFormat(localizedPattern, locale).format(Date(time)) + val comparisonTime = now + TimeUnit.DAYS.toMillis(offset.toLong()) + + return AndroidxDateUtils.getRelativeTimeSpanString( + comparisonTime, + now, + AndroidxDateUtils.DAY_IN_MILLIS, + AndroidxDateUtils.FORMAT_SHOW_DATE + ).toString() } - fun getHourFormat(c: Context?): String { - return if ((DateFormat.is24HourFormat(c))) "HH:mm" else "hh:mm a" + // Format a given timestamp with a specific pattern + private fun formatTime(timestamp: Long, pattern: String, locale: Locale = Locale.getDefault()): String { + val formatter = DateTimeFormatter.ofPattern(pattern, locale) + + return Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .format(formatter) } - fun getDisplayFormattedTimeSpanString(c: Context, locale: Locale, timestamp: Long): String { - // If the timestamp is within the last 24 hours we just give the time, e.g, "1:23 PM" or - // "13:23" depending on 12/24 hour formatting. - return if (isToday(timestamp)) { - getFormattedDateTime(timestamp, getHourFormat(c), locale) - } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { - getFormattedDateTime(timestamp, "EEE " + getHourFormat(c), locale) - } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { - getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c), locale) + fun getLocaleFormattedDateTime(timestamp: Long): String = + formatTime(timestamp, defaultDateTimeFormat) + + // Method to get a date in a locale-aware fashion or with a specific pattern + fun getLocaleFormattedDate(timestamp: Long, specificPattern: String = ""): String = + formatTime(timestamp, specificPattern.takeIf { it.isNotEmpty() } ?: userDateFormat) + + // Method to get a time in a locale-aware fashion (i.e., 13:25 or 1:25 PM) + fun getLocaleFormattedTime(timestamp: Long): String = + formatTime(timestamp, userTimeFormat) + + // Method to get a time in a forced 12-hour format (e.g., 1:25 PM rather than 13:25) + fun getLocaleFormattedTwelveHourTime(timestamp: Long): String = + formatTime(timestamp, twelveHourFormat) + + // TODO: While currently unused, this will be tied into the UI when the user can adjust their preferred date format + fun updatePreferredDateFormat(dateFormatPattern: String) { + userDateFormat = if (dateFormatPattern in validDatePatterns) { + dateFormatPattern } else { - getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c) + ", yyyy", locale) + Log.w(tag, "Asked to set invalid date format pattern: $dateFormatPattern - using default instead.") + defaultDateFormat } } - fun getDetailedDateFormatter(context: Context?, locale: Locale): SimpleDateFormat { - val dateFormatPattern = if (DateFormat.is24HourFormat(context)) { - getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale) + // TODO: While currently unused, this will be tied into the UI when the user can adjust their preferred time format + fun updatePreferredTimeFormat(timeFormatPattern: String) { + userTimeFormat = if (timeFormatPattern in validTimePatterns) { + timeFormatPattern } else { - getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale) + Log.w(tag, "Asked to set invalid time format pattern: $timeFormatPattern - using default instead.") + defaultTimeFormat } - - return SimpleDateFormat(dateFormatPattern, locale) } - fun getMediumDateTimeFormatter(): DateTimeFormatter { - return DateTimeFormatter.ofPattern("h:mm a, d MMM yyyy") - } + // Note: Date patterns are in TR-35 format. + // See: https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns + fun getDisplayFormattedTimeSpanString(locale: Locale, timestamp: Long): String = + when { + // If it's within the last 24 hours we just give the time in 24-hour format, such as "13:27" for 1:27pm + isToday(timestamp) -> formatTime(timestamp, userTimeFormat, locale) + + // If it's within the last week we give the day as 3 letters then the time in 24-hour format, such as "Fri 13:27" for Friday 1:27pm + isWithinDays(timestamp, 7) -> formatTime(timestamp, "EEE $userTimeFormat", locale) + + // If it's within the last year we give the month as 3 letters then the time in 24-hour format, such as "Mar 13:27" for March 1:27pm + // CAREFUL: "MMM d + getHourFormat(c)" actually turns out to be "8 July, 17:14" etc. - it is DAY-NUMBER and then MONTH (which can go up to 4 chars) - and THEN the time. Wild. + isWithinDays(timestamp, 365) -> formatTime(timestamp, "MMM d $userTimeFormat", locale) + + // NOTE: The `userDateFormat` is ONLY ever used on dates which exceed one year! + // See the Figma linked in ticket SES-360 for details. + else -> formatTime(timestamp, userDateFormat, locale) + } + + fun getMediumDateTimeFormatter(): DateTimeFormatter = + DateTimeFormatter.ofPattern(defaultDateTimeFormat) // Method to get the String for a relative day in a locale-aware fashion, including using the // auto-localised words for "today" and "yesterday" as appropriate. - fun getRelativeDate( - context: Context, - locale: Locale, - timestamp: Long - ): String { - return if (isToday(timestamp)) { - getLocalisedRelativeDayString(RelativeDay.TODAY) - } else if (isYesterday(timestamp)) { - getLocalisedRelativeDayString(RelativeDay.YESTERDAY) - } else { - getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale) + fun getRelativeDate(locale: Locale, timestamp: Long): String = + when { + isToday(timestamp) -> getLocalisedRelativeDayString(RelativeDay.TODAY) + isYesterday(timestamp) -> getLocalisedRelativeDayString(RelativeDay.YESTERDAY) + else -> formatTime(timestamp, userDateFormat, locale) } - } fun isSameDay(t1: Long, t2: Long): Boolean { - return DAY_PRECISION_DATE_FORMAT.format(Date(t1)) == DAY_PRECISION_DATE_FORMAT.format(Date(t2)) + val date1 = toLocalDate(t1) + val date2 = toLocalDate(t2) + return date1 == date2 } fun isSameHour(t1: Long, t2: Long): Boolean { - return HOUR_PRECISION_DATE_FORMAT.format(Date(t1)) == HOUR_PRECISION_DATE_FORMAT.format(Date(t2)) + val date1 = toLocalDateTime(t1) + val date2 = toLocalDateTime(t2) + return date1.year == date2.year && + date1.month == date2.month && + date1.dayOfMonth == date2.dayOfMonth && + date1.hour == date2.hour } - private fun getLocalizedPattern(template: String, locale: Locale): String { - return DateFormat.getBestDateTimePattern(locale, template) - } + // Helper methods + private fun toLocalDate(timestamp: Long): LocalDate = + Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() + + private fun toLocalDateTime(timestamp: Long): LocalDateTime = + Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime() + + private fun isToday(timestamp: Long): Boolean = + toLocalDate(timestamp) == LocalDate.now() + + private fun isYesterday(timestamp: Long): Boolean = + toLocalDate(timestamp) == LocalDate.now().minusDays(1) + + private fun isWithinDays(timestamp: Long, days: Long): Boolean = + System.currentTimeMillis() - timestamp <= TimeUnit.DAYS.toMillis(days) + + private fun getLocalizedPattern(template: String, locale: Locale): String = + DateFormat.getBestDateTimePattern(locale, template) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt index c35ddbefaa..b739ad5251 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt @@ -1,17 +1,34 @@ package org.thoughtcrime.securesms.util +import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat import java.util.Locale +import kotlin.math.abs object NumberUtil { + // Method to format a number so that 1000 becomes 1k, 1200 becomes 1.2k etc. - typically used to display + // the count of emoji reactions. + // Note: This method is only designed to handle values in the scale of 0..999_999 - it will return "1000k" + // etc. for values greater than a million. @JvmStatic - fun getFormattedNumber(count: Long): String { - val isNegative = count < 0 - val absoluteCount = Math.abs(count) - if (absoluteCount < 1000) return count.toString() + fun getFormattedNumber(value: Long): String { + + // Values less than 1000 get returned as just that + // Note: While we abs the value for range, we actually return the original value with the sign + val absoluteCount = abs(value) + if (absoluteCount < 1000) return value.toString() + + // Otherwise we work out the thousands and hundreds values to use in "1.2 etc. val thousands = absoluteCount / 1000 val hundreds = (absoluteCount - thousands * 1000) / 100 - val negativePrefix = if (isNegative) "-" else "" + + // Set a negative prefix to be either a minus sign or nothing + val negativePrefix = if (value < 0) "-" else "" + + // Finally, return the formatted string return if (hundreds == 0L) { String.format(Locale.ROOT, "$negativePrefix%dk", thousands) } else { @@ -19,4 +36,69 @@ object NumberUtil { } } + /** + * Extension function on Number to format it with specified decimal places using locale settings. + * + * @param decimalPlaces Number of decimal places to display + * @param locale Locale for formatting (defaults to system locale) + * @return Formatted string representation of the number + */ + fun Number.formatWithDecimalPlaces(decimalPlaces: Int, locale: Locale = Locale.getDefault()): String { + val pattern = if (decimalPlaces > 0) { + "#,##0.${"0".repeat(decimalPlaces)}" + } else { + "#,##0" + } + + // Create locale-specific decimal format symbols + val symbols = DecimalFormatSymbols(locale) + + return DecimalFormat(pattern, symbols).apply { + this.isDecimalSeparatorAlwaysShown = decimalPlaces > 0 + this.maximumFractionDigits = decimalPlaces + this.minimumFractionDigits = decimalPlaces + }.format(this) + } + + /** + * Extension function on Number to format it with abbreviated suffixes (K, M, B, T) + * in a locale-aware way. + * + * @param locale Locale for formatting (defaults to system locale) + * @param minFractionDigits Minimum fraction digits to display + * @param maxFractionDigits Maximum fraction digits to display + * @return Formatted string with appropriate suffix, or full number if below 1,000. + */ + fun Number.formatAbbreviated( + locale: Locale = Locale.getDefault(), + minFractionDigits: Int = 1, + maxFractionDigits: Int = 1 + ): String { + // Convert to BigDecimal for precise arithmetic + val bd = when (this) { + is BigDecimal -> this + is Long, is Int, is Short, is Byte -> BigDecimal(this.toLong()) + is Double, is Float -> BigDecimal.valueOf(this.toDouble()) + else -> throw IllegalArgumentException("Unsupported number type: ${this::class.java.name}") + } + // Create a locale-aware formatter + val formatter = NumberFormat.getNumberInstance(locale).apply { + this.minimumFractionDigits = minFractionDigits + this.maximumFractionDigits = maxFractionDigits + this.isGroupingUsed = false + } + val absValue = bd.abs() + return when { + absValue >= BigDecimal(1_000_000_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000_000_000)))}T" + absValue >= BigDecimal(1_000_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000_000)))}B" + absValue >= BigDecimal(1_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000)))}M" + absValue >= BigDecimal(1_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000)))}K" + else -> formatter.format(bd) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java b/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java index 7e45d51996..8e1a6d2bfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java @@ -5,9 +5,6 @@ import androidx.annotation.NonNull; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.thoughtcrime.securesms.ApplicationContext; - import java.util.LinkedHashMap; import java.util.Map; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 8bd1f68c49..268f188479 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -248,4 +248,4 @@ fun View.setSafeOnClickListener(clickIntervalMS: Long = 1000L, onSafeClick: (Vie onSafeClick(it) } setOnClickListener(safeClickListener) -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/session_network_logo.xml b/app/src/main/res/drawable/session_network_logo.xml new file mode 100644 index 0000000000..285ccee0a6 --- /dev/null +++ b/app/src/main/res/drawable/session_network_logo.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_1.xml b/app/src/main/res/drawable/session_node_lines_1.xml new file mode 100644 index 0000000000..8abadc694d --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_1.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_10.xml b/app/src/main/res/drawable/session_node_lines_10.xml new file mode 100644 index 0000000000..afd23275f5 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_2.xml b/app/src/main/res/drawable/session_node_lines_2.xml new file mode 100644 index 0000000000..4fe0589a0d --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_2.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_3.xml b/app/src/main/res/drawable/session_node_lines_3.xml new file mode 100644 index 0000000000..fbaefe3a82 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_3.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_4.xml b/app/src/main/res/drawable/session_node_lines_4.xml new file mode 100644 index 0000000000..641d0229ac --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_4.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_5.xml b/app/src/main/res/drawable/session_node_lines_5.xml new file mode 100644 index 0000000000..4cfeb0ec45 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_5.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_6.xml b/app/src/main/res/drawable/session_node_lines_6.xml new file mode 100644 index 0000000000..c1df45092b --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_6.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_7.xml b/app/src/main/res/drawable/session_node_lines_7.xml new file mode 100644 index 0000000000..f11999108a --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_7.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_8.xml b/app/src/main/res/drawable/session_node_lines_8.xml new file mode 100644 index 0000000000..16c2552dce --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_8.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_9.xml b/app/src/main/res/drawable/session_node_lines_9.xml new file mode 100644 index 0000000000..b37ce417f9 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_9.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_nodes_1.xml b/app/src/main/res/drawable/session_nodes_1.xml new file mode 100644 index 0000000000..94f3b408e4 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_1.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/session_nodes_10.xml b/app/src/main/res/drawable/session_nodes_10.xml new file mode 100644 index 0000000000..219c0e6e35 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_10.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_2.xml b/app/src/main/res/drawable/session_nodes_2.xml new file mode 100644 index 0000000000..5ab0ae04d5 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_2.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_3.xml b/app/src/main/res/drawable/session_nodes_3.xml new file mode 100644 index 0000000000..5e4c490675 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_3.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_4.xml b/app/src/main/res/drawable/session_nodes_4.xml new file mode 100644 index 0000000000..2700dcb250 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_4.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_5.xml b/app/src/main/res/drawable/session_nodes_5.xml new file mode 100644 index 0000000000..3eecdf0f0e --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_5.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_6.xml b/app/src/main/res/drawable/session_nodes_6.xml new file mode 100644 index 0000000000..7fa062d182 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_6.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_7.xml b/app/src/main/res/drawable/session_nodes_7.xml new file mode 100644 index 0000000000..093877b0ba --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_7.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_8.xml b/app/src/main/res/drawable/session_nodes_8.xml new file mode 100644 index 0000000000..309aea6d36 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_8.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_9.xml b/app/src/main/res/drawable/session_nodes_9.xml new file mode 100644 index 0000000000..8f67bf9777 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_9.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/space_mono_bold.ttf b/app/src/main/res/font/space_mono_bold.ttf deleted file mode 100644 index 4acd36ac3874ca76542eec2fcedc51cb63053778..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89020 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%0Ay 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 diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7165580f2a..ce1aafd76b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -211,10 +211,16 @@ @dimen/medium_spacing 20sp ?android:textColorPrimary - @font/space_mono_regular + monospace viewStart + + + + + + + + + diff --git a/libsession/build.gradle.kts b/libsession/build.gradle.kts index 35532b3a2c..f951095391 100644 --- a/libsession/build.gradle.kts +++ b/libsession/build.gradle.kts @@ -53,25 +53,16 @@ dependencies { api(libs.libsession.util.android) - implementation(libs.jna) { - artifact { - type = "aar" - } - } implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) - implementation(libs.androidx.preference.ktx) implementation(libs.material) implementation(libs.protobuf.java) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation(libs.glide) implementation(libs.stream) - implementation(libs.roundedimageview) - implementation(libs.kryo) implementation(libs.jackson.databind) - implementation(libs.curve25519.java) implementation(libs.okhttp) implementation(libs.phrase) implementation(libs.kotlin.reflect) diff --git a/libsession/src/main/res/values/colors.xml b/libsession/src/main/res/values/colors.xml index bd0ea5e635..a39339281a 100644 --- a/libsession/src/main/res/values/colors.xml +++ b/libsession/src/main/res/values/colors.xml @@ -1,7 +1,6 @@ #353535 - #0C0C0C #171717 #36383C #333132 @@ -18,11 +17,6 @@ #0a0a0a - @color/accent_green - @color/accent_green - - @color/signal_primary - @color/signal_primary_dark #ffffffff #ff000000 @@ -51,14 +45,6 @@ @color/transparent_black_90 - #ff31F196 - #ff57C9FA - #ffC993FF - #ffFF95EF - #ffFF9C8E - #ffFCB159 - #ffFAD657 - #A64B00 diff --git a/libsession/src/main/res/values/conversation_colors.xml b/libsession/src/main/res/values/conversation_colors.xml deleted file mode 100644 index 57d250ebb8..0000000000 --- a/libsession/src/main/res/values/conversation_colors.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - #cc163d - #eda6ae - #8a0f29 - - #c73800 - #eba78e - #872600 - - #756c53 - #c4b997 - #58513c - - #3b7845 - #8fcc9a - #2b5934 - - #1c8260 - #9bcfbd - #36544a - - #067589 - #a5cad5 - #055968 - - #336ba3 - #adc8e1 - #285480 - - #5951c8 - #c2c1e8 - #4840a0 - - #862caf - #cdaddc - #6b248a - - #a23474 - #dcb2ca - #881b5b - - #895d66 - #cfb5bb - #6a4e54 - - #6b6b78 - #bebec6 - #5a5a63 - - @color/textsecure_primary - @color/textsecure_primary - @color/textsecure_primary_dark - \ No newline at end of file diff --git a/libsession/src/main/res/values/dimens.xml b/libsession/src/main/res/values/dimens.xml index 14dc645642..5428b89e65 100644 --- a/libsession/src/main/res/values/dimens.xml +++ b/libsession/src/main/res/values/dimens.xml @@ -19,7 +19,7 @@ 36dp 46dp 76dp - 128dp + 14dp 1dp 36dp @@ -96,7 +96,6 @@ 14dp 24dp - 16sp 16dp 56dp diff --git a/libsession/src/main/res/values/themes.xml b/libsession/src/main/res/values/themes.xml index 9aacedf6c8..0d2c4cc409 100644 --- a/libsession/src/main/res/values/themes.xml +++ b/libsession/src/main/res/values/themes.xml @@ -1,24 +1,4 @@ - - - - - - - + \ No newline at end of file diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Reflection.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Reflection.kt deleted file mode 100644 index 11f5a28246..0000000000 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Reflection.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.session.libsignal.utilities - -import kotlin.reflect.KProperty1 -import kotlin.reflect.full.memberProperties - -@Suppress("UNCHECKED_CAST") -fun T.getProperty(name: String): U { - val p = this::class.memberProperties.first { it.name == name } as KProperty1 - return p.get(this) -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 64cca3f031..b6cdc45b3c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,4 @@ includeBuild("build-logic") include(":app") include(":liblazysodium") -include(":libsession") -include(":libsignal") include(":content-descriptions") // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing \ No newline at end of file From 52cf6a621ec1af035fae9568805be55cece57c82 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 16 May 2025 15:01:40 +1000 Subject: [PATCH 294/867] Using SecureRandom as a direct instance --- .../libsession/messaging/utilities/MessageWrapper.kt | 2 +- .../org/session/libsignal/utilities/KeyHelper.java | 10 +++------- .../java/org/session/libsignal/utilities/Util.java | 10 +++------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index ee5b5f87f3..cee136afa4 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -53,7 +53,7 @@ object MessageWrapper { request = WebSocketRequestMessage.newBuilder().apply { verb = "PUT" path = "/api/v1/message" - id = SecureRandom.getInstance("SHA1PRNG").nextLong() + id = SecureRandom().nextLong() body = envelope.toByteString() }.build() type = WebSocketMessage.Type.REQUEST diff --git a/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java b/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java index 0c807d1b92..f4fe56f332 100644 --- a/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java +++ b/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java @@ -28,12 +28,8 @@ private KeyHelper() {} * @return the generated registration ID. */ public static int generateRegistrationId(boolean extendedRange) { - try { - SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG"); - if (extendedRange) return secureRandom.nextInt(Integer.MAX_VALUE - 1) + 1; - else return secureRandom.nextInt(16380) + 1; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } + SecureRandom secureRandom = new SecureRandom(); + if (extendedRange) return secureRandom.nextInt(Integer.MAX_VALUE - 1) + 1; + else return secureRandom.nextInt(16380) + 1; } } diff --git a/app/src/main/java/org/session/libsignal/utilities/Util.java b/app/src/main/java/org/session/libsignal/utilities/Util.java index 3b3b7aa5e6..dceb94dbfe 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Util.java +++ b/app/src/main/java/org/session/libsignal/utilities/Util.java @@ -69,13 +69,9 @@ public static boolean isEmpty(String value) { } public static byte[] getSecretBytes(int size) { - try { - byte[] secret = new byte[size]; - SecureRandom.getInstance("SHA1PRNG").nextBytes(secret); - return secret; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } + byte[] secret = new byte[size]; + new SecureRandom().nextBytes(secret); + return secret; } public static String readFully(InputStream in) throws IOException { From 7bc301b80d6ccc4a312fc6920e69154516e00135 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 16 May 2025 15:21:25 +1000 Subject: [PATCH 295/867] Description was being overriden on subsequent poll --- .../messaging/sending_receiving/pollers/OpenGroupPoller.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 096039d45a..e40b5f00a0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -70,7 +70,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S server = server, room = pollInfo.token, name = name ?: "", - description = pollInfo.details?.description, + description = (pollInfo.details?.description ?: existingOpenGroup?.description), publicKey = publicKey, imageId = (pollInfo.details?.imageId ?: existingOpenGroup?.imageId), canWrite = pollInfo.write, From 54b5b6a74033658951f4251d2f5604c0a222ea65 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 16 May 2025 15:39:45 +1000 Subject: [PATCH 296/867] Move crowdin string file to strings.xml (#1168) --- app/src/main/res/values/strings.xml | 984 ++++++++++++++++++- app/src/main/res/values/strings_crowdin.xml | 985 -------------------- 2 files changed, 982 insertions(+), 987 deletions(-) delete mode 100644 app/src/main/res/values/strings_crowdin.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78554f9ebb..d4c3226b5f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,985 @@ - - + Session + About + Accept + Copy Account ID + Account ID + Account ID Copied + Copy your Account ID then share it with your friends so they can message you. + Enter Account ID + This Account ID is invalid. Please check and try again. + Enter Account ID or ONS + Invite Account ID or ONS + Hey, I\'ve been using {app_name} to chat with complete privacy and security. Come join me! My Account ID is\n\n{account_id}\n\nDownload it at {session_download_url} + Your Account ID + This is your Account ID. Other users can scan it to start a conversation with you. + Actual Size + Add + Add Admins + Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. + Admins cannot be removed. + {name} and {count} others were promoted to Admin. + Promote Admins + Are you sure you want to promote {name} to admin? Admins cannot be removed. + Are you sure you want to promote {name} and {count} others to admin? Admins cannot be removed. + Promote to Admin + Are you sure you want to promote {name} and {other_name} to admin? Admins cannot be removed. + {name} was promoted to Admin. + Admin promotion failed + Failed to promote {name} in {group_name} + Failed to promote {name} and {count} others in {group_name} + Failed to promote {name} and {other_name} in {group_name} + Promotion not sent + Admin promotion sent + Promotion status unknown + Remove Admins + Remove as Admin + There are no Admins in this Community. + Failed to remove {name} as Admin. + Failed to remove {name} and {count} others as Admin. + Failed to remove {name} and {other_name} as Admin. + {name} was removed as Admin. + {name} and {count} others were removed as Admin. + {name} and {other_name} were removed as Admin. + + Sending admin promotion + Sending admin promotions + + Admin Settings + {name} and {other_name} were promoted to Admin. + +{count} + Anonymous + App Icon + Change App Icon and Name + Changing the app icon and name requires {app_name} to be closed. Notifications will continue to use the default {app_name} icon and name. + Alternate app icon and name is displayed on home screen and app drawer. + The selected app icon and name is displayed on the home screen and app drawer. + Icon and name + Alternate app icon is displayed on home screen and app library. App name will still appear as \'{app_name}\'. + Use alternate app icon + Use alternate app icon and name + Select alternate app icon + Icon + Calculator + MeetingSE + News + Notes + Stocks + Weather + Auto dark-mode + Hide Menu Bar + Language + Choose your language setting for {app_name}. {app_name} will restart when you change your language setting. + How are you? + I\'m good thanks, you? + I\'m doing great, thanks. + Primary Color + Themes + Classic Dark + Classic Light + Ocean Dark + Ocean Light + Zoom + Zoom In + Zoom Out + Attachment + Attachments + Add attachment + Unnamed Album + Auto-download Attachments + Automatically download media and files from this chat. + Would you like to automatically download all files from {conversation_name}? + Auto Download + Clear All Attachments + Are you sure you want to clear all attachments? Messages with attachments will also be deleted. + Click to download {file_type} + Collapse attachment options + Collecting attachments... + Download Attachment + Duration: + Error attaching file + Failed to select attachment + Can\'t find an app to select media. + This file type is not supported. + Unable to send more than 32 image and video files at once. + Unable to open file. + Error sending file + Please send files as separate messages. + Files must be less than 10MB + Cannot attach images and video with other file types. Try sending other files in a separate message. + Attachment expired + File ID: + File Size: + File Type: + You don\'t have any files in this conversation. + Unable to remove metadata from file. + Loading Newer Media... + Loading Newer Files... + Loading Older Media... + Loading Older Files... + {name} on {date_time} + You don\'t have any media in this conversation. + Media saved by {name} + Move and Scale + N/A + {emoji} Attachment + {author}: {emoji} Attachment + Resolution: + Unable to save file. + Send to {name} + Tap to download {file_type} + This Month + This Week + Attachments you save can be accessed by other apps on your device. + Audio + No audio input found + No audio output found + Unable to play audio file. + Unable to record audio. + Authentication Failed + Too many failed authentication attempts. Please try again later. + Authentication could not be accessed. + Authenticate to open {app_name}. + Back + Ban and Delete All + Ban failed + Unban failed + Unban User + Enter the Account ID of the user you are unbanning + User unbanned + Ban User + User banned + Enter the Account ID of the user you are banning + Block + Unblock this contact to send a message. + No blocked contacts + Blocked {name} + Are you sure you want to block {name}? Blocked users cannot send you message requests, group invites or call you. + Unblock + Are you sure you want to unblock {name}? + Are you sure you want to unblock {name} and {count} others? + Are you sure you want to unblock {name} and 1 other? + Unblocked {name} + Call + {name} called you + You cannot start a new call. Finish your current call first. + Connecting... + End call + Call Ended + Failed to answer call + Failed to start call + Call in progress + Incoming call from {name} + Incoming call + You missed a call from {name} because you haven\'t granted microphone access. + Missed Call + Missed call from {name} + Voice and Video Calls require notifications to be enabled in your device system settings. + Call Permissions Required + You can enable the \"Voice and Video Calls\" permission in Privacy Settings. + You can enable the \"Voice and Video Calls\" permission in Permissions Settings. + Reconnecting… + Ringing... + {app_name} Call + Calls (Beta) + Voice and Video Calls + Voice and Video Calls (Beta) + Your IP is visible to your call partner and a Session Technology Foundation server while using beta calls. + Enables voice and video calls to and from other users. + You called {name} + You missed a call from {name} because you haven\'t enabled Voice and Video Calls in Privacy Settings. + No camera found + Camera unavailable. + Grant Camera Access + {app_name} needs camera access to take photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". + {app_name} needs camera access to take photos and videos, or scan QR codes. + {app_name} needs camera access to scan QR codes + Cancel + Failed to change password + Clear + Clear All + Clear All Data + This will permanently delete your messages and contacts. Would you like to clear this device only, or delete your data from the network as well? + Data Not Deleted + + Data not deleted by %1$d Service Node. Service Node ID: %2$s. + Data not deleted by %1$d Service Nodes. Service Node IDs: %2$s. + + An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead? + Clear Device + Clear device and network + Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts. + Are you sure you want to clear your device? + Clear device only + Clear Device and Restart + Clear Device and Restore + Clear All Messages + Are you sure you want to clear all messages from your conversation with {name} from your device? + Are you sure you want to clear all messages from your conversation with {name} on this device? + Are you sure you want to clear all {community_name} messages from your device? + Are you sure you want to clear all messages from {community_name} on this device? + Clear for everyone + Clear for me + Are you sure you want to clear all {group_name} messages? + Are you sure you want to clear all messages from {group_name}? + Are you sure you want to clear all {group_name} messages from your device? + Are you sure you want to clear all messages from {group_name} on this device? + Are you sure you want to clear all Note to Self messages from your device? + Are you sure you want to clear all Note to Self messages on this device? + Clear on this device + Close + Close App + Close Window + Commit Hash: {hash} + This will ban the selected user from this Community and delete all their messages. Are you sure you want to continue? + This will ban the selected user from this Community. Are you sure you want to continue? + Enter Community URL + Invalid URL + Please check the Community URL and try again. + Community Error + Oops, an error occurred. Please try again later. + Community Invitation + Join Community + Are you sure you want to join {community_name}? + Failed to join community + Or join one of these... + Joined Community + You are already a member of this community. + Leave Community + Failed to leave {community_name} + Unknown Community + Community URL + Copy Community URL + Confirm + Contacts + Delete Contact + Are you sure you want to delete {name} from your contacts? New messages from {name} will arrive as a message request. + You don\'t have any contacts yet + Select Contacts + User Details + Camera + Choose an action to start a conversation + Media message + Message composition + Thumbnail of image from quoted message + Create a conversation with a new contact + Add to home screen + Added to home screen + Audio Messages + Autoplay Audio Messages + Autoplay consecutively sent audio messages. + Blocked Contacts + Communities + Delete Conversation + Are you sure you want to delete your conversation with {name}? New messages from {name} will start a new conversation. + Conversation deleted + There are no messages in {conversation_name}. + Enter Key + Function of the enter key when typing in a conversation. + SHIFT + ENTER sends a message, ENTER starts a new line + ENTER sends a message, SHIFT + ENTER starts a new line + Groups + Message Trimming + Trim Communities + Delete messages from Community conversations older than 6 months, and where there are over 2,000 messages. + New Conversation + You don\'t have any conversations yet + Send with Enter Key + Tapping the Enter Key will send message instead of starting a new line. + All Media + Spell Check + Enable spell check when typing messages. + Start Conversation + Copied + Copy + Create + Creating Call + Cut + Are you sure you want to delete all messages, attachments, and account data from this device and create a new account? + A database error occurred.\n\nExport your application logs to share for troubleshooting. If this is unsuccessful, reinstall {app_name} and restore your account. + Are you sure you want to delete all messages, attachments, and account data from this device and restore your account from the network? + We\'ve noticed {app_name} is taking a long time to start.\n\nYou can continue to wait, export your device logs to share for troubleshooting, or try restarting {app_name}. + Your app database is incompatible with this version of {app_name}. Reinstall the app and restore your account to generate a new database and continue using {app_name}.\n\nWarning: This will result in the loss of all messages and attachments older than two weeks. + Optimizing Database + Debug Log + Decline + Delete + Some of your devices are using outdated versions. Syncing may be unreliable until they are updated. + Block This User + Block User + Group Settings + Notify for Mentions Only + When enabled, you\'ll only be notified for messages mentioning you. + Message Sound + Permanently delete the messages in this conversation? + Can\'t leave while adding or removing other members. + Legacy + Original version of disappearing messages. + {name} set the disappearing message timer to {time} + Please wait while the group is created... + Failed to Update Group + You don’t have permission to delete others’ messages + Are you sure you want to delete {name} from your contacts?\n\nThis will delete your conversation, including all messages and attachments. Future messages from {name} will appear as a message request. + Are you sure you want to delete your conversation with {name}?\nThis will permanently delete all messages and attachments. + + Delete Message + Delete Messages + + + Are you sure you want to delete this message? + Are you sure you want to delete these messages? + + + Message deleted + Messages deleted + + This message was deleted + This message was deleted on this device + + Are you sure you want to delete this message from this device only? + Are you sure you want to delete these messages from this device only? + + Are you sure you want to delete this message for everyone? + Delete on this device only + Delete on all my devices + Delete for everyone + + Failed to delete message + Failed to delete messages + + + This message cannot be deleted from all your devices + Some of the messages you have selected cannot be deleted from all your devices + + + This message cannot be deleted for everyone + Some of the messages you have selected cannot be deleted for everyone + + Are you sure you want to delete these messages for everyone? + Deleting + Toggle Developer Tools + Start Dictation... + Disappearing Messages + Message will delete in {time_large} + Auto-deletes in {time_large} + Message will delete in {time_large} {time_small} + Auto-deletes in {time_large} {time_small} + Delete Type + This setting applies to everyone in this conversation. + This setting applies to messages you send in this conversation. + This setting applies to everyone in this conversation.\nOnly group admins can change this setting. + Disappear After {disappearing_messages_type} - {time} + Disappear After Read + Messages delete after they have been read. + Disappear After Read - {time} + Disappear After Send + Messages delete after they have been sent. + Disappear After Send - {time} + Follow Setting + Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages? + Set your messages to disappear {time} after they have been {disappearing_messages_type}? + {name} is using an outdated client. Disappearing messages may not work as expected. + Only group admins can change this setting. + Sent + {name} has set messages to disappear {time} after they have been {disappearing_messages_type}. + You set messages to disappear {time} after they have been {disappearing_messages_type}. + Timer + {name} has turned disappearing messages off. Messages they send will no longer disappear. + {name} has turned disappearing messages off. + You turned off disappearing messages. Messages you send will no longer disappear. + You turned off disappearing messages. + read + sent + {admin_name} updated disappearing message settings. + You updated disappearing message settings. + Dismiss + It can be your real name, an alias, or anything else you like — and you can change it at any time. + Enter your display name + Please enter a display name + Please enter a shorter display name + We were unable to load your display name. Please enter a new display name to continue. + Pick a new display name + Pick your display name + Set Display Name + Your Display Name is visible to users, groups and communities you interact with. + Document + Done + Download + Downloading... + Draft + Edit + Emoji and Symbols + Activities + Animals and Nature + Flags + Food and Drink + Objects + Recently Used + Smileys and People + Symbols + Travel and Places + Are you sure you want to clear all {emoji}? + Slow down! You\'ve sent too many emoji reacts. Try again soon + + And %1$d other has reacted %2$s to this message. + And %1$d others have reacted %2$s to this message. + + {name} reacted with {emoji_name} + {name} and {other_name} reacted with {emoji_name} + {name} and {count} others reacted with {emoji_name} + You reacted with {emoji_name} + You and {count} others reacted with {emoji_name} + You and {name} reacted with {emoji_name} + Reacted to your message {emoji} + Enable + Please check your internet connection and try again. + Copy Error and Quit + Database Error + Something went wrong. Please try again later. + An unknown error occurred. + Failed to download + Failures + File + Files + Follow system settings + Forever + From: + Toggle Full Screen + GIF + Giphy + {app_name} will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs. + Groups have a maximum of 100 members + Create Group + Please pick at least one other group member. + Delete Group + Are you sure you want to delete {group_name}?\n\nThis will remove all members and delete all group content. + Are you sure you want to delete {group_name}? + {group_name} has been deleted by a group admin. You will not be able to send any more messages. + Enter a group description + Group display picture updated. + Edit Group + Group Error + Failed to create group. Please check your internet connection and try again. + Failed to join {group_name} + Set Group Information + Are you sure you want to delete this group invite? + Invite failed + Failed to invite {name} and {count} others to {group_name} + Failed to invite {name} and {other_name} to {group_name} + Failed to invite {name} to {group_name} + Invite not sent + {name} invited you to rejoin {group_name}, where you are an Admin. + You were invited to rejoin {group_name}, where you are an Admin. + + Sending invite + Sending invites + + Invite sent + Invite status unknown + Group invite successful + Users must have the latest release to receive invitations + You were invited to join the group. + You and {count} others were invited to join the group. + You and {other_name} were invited to join the group. + You were invited to join the group. Chat history was shared. + Leave Group + Are you sure you want to leave {group_name}? + Are you sure you want to leave {group_name}?\n\nThis will remove all members and delete all group content. + Failed to leave {group_name} + {name} left the group. + {name} and {count} others left the group. + {name} and {other_name} left the group. + {name} was invited to join the group. + {name} was invited to join the group. Chat history was shared. + {name} and {count} others were invited to join the group. Chat history was shared. + {name} and {other_name} were invited to join the group. Chat history was shared. + {name} and {count} others were invited to join the group. + {name} and {other_name} were invited to join the group. + You and {count} others were invited to join the group. Chat history was shared. + You and {other_name} were invited to join the group. Chat history was shared. + You left the group. + Group Members + There are no other members in this group. + Group Name + Enter a group name + Please enter a group name. + Please enter a shorter group name. + Group name is now {group_name}. + Group name updated. + Group name is visible to all group members. + You have no messages from {group_name}. Send a message to start the conversation! + This group has not been updated in over 30 days. You may experience issues sending messages or viewing group information. + You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. + Pending removal + You were promoted to Admin. + You and {count} others were promoted to Admin. + You and {other_name} were promoted to Admin. + Would you like to remove {name} from {group_name}? + Would you like to remove {name} and {count} others from {group_name}? + Would you like to remove {name} and {other_name} from {group_name}? + + Remove user and their messages + Remove users and their messages + + + Remove user + Remove users + + {name} was removed from the group. + {name} and {count} others were removed from the group. + {name} and {other_name} were removed from the group. + You were removed from {group_name}. + You were removed from the group. + You and {count} others were removed from the group. + You and {other_name} were removed from the group. + Set Group Display Picture + Unknown Group + Group updated + Handling Connection Candidates + FAQ + Help us translate {app_name} + Report a bug + Share some details to help us resolve your issue. Export your logs, then upload the file through {app_name}\'s Help Desk. + Export Logs + Export your logs, then upload the file through {app_name}\'s Help Desk. + Save to desktop + Save this file to your desktop, then share it with {app_name} developers. + Support + We\'d love your feedback + Hide + Toggle system menu bar visibility + Are you sure you want to hide Note to Self from your conversation list? + Hide Others + Image + images + Incognito Keyboard + Request incognito mode if available. Depending on the keyboard you are using, your keyboard may ignore this request. + Info + Invalid shortcut + + Invite Failed + Invites Failed + + + The invite could not be sent. Would you like to try again? + The invites could not be sent. Would you like to try again? + + Join + Later + Learn More + Leave + Leaving... + This group is now read-only. Recreate this group to keep chatting. + This group is now read-only. Ask the group admin to recreate this group to keep chatting. + Groups have been upgraded! Recreate this group for improved reliability. This group will become read-only at {date}. + Groups have been upgraded! Ask the group admin to recreate this group for improved reliability. This group will become read-only at {date}. + Chat history will not be transferred to the new group. You can still view all chat history in your old group. + {name} joined the group. + {name} and {count} others joined the group. + You and {count} others joined the group. + You and {other_name} joined the group. + {name} and {other_name} joined the group. + You joined the group. + Link Previews + Show link previews for supported URLs. + Enable Link Previews + Unable to load link preview + Preview not loaded for unsecure link + Display previews for URLs you send and receive. This can be useful, however {app_name} must contact linked websites to generate previews. You can always turn off link previews in {app_name}\'s settings. + Send Link Previews + You will not have full metadata protection when sending link previews. + Link Previews Are Off + {app_name} must contact linked websites to generate previews of links you send and receive.\n\nYou can turn them on in {app_name}\'s settings. + Load Account + Loading your account + Loading... + Lock App + Require fingerprint, PIN, pattern or password to unlock {app_name}. + Require Touch ID, Face ID or your passcode to unlock {app_name}. + You must enable a passcode in your iOS Settings in order to use Screen Lock. + {app_name} is locked + Quick response unavailable when {app_name} is locked! + Lock status + Tap to unlock + {app_name} is unlocked + Manage Members + Max + Media + + %1$d member + %1$d members + + + %1$d active member + %1$d active members + + Add Account ID or ONS + Invite Contacts + + Send Invite + Send Invites + + Would you like to share group message history with {name}? + Would you like to share group message history with {name} and {count} others? + Would you like to share group message history with {name} and {other_name}? + Share message history + Share new messages only + Invite + Message + This message is empty. + Message delivery failed + Message limit reached + Received a message encrypted using an old version of {app_name} that is no longer supported. Please ask the sender to update to the most recent version and resend the message. + Original message not found + Message Info + Mark read + Mark unread + + New Message + New Messages + + Start a new conversation by entering your friend\'s Account ID or ONS. + Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code. + + You\'ve got a new message. + You\'ve got %1$d new messages. + + + You\'ve got a new message in %1$s. + You\'ve got %1$d new messages in %2$s. + + Replying to + You cannot send attachments until your Message Request is accepted + You cannot send voice messages until your Message Request is accepted + {name} invited you to join {group_name}. + Sending a message to this group will automatically accept the group invite. + Your message request is currently pending. + You will be able to send voice messages and attachments once the recipient has approved this message request. + You have accepted the message request from {name}. + Sending a message to this user will automatically accept their message request and reveal your Account ID. + Your message request has been accepted. + Are you sure you want to clear all message requests and group invites? + Community Message Requests + Allow message requests from Community conversations. + Are you sure you want to delete this message request? + You have a new message request + No pending message requests + {name} has message requests from Community conversations turned off, so you cannot send them a message. + Select Message + {author}: {message_snippet} + Failed to send + Failed to sync + Syncing + Unread messages + Voice Message + Hold to record a voice message + Slide to Cancel + {emoji} Voice Message + {author}: {emoji} Voice Message + Messages + Minimize + Next + Choose a nickname for {name}. This will appear to you in your one-to-one and group conversations. + Enter nickname + Please enter a shorter nickname + Remove Nickname + Set Nickname + No + No Suggestions + None + Not now + Note to Self + You have no messages in Note to Self. + Hide Note to Self + Are you sure you want to hide Note to Self? + All Messages + Notification Content + The information shown in notifications. + Name and Content + Name Only + No Name or Content + Fast Mode + You\'ll be notified of new messages reliably and immediately using Google\'s notification Servers. + You\'ll be notified of new messages reliably and immediately using Huawei’s notification servers. + You\'ll be notified of new messages reliably and immediately using Apple\'s notification Servers. + Go to device notification settings + Notifications - All + Notifications - Mentions Only + Notifications - Muted + {name} to {conversation_name} + You may have received messages while your {device} was restarting. + LED color + Mentions Only + Message notifications + Most recent from {name} + Mute + Mute for {time_large} + Unmute + Muted + Muted for {time_large} + Muted until {date_time} + Slow Mode + {app_name} will occasionally check for new messages in the background. + Sound + Sound when App is open + Audio Notifications + Notification Strategy + Notification Style + {message_count} new messages in {conversation_count} conversations + Vibrate + Off + Okay + On + Create account + Account Created + I have an account + You cannot go back further. In order to cancel your account creation, {app_name} needs to quit. + You cannot go back further. In order to stop loading your account, {app_name} needs to quit. + Creating an account is instant, free, and anonymous {emoji} + You don\'t even need a phone number to sign up. + Privacy in your pocket. + {app_name} is engineered to protect your privacy. + Welcome to {app_name} {emoji} + Hit the plus button to start a chat, create a group, or join an official community! + There are two ways {app_name} can notify you of new messages. + Privacy Policy + Terms of Service + By using this service, you agree to our Terms of Service and Privacy Policy + Path + {app_name} hides your IP by routing your messages through multiple service nodes in {app_name}\'s decentralized network. This is your current path: + Destination + Entry Node + Service Node + Unknown Country + We couldn\'t recognize this ONS. Please check it and try again. + We were unable to search for this ONS. Please try again later. + Open + Other + Change Password + Change the password required to unlock {app_name}. + Your password has been changed. Please keep it safe. + Confirm password + Create your password + Your current password is incorrect. + Require password to unlock {app_name}. + Enter password + Please enter your current password + Please enter your new password + Password must only contain letters, numbers and symbols + Password must be between 6 and 64 characters long + Passwords do not match + Failed to set password + Incorrect password + Remove Password + Remove the password required to unlock {app_name}. + Your password has been removed. + Set Password + Your password has been set. Please keep it safe. + Paste + Permission Change + {app_name} needs music and audio access in order to send files, music and audio, but it has been permanently denied. Tap Settings → Permissions, and turn \"Music and audio\" on. + {app_name} needs to use Apple Music to play media attachments. + Auto Update + Automatically check for updates on startup. + Camera access is required to make video calls. Toggle the \"Camera\" permission in Settings to continue. + Camera access is currently enabled. To disable it, toggle the \"Camera\" permission in Settings. + {app_name} needs camera access to take photos and videos, but it has been permanently denied. Tap Settings → Permissions, and turn \"Camera\" on. + Allow access to camera for video calls. + The screen lock feature on {app_name} uses Face ID. + Keep in System Tray + {app_name} continues running in the background when you close the window + {app_name} needs photo library access to continue. You can enable access in the iOS settings. + Local Network access is required to facilitate calls. Toggle the \"Local Network\" permission in Settings to continue. + {app_name} needs access to local network to make voice and video calls. + Local Network access is currently enabled. To disable it, toggle the \"Local Network\" permission in Settings. + Allow access to local network to facilitate voice and video calls. + Local Network + Microphone + {app_name} needs microphone access to make calls and send audio messages, but it has been permanently denied. Tap settings → Permissions, and turn \"Microphone\" on. + Microphone access is required to make calls and record audio messages. Toggle the \"Microphone\" permission in Settings to continue. + You can enable microphone access in {app_name}\'s privacy settings + {app_name} needs microphone access to make calls and record audio messages. + Microphone access is currently enabled. To disable it, toggle the \"Microphone\" permission in Settings. + Allow access to microphone. + Allow access to microphone for voice calls and audio messages. + {app_name} needs music and audio access in order to send files, music and audio. + Permission Required + {app_name} needs photo library access so you can send photos and videos, but it has been permanently denied. Tap Settings → Permissions, and turn \"Photos and videos\" on. + {app_name} needs storage access so you can send and save attachments. Tap Settings → Permissions, and turn \"Storage\" on. + {app_name} needs storage access to save attachments and media. + {app_name} needs storage access to save photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". + {app_name} needs storage access to send photos and videos. + Pin + Pin Conversation + Unpin + Unpin Conversation + Preview + Profile + Display Picture + Failed to remove display picture. + Set Display Picture + Please pick a smaller file. + Failed to update profile. + Promote + + Promotion Failed + Promotions Failed + + + The promotion could not be applied. Would you like to try again? + The promotions could not be applied. Would you like to try again? + + QR Code + This QR code does not contain an Account ID + This QR code does not contain a Recovery Password + Scan QR Code + View QR + Friends can message you by scanning your QR code. + Quit {app_name} + Quit + Read + Read Receipts + Show read receipts for all messages you send and receive. + Received: + Received Answer + Receiving Call Offer + Receiving Pre Offer + Recommended + Save your recovery password to make sure you don\'t lose access to your account. + Save your recovery password + Use your recovery password to load your account on new devices.\n\nYour account cannot be recovered without your recovery password. Make sure it\'s stored somewhere safe and secure — and don\'t share it with anyone. + Enter your recovery password + An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file through the {app_name} Help Desk to help resolve this issue. + Please check your recovery password and try again. + Some of the words in your Recovery Password are incorrect. Please check and try again. + The Recovery Password you entered is not long enough. Please check and try again. + Incorrect Recovery Password + To load your account, enter your recovery password. + Hide Recovery Password Permanently + Without your recovery password, you cannot load your account on new devices. \n\nWe strongly recommend you save your recovery password in a safe and secure place before continuing. + Are you sure you want to permanently hide your recovery password on this device? This cannot be undone. + Hide Recovery Password + Permanently hide your recovery password on this device. + Enter your recovery password to load your account. If you haven\'t saved it, you can find it in your app settings. + View Password + This is your recovery password. If you send it to someone they\'ll have full access to your account. + Recreate Group + Redo + Remove + Failed to remove password + Reply + Resend + Loading country information... + Restart + Resync + Retry + Save + Saved + Saved messages + Saving... + Scan + Screen Security + Screenshot Notifications + Require a notification when a contact takes a screenshot of a one-to-one chat. + {name} took a screenshot. + Search + Search Contacts + Search Conversation + Please enter your search. + + %1$d of %2$d match + %1$d of %2$d matches + + No results found. + No results found for {query} + Search Members + Searching... + Select + Select All + Select app icon + Send + Sending + Sending Call Offer + Sending Connection Candidates + Sent: + Appearance + Clear Data + Conversations + Help + Invite a Friend + Message Requests + Current {token_name_short} price + Messages are sent using the {network_name}. The network is comprised of nodes incentivized with {token_name_long}, which keeps {app_name} decentralized and secure. Learn More {icon} + Learn About Staking + Market Cap + {app_name} Nodes securing your messages + {app_name} Nodes in your swarm + {token_name_long} is live! Explore the new {network_name} section in Settings to learn how {token_name_long} powers Session. + Network secured by + When you stake {token_name_long} to secure the network, you earn rewards in {token_name_short} from the {staking_reward_pool}. + New + Notifications + Permissions + Privacy + Recovery Password + Settings + Set + Set Community Display Picture + You must restart {app_name} to apply your new settings. + Share + Invite your friend to chat with you on {app_name} by sharing your Account ID with them. + Share with your friends wherever you usually speak with them — then move the conversation here. + There is an issue opening the database. Please restart the app and try again. + Oops! Looks like you don\'t have a {app_name} account yet.\n\nYou\'ll need to create one in the {app_name} app before you can share. + Share to {app_name} + Show + Show All + Show Less + Show Note to Self + Are you sure you want to show Note to Self in your conversation list? + Stickers + Go to Support Page + System Information: {information} + Tap to retry + Continue + Default + Error + Try Again + Typing Indicators + See and share typing indicators. + Unavailable + Undo + Unknown + App updates + Update installed, click to restart + Downloading update: {percent_loader}% + Cannot Update + {app_name} failed to update. Please go to {session_download_url} and install the new version manually, then contact our Help Center to let us know about this problem. + Update Group Information + Group name and description are visible to all group members. + Please enter a shorter group description + A new version of {app_name} is available, tap to update + A new version ({version}) of {app_name} is available. + Go to Release Notes + {app_name} Update + Version {version} + Last updated {relative_time} ago + Uploading + Copy URL + Open URL + This will open in your browser. + Are you sure you want to open this URL in your browser?\n\n{url} + Use Fast Mode + Video + Unable to play video. + View + View Less + View More + This can take a few minutes. + One moment please... + Warning + Window + Yes + You \ No newline at end of file diff --git a/app/src/main/res/values/strings_crowdin.xml b/app/src/main/res/values/strings_crowdin.xml deleted file mode 100644 index d4c3226b5f..0000000000 --- a/app/src/main/res/values/strings_crowdin.xml +++ /dev/null @@ -1,985 +0,0 @@ - - - Session - About - Accept - Copy Account ID - Account ID - Account ID Copied - Copy your Account ID then share it with your friends so they can message you. - Enter Account ID - This Account ID is invalid. Please check and try again. - Enter Account ID or ONS - Invite Account ID or ONS - Hey, I\'ve been using {app_name} to chat with complete privacy and security. Come join me! My Account ID is\n\n{account_id}\n\nDownload it at {session_download_url} - Your Account ID - This is your Account ID. Other users can scan it to start a conversation with you. - Actual Size - Add - Add Admins - Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. - Admins cannot be removed. - {name} and {count} others were promoted to Admin. - Promote Admins - Are you sure you want to promote {name} to admin? Admins cannot be removed. - Are you sure you want to promote {name} and {count} others to admin? Admins cannot be removed. - Promote to Admin - Are you sure you want to promote {name} and {other_name} to admin? Admins cannot be removed. - {name} was promoted to Admin. - Admin promotion failed - Failed to promote {name} in {group_name} - Failed to promote {name} and {count} others in {group_name} - Failed to promote {name} and {other_name} in {group_name} - Promotion not sent - Admin promotion sent - Promotion status unknown - Remove Admins - Remove as Admin - There are no Admins in this Community. - Failed to remove {name} as Admin. - Failed to remove {name} and {count} others as Admin. - Failed to remove {name} and {other_name} as Admin. - {name} was removed as Admin. - {name} and {count} others were removed as Admin. - {name} and {other_name} were removed as Admin. - - Sending admin promotion - Sending admin promotions - - Admin Settings - {name} and {other_name} were promoted to Admin. - +{count} - Anonymous - App Icon - Change App Icon and Name - Changing the app icon and name requires {app_name} to be closed. Notifications will continue to use the default {app_name} icon and name. - Alternate app icon and name is displayed on home screen and app drawer. - The selected app icon and name is displayed on the home screen and app drawer. - Icon and name - Alternate app icon is displayed on home screen and app library. App name will still appear as \'{app_name}\'. - Use alternate app icon - Use alternate app icon and name - Select alternate app icon - Icon - Calculator - MeetingSE - News - Notes - Stocks - Weather - Auto dark-mode - Hide Menu Bar - Language - Choose your language setting for {app_name}. {app_name} will restart when you change your language setting. - How are you? - I\'m good thanks, you? - I\'m doing great, thanks. - Primary Color - Themes - Classic Dark - Classic Light - Ocean Dark - Ocean Light - Zoom - Zoom In - Zoom Out - Attachment - Attachments - Add attachment - Unnamed Album - Auto-download Attachments - Automatically download media and files from this chat. - Would you like to automatically download all files from {conversation_name}? - Auto Download - Clear All Attachments - Are you sure you want to clear all attachments? Messages with attachments will also be deleted. - Click to download {file_type} - Collapse attachment options - Collecting attachments... - Download Attachment - Duration: - Error attaching file - Failed to select attachment - Can\'t find an app to select media. - This file type is not supported. - Unable to send more than 32 image and video files at once. - Unable to open file. - Error sending file - Please send files as separate messages. - Files must be less than 10MB - Cannot attach images and video with other file types. Try sending other files in a separate message. - Attachment expired - File ID: - File Size: - File Type: - You don\'t have any files in this conversation. - Unable to remove metadata from file. - Loading Newer Media... - Loading Newer Files... - Loading Older Media... - Loading Older Files... - {name} on {date_time} - You don\'t have any media in this conversation. - Media saved by {name} - Move and Scale - N/A - {emoji} Attachment - {author}: {emoji} Attachment - Resolution: - Unable to save file. - Send to {name} - Tap to download {file_type} - This Month - This Week - Attachments you save can be accessed by other apps on your device. - Audio - No audio input found - No audio output found - Unable to play audio file. - Unable to record audio. - Authentication Failed - Too many failed authentication attempts. Please try again later. - Authentication could not be accessed. - Authenticate to open {app_name}. - Back - Ban and Delete All - Ban failed - Unban failed - Unban User - Enter the Account ID of the user you are unbanning - User unbanned - Ban User - User banned - Enter the Account ID of the user you are banning - Block - Unblock this contact to send a message. - No blocked contacts - Blocked {name} - Are you sure you want to block {name}? Blocked users cannot send you message requests, group invites or call you. - Unblock - Are you sure you want to unblock {name}? - Are you sure you want to unblock {name} and {count} others? - Are you sure you want to unblock {name} and 1 other? - Unblocked {name} - Call - {name} called you - You cannot start a new call. Finish your current call first. - Connecting... - End call - Call Ended - Failed to answer call - Failed to start call - Call in progress - Incoming call from {name} - Incoming call - You missed a call from {name} because you haven\'t granted microphone access. - Missed Call - Missed call from {name} - Voice and Video Calls require notifications to be enabled in your device system settings. - Call Permissions Required - You can enable the \"Voice and Video Calls\" permission in Privacy Settings. - You can enable the \"Voice and Video Calls\" permission in Permissions Settings. - Reconnecting… - Ringing... - {app_name} Call - Calls (Beta) - Voice and Video Calls - Voice and Video Calls (Beta) - Your IP is visible to your call partner and a Session Technology Foundation server while using beta calls. - Enables voice and video calls to and from other users. - You called {name} - You missed a call from {name} because you haven\'t enabled Voice and Video Calls in Privacy Settings. - No camera found - Camera unavailable. - Grant Camera Access - {app_name} needs camera access to take photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". - {app_name} needs camera access to take photos and videos, or scan QR codes. - {app_name} needs camera access to scan QR codes - Cancel - Failed to change password - Clear - Clear All - Clear All Data - This will permanently delete your messages and contacts. Would you like to clear this device only, or delete your data from the network as well? - Data Not Deleted - - Data not deleted by %1$d Service Node. Service Node ID: %2$s. - Data not deleted by %1$d Service Nodes. Service Node IDs: %2$s. - - An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead? - Clear Device - Clear device and network - Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts. - Are you sure you want to clear your device? - Clear device only - Clear Device and Restart - Clear Device and Restore - Clear All Messages - Are you sure you want to clear all messages from your conversation with {name} from your device? - Are you sure you want to clear all messages from your conversation with {name} on this device? - Are you sure you want to clear all {community_name} messages from your device? - Are you sure you want to clear all messages from {community_name} on this device? - Clear for everyone - Clear for me - Are you sure you want to clear all {group_name} messages? - Are you sure you want to clear all messages from {group_name}? - Are you sure you want to clear all {group_name} messages from your device? - Are you sure you want to clear all messages from {group_name} on this device? - Are you sure you want to clear all Note to Self messages from your device? - Are you sure you want to clear all Note to Self messages on this device? - Clear on this device - Close - Close App - Close Window - Commit Hash: {hash} - This will ban the selected user from this Community and delete all their messages. Are you sure you want to continue? - This will ban the selected user from this Community. Are you sure you want to continue? - Enter Community URL - Invalid URL - Please check the Community URL and try again. - Community Error - Oops, an error occurred. Please try again later. - Community Invitation - Join Community - Are you sure you want to join {community_name}? - Failed to join community - Or join one of these... - Joined Community - You are already a member of this community. - Leave Community - Failed to leave {community_name} - Unknown Community - Community URL - Copy Community URL - Confirm - Contacts - Delete Contact - Are you sure you want to delete {name} from your contacts? New messages from {name} will arrive as a message request. - You don\'t have any contacts yet - Select Contacts - User Details - Camera - Choose an action to start a conversation - Media message - Message composition - Thumbnail of image from quoted message - Create a conversation with a new contact - Add to home screen - Added to home screen - Audio Messages - Autoplay Audio Messages - Autoplay consecutively sent audio messages. - Blocked Contacts - Communities - Delete Conversation - Are you sure you want to delete your conversation with {name}? New messages from {name} will start a new conversation. - Conversation deleted - There are no messages in {conversation_name}. - Enter Key - Function of the enter key when typing in a conversation. - SHIFT + ENTER sends a message, ENTER starts a new line - ENTER sends a message, SHIFT + ENTER starts a new line - Groups - Message Trimming - Trim Communities - Delete messages from Community conversations older than 6 months, and where there are over 2,000 messages. - New Conversation - You don\'t have any conversations yet - Send with Enter Key - Tapping the Enter Key will send message instead of starting a new line. - All Media - Spell Check - Enable spell check when typing messages. - Start Conversation - Copied - Copy - Create - Creating Call - Cut - Are you sure you want to delete all messages, attachments, and account data from this device and create a new account? - A database error occurred.\n\nExport your application logs to share for troubleshooting. If this is unsuccessful, reinstall {app_name} and restore your account. - Are you sure you want to delete all messages, attachments, and account data from this device and restore your account from the network? - We\'ve noticed {app_name} is taking a long time to start.\n\nYou can continue to wait, export your device logs to share for troubleshooting, or try restarting {app_name}. - Your app database is incompatible with this version of {app_name}. Reinstall the app and restore your account to generate a new database and continue using {app_name}.\n\nWarning: This will result in the loss of all messages and attachments older than two weeks. - Optimizing Database - Debug Log - Decline - Delete - Some of your devices are using outdated versions. Syncing may be unreliable until they are updated. - Block This User - Block User - Group Settings - Notify for Mentions Only - When enabled, you\'ll only be notified for messages mentioning you. - Message Sound - Permanently delete the messages in this conversation? - Can\'t leave while adding or removing other members. - Legacy - Original version of disappearing messages. - {name} set the disappearing message timer to {time} - Please wait while the group is created... - Failed to Update Group - You don’t have permission to delete others’ messages - Are you sure you want to delete {name} from your contacts?\n\nThis will delete your conversation, including all messages and attachments. Future messages from {name} will appear as a message request. - Are you sure you want to delete your conversation with {name}?\nThis will permanently delete all messages and attachments. - - Delete Message - Delete Messages - - - Are you sure you want to delete this message? - Are you sure you want to delete these messages? - - - Message deleted - Messages deleted - - This message was deleted - This message was deleted on this device - - Are you sure you want to delete this message from this device only? - Are you sure you want to delete these messages from this device only? - - Are you sure you want to delete this message for everyone? - Delete on this device only - Delete on all my devices - Delete for everyone - - Failed to delete message - Failed to delete messages - - - This message cannot be deleted from all your devices - Some of the messages you have selected cannot be deleted from all your devices - - - This message cannot be deleted for everyone - Some of the messages you have selected cannot be deleted for everyone - - Are you sure you want to delete these messages for everyone? - Deleting - Toggle Developer Tools - Start Dictation... - Disappearing Messages - Message will delete in {time_large} - Auto-deletes in {time_large} - Message will delete in {time_large} {time_small} - Auto-deletes in {time_large} {time_small} - Delete Type - This setting applies to everyone in this conversation. - This setting applies to messages you send in this conversation. - This setting applies to everyone in this conversation.\nOnly group admins can change this setting. - Disappear After {disappearing_messages_type} - {time} - Disappear After Read - Messages delete after they have been read. - Disappear After Read - {time} - Disappear After Send - Messages delete after they have been sent. - Disappear After Send - {time} - Follow Setting - Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages? - Set your messages to disappear {time} after they have been {disappearing_messages_type}? - {name} is using an outdated client. Disappearing messages may not work as expected. - Only group admins can change this setting. - Sent - {name} has set messages to disappear {time} after they have been {disappearing_messages_type}. - You set messages to disappear {time} after they have been {disappearing_messages_type}. - Timer - {name} has turned disappearing messages off. Messages they send will no longer disappear. - {name} has turned disappearing messages off. - You turned off disappearing messages. Messages you send will no longer disappear. - You turned off disappearing messages. - read - sent - {admin_name} updated disappearing message settings. - You updated disappearing message settings. - Dismiss - It can be your real name, an alias, or anything else you like — and you can change it at any time. - Enter your display name - Please enter a display name - Please enter a shorter display name - We were unable to load your display name. Please enter a new display name to continue. - Pick a new display name - Pick your display name - Set Display Name - Your Display Name is visible to users, groups and communities you interact with. - Document - Done - Download - Downloading... - Draft - Edit - Emoji and Symbols - Activities - Animals and Nature - Flags - Food and Drink - Objects - Recently Used - Smileys and People - Symbols - Travel and Places - Are you sure you want to clear all {emoji}? - Slow down! You\'ve sent too many emoji reacts. Try again soon - - And %1$d other has reacted %2$s to this message. - And %1$d others have reacted %2$s to this message. - - {name} reacted with {emoji_name} - {name} and {other_name} reacted with {emoji_name} - {name} and {count} others reacted with {emoji_name} - You reacted with {emoji_name} - You and {count} others reacted with {emoji_name} - You and {name} reacted with {emoji_name} - Reacted to your message {emoji} - Enable - Please check your internet connection and try again. - Copy Error and Quit - Database Error - Something went wrong. Please try again later. - An unknown error occurred. - Failed to download - Failures - File - Files - Follow system settings - Forever - From: - Toggle Full Screen - GIF - Giphy - {app_name} will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs. - Groups have a maximum of 100 members - Create Group - Please pick at least one other group member. - Delete Group - Are you sure you want to delete {group_name}?\n\nThis will remove all members and delete all group content. - Are you sure you want to delete {group_name}? - {group_name} has been deleted by a group admin. You will not be able to send any more messages. - Enter a group description - Group display picture updated. - Edit Group - Group Error - Failed to create group. Please check your internet connection and try again. - Failed to join {group_name} - Set Group Information - Are you sure you want to delete this group invite? - Invite failed - Failed to invite {name} and {count} others to {group_name} - Failed to invite {name} and {other_name} to {group_name} - Failed to invite {name} to {group_name} - Invite not sent - {name} invited you to rejoin {group_name}, where you are an Admin. - You were invited to rejoin {group_name}, where you are an Admin. - - Sending invite - Sending invites - - Invite sent - Invite status unknown - Group invite successful - Users must have the latest release to receive invitations - You were invited to join the group. - You and {count} others were invited to join the group. - You and {other_name} were invited to join the group. - You were invited to join the group. Chat history was shared. - Leave Group - Are you sure you want to leave {group_name}? - Are you sure you want to leave {group_name}?\n\nThis will remove all members and delete all group content. - Failed to leave {group_name} - {name} left the group. - {name} and {count} others left the group. - {name} and {other_name} left the group. - {name} was invited to join the group. - {name} was invited to join the group. Chat history was shared. - {name} and {count} others were invited to join the group. Chat history was shared. - {name} and {other_name} were invited to join the group. Chat history was shared. - {name} and {count} others were invited to join the group. - {name} and {other_name} were invited to join the group. - You and {count} others were invited to join the group. Chat history was shared. - You and {other_name} were invited to join the group. Chat history was shared. - You left the group. - Group Members - There are no other members in this group. - Group Name - Enter a group name - Please enter a group name. - Please enter a shorter group name. - Group name is now {group_name}. - Group name updated. - Group name is visible to all group members. - You have no messages from {group_name}. Send a message to start the conversation! - This group has not been updated in over 30 days. You may experience issues sending messages or viewing group information. - You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. - Pending removal - You were promoted to Admin. - You and {count} others were promoted to Admin. - You and {other_name} were promoted to Admin. - Would you like to remove {name} from {group_name}? - Would you like to remove {name} and {count} others from {group_name}? - Would you like to remove {name} and {other_name} from {group_name}? - - Remove user and their messages - Remove users and their messages - - - Remove user - Remove users - - {name} was removed from the group. - {name} and {count} others were removed from the group. - {name} and {other_name} were removed from the group. - You were removed from {group_name}. - You were removed from the group. - You and {count} others were removed from the group. - You and {other_name} were removed from the group. - Set Group Display Picture - Unknown Group - Group updated - Handling Connection Candidates - FAQ - Help us translate {app_name} - Report a bug - Share some details to help us resolve your issue. Export your logs, then upload the file through {app_name}\'s Help Desk. - Export Logs - Export your logs, then upload the file through {app_name}\'s Help Desk. - Save to desktop - Save this file to your desktop, then share it with {app_name} developers. - Support - We\'d love your feedback - Hide - Toggle system menu bar visibility - Are you sure you want to hide Note to Self from your conversation list? - Hide Others - Image - images - Incognito Keyboard - Request incognito mode if available. Depending on the keyboard you are using, your keyboard may ignore this request. - Info - Invalid shortcut - - Invite Failed - Invites Failed - - - The invite could not be sent. Would you like to try again? - The invites could not be sent. Would you like to try again? - - Join - Later - Learn More - Leave - Leaving... - This group is now read-only. Recreate this group to keep chatting. - This group is now read-only. Ask the group admin to recreate this group to keep chatting. - Groups have been upgraded! Recreate this group for improved reliability. This group will become read-only at {date}. - Groups have been upgraded! Ask the group admin to recreate this group for improved reliability. This group will become read-only at {date}. - Chat history will not be transferred to the new group. You can still view all chat history in your old group. - {name} joined the group. - {name} and {count} others joined the group. - You and {count} others joined the group. - You and {other_name} joined the group. - {name} and {other_name} joined the group. - You joined the group. - Link Previews - Show link previews for supported URLs. - Enable Link Previews - Unable to load link preview - Preview not loaded for unsecure link - Display previews for URLs you send and receive. This can be useful, however {app_name} must contact linked websites to generate previews. You can always turn off link previews in {app_name}\'s settings. - Send Link Previews - You will not have full metadata protection when sending link previews. - Link Previews Are Off - {app_name} must contact linked websites to generate previews of links you send and receive.\n\nYou can turn them on in {app_name}\'s settings. - Load Account - Loading your account - Loading... - Lock App - Require fingerprint, PIN, pattern or password to unlock {app_name}. - Require Touch ID, Face ID or your passcode to unlock {app_name}. - You must enable a passcode in your iOS Settings in order to use Screen Lock. - {app_name} is locked - Quick response unavailable when {app_name} is locked! - Lock status - Tap to unlock - {app_name} is unlocked - Manage Members - Max - Media - - %1$d member - %1$d members - - - %1$d active member - %1$d active members - - Add Account ID or ONS - Invite Contacts - - Send Invite - Send Invites - - Would you like to share group message history with {name}? - Would you like to share group message history with {name} and {count} others? - Would you like to share group message history with {name} and {other_name}? - Share message history - Share new messages only - Invite - Message - This message is empty. - Message delivery failed - Message limit reached - Received a message encrypted using an old version of {app_name} that is no longer supported. Please ask the sender to update to the most recent version and resend the message. - Original message not found - Message Info - Mark read - Mark unread - - New Message - New Messages - - Start a new conversation by entering your friend\'s Account ID or ONS. - Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code. - - You\'ve got a new message. - You\'ve got %1$d new messages. - - - You\'ve got a new message in %1$s. - You\'ve got %1$d new messages in %2$s. - - Replying to - You cannot send attachments until your Message Request is accepted - You cannot send voice messages until your Message Request is accepted - {name} invited you to join {group_name}. - Sending a message to this group will automatically accept the group invite. - Your message request is currently pending. - You will be able to send voice messages and attachments once the recipient has approved this message request. - You have accepted the message request from {name}. - Sending a message to this user will automatically accept their message request and reveal your Account ID. - Your message request has been accepted. - Are you sure you want to clear all message requests and group invites? - Community Message Requests - Allow message requests from Community conversations. - Are you sure you want to delete this message request? - You have a new message request - No pending message requests - {name} has message requests from Community conversations turned off, so you cannot send them a message. - Select Message - {author}: {message_snippet} - Failed to send - Failed to sync - Syncing - Unread messages - Voice Message - Hold to record a voice message - Slide to Cancel - {emoji} Voice Message - {author}: {emoji} Voice Message - Messages - Minimize - Next - Choose a nickname for {name}. This will appear to you in your one-to-one and group conversations. - Enter nickname - Please enter a shorter nickname - Remove Nickname - Set Nickname - No - No Suggestions - None - Not now - Note to Self - You have no messages in Note to Self. - Hide Note to Self - Are you sure you want to hide Note to Self? - All Messages - Notification Content - The information shown in notifications. - Name and Content - Name Only - No Name or Content - Fast Mode - You\'ll be notified of new messages reliably and immediately using Google\'s notification Servers. - You\'ll be notified of new messages reliably and immediately using Huawei’s notification servers. - You\'ll be notified of new messages reliably and immediately using Apple\'s notification Servers. - Go to device notification settings - Notifications - All - Notifications - Mentions Only - Notifications - Muted - {name} to {conversation_name} - You may have received messages while your {device} was restarting. - LED color - Mentions Only - Message notifications - Most recent from {name} - Mute - Mute for {time_large} - Unmute - Muted - Muted for {time_large} - Muted until {date_time} - Slow Mode - {app_name} will occasionally check for new messages in the background. - Sound - Sound when App is open - Audio Notifications - Notification Strategy - Notification Style - {message_count} new messages in {conversation_count} conversations - Vibrate - Off - Okay - On - Create account - Account Created - I have an account - You cannot go back further. In order to cancel your account creation, {app_name} needs to quit. - You cannot go back further. In order to stop loading your account, {app_name} needs to quit. - Creating an account is instant, free, and anonymous {emoji} - You don\'t even need a phone number to sign up. - Privacy in your pocket. - {app_name} is engineered to protect your privacy. - Welcome to {app_name} {emoji} - Hit the plus button to start a chat, create a group, or join an official community! - There are two ways {app_name} can notify you of new messages. - Privacy Policy - Terms of Service - By using this service, you agree to our Terms of Service and Privacy Policy - Path - {app_name} hides your IP by routing your messages through multiple service nodes in {app_name}\'s decentralized network. This is your current path: - Destination - Entry Node - Service Node - Unknown Country - We couldn\'t recognize this ONS. Please check it and try again. - We were unable to search for this ONS. Please try again later. - Open - Other - Change Password - Change the password required to unlock {app_name}. - Your password has been changed. Please keep it safe. - Confirm password - Create your password - Your current password is incorrect. - Require password to unlock {app_name}. - Enter password - Please enter your current password - Please enter your new password - Password must only contain letters, numbers and symbols - Password must be between 6 and 64 characters long - Passwords do not match - Failed to set password - Incorrect password - Remove Password - Remove the password required to unlock {app_name}. - Your password has been removed. - Set Password - Your password has been set. Please keep it safe. - Paste - Permission Change - {app_name} needs music and audio access in order to send files, music and audio, but it has been permanently denied. Tap Settings → Permissions, and turn \"Music and audio\" on. - {app_name} needs to use Apple Music to play media attachments. - Auto Update - Automatically check for updates on startup. - Camera access is required to make video calls. Toggle the \"Camera\" permission in Settings to continue. - Camera access is currently enabled. To disable it, toggle the \"Camera\" permission in Settings. - {app_name} needs camera access to take photos and videos, but it has been permanently denied. Tap Settings → Permissions, and turn \"Camera\" on. - Allow access to camera for video calls. - The screen lock feature on {app_name} uses Face ID. - Keep in System Tray - {app_name} continues running in the background when you close the window - {app_name} needs photo library access to continue. You can enable access in the iOS settings. - Local Network access is required to facilitate calls. Toggle the \"Local Network\" permission in Settings to continue. - {app_name} needs access to local network to make voice and video calls. - Local Network access is currently enabled. To disable it, toggle the \"Local Network\" permission in Settings. - Allow access to local network to facilitate voice and video calls. - Local Network - Microphone - {app_name} needs microphone access to make calls and send audio messages, but it has been permanently denied. Tap settings → Permissions, and turn \"Microphone\" on. - Microphone access is required to make calls and record audio messages. Toggle the \"Microphone\" permission in Settings to continue. - You can enable microphone access in {app_name}\'s privacy settings - {app_name} needs microphone access to make calls and record audio messages. - Microphone access is currently enabled. To disable it, toggle the \"Microphone\" permission in Settings. - Allow access to microphone. - Allow access to microphone for voice calls and audio messages. - {app_name} needs music and audio access in order to send files, music and audio. - Permission Required - {app_name} needs photo library access so you can send photos and videos, but it has been permanently denied. Tap Settings → Permissions, and turn \"Photos and videos\" on. - {app_name} needs storage access so you can send and save attachments. Tap Settings → Permissions, and turn \"Storage\" on. - {app_name} needs storage access to save attachments and media. - {app_name} needs storage access to save photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". - {app_name} needs storage access to send photos and videos. - Pin - Pin Conversation - Unpin - Unpin Conversation - Preview - Profile - Display Picture - Failed to remove display picture. - Set Display Picture - Please pick a smaller file. - Failed to update profile. - Promote - - Promotion Failed - Promotions Failed - - - The promotion could not be applied. Would you like to try again? - The promotions could not be applied. Would you like to try again? - - QR Code - This QR code does not contain an Account ID - This QR code does not contain a Recovery Password - Scan QR Code - View QR - Friends can message you by scanning your QR code. - Quit {app_name} - Quit - Read - Read Receipts - Show read receipts for all messages you send and receive. - Received: - Received Answer - Receiving Call Offer - Receiving Pre Offer - Recommended - Save your recovery password to make sure you don\'t lose access to your account. - Save your recovery password - Use your recovery password to load your account on new devices.\n\nYour account cannot be recovered without your recovery password. Make sure it\'s stored somewhere safe and secure — and don\'t share it with anyone. - Enter your recovery password - An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file through the {app_name} Help Desk to help resolve this issue. - Please check your recovery password and try again. - Some of the words in your Recovery Password are incorrect. Please check and try again. - The Recovery Password you entered is not long enough. Please check and try again. - Incorrect Recovery Password - To load your account, enter your recovery password. - Hide Recovery Password Permanently - Without your recovery password, you cannot load your account on new devices. \n\nWe strongly recommend you save your recovery password in a safe and secure place before continuing. - Are you sure you want to permanently hide your recovery password on this device? This cannot be undone. - Hide Recovery Password - Permanently hide your recovery password on this device. - Enter your recovery password to load your account. If you haven\'t saved it, you can find it in your app settings. - View Password - This is your recovery password. If you send it to someone they\'ll have full access to your account. - Recreate Group - Redo - Remove - Failed to remove password - Reply - Resend - Loading country information... - Restart - Resync - Retry - Save - Saved - Saved messages - Saving... - Scan - Screen Security - Screenshot Notifications - Require a notification when a contact takes a screenshot of a one-to-one chat. - {name} took a screenshot. - Search - Search Contacts - Search Conversation - Please enter your search. - - %1$d of %2$d match - %1$d of %2$d matches - - No results found. - No results found for {query} - Search Members - Searching... - Select - Select All - Select app icon - Send - Sending - Sending Call Offer - Sending Connection Candidates - Sent: - Appearance - Clear Data - Conversations - Help - Invite a Friend - Message Requests - Current {token_name_short} price - Messages are sent using the {network_name}. The network is comprised of nodes incentivized with {token_name_long}, which keeps {app_name} decentralized and secure. Learn More {icon} - Learn About Staking - Market Cap - {app_name} Nodes securing your messages - {app_name} Nodes in your swarm - {token_name_long} is live! Explore the new {network_name} section in Settings to learn how {token_name_long} powers Session. - Network secured by - When you stake {token_name_long} to secure the network, you earn rewards in {token_name_short} from the {staking_reward_pool}. - New - Notifications - Permissions - Privacy - Recovery Password - Settings - Set - Set Community Display Picture - You must restart {app_name} to apply your new settings. - Share - Invite your friend to chat with you on {app_name} by sharing your Account ID with them. - Share with your friends wherever you usually speak with them — then move the conversation here. - There is an issue opening the database. Please restart the app and try again. - Oops! Looks like you don\'t have a {app_name} account yet.\n\nYou\'ll need to create one in the {app_name} app before you can share. - Share to {app_name} - Show - Show All - Show Less - Show Note to Self - Are you sure you want to show Note to Self in your conversation list? - Stickers - Go to Support Page - System Information: {information} - Tap to retry - Continue - Default - Error - Try Again - Typing Indicators - See and share typing indicators. - Unavailable - Undo - Unknown - App updates - Update installed, click to restart - Downloading update: {percent_loader}% - Cannot Update - {app_name} failed to update. Please go to {session_download_url} and install the new version manually, then contact our Help Center to let us know about this problem. - Update Group Information - Group name and description are visible to all group members. - Please enter a shorter group description - A new version of {app_name} is available, tap to update - A new version ({version}) of {app_name} is available. - Go to Release Notes - {app_name} Update - Version {version} - Last updated {relative_time} ago - Uploading - Copy URL - Open URL - This will open in your browser. - Are you sure you want to open this URL in your browser?\n\n{url} - Use Fast Mode - Video - Unable to play video. - View - View Less - View More - This can take a few minutes. - One moment please... - Warning - Window - Yes - You - \ No newline at end of file From 0bb726598fbb425c87103267f99ef55ba2058a5a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 16 May 2025 16:33:11 +1000 Subject: [PATCH 297/867] SES-3827 and SES-3828 --- .../org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt | 3 +-- app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index f9a34e35aa..07703b084c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -201,8 +201,7 @@ class TokenPageViewModel @Inject constructor( networkSecuredByUSDString = "\$- ${USD_NAME_SHORT}", infoResponseData = null, priceDataPopupText = Phrase.from(SESSION_NETWORK_DATA_PRICE) - .put(DATE_KEY, "-") - .put(TIME_KEY, "") + .put(DATE_TIME_KEY, "-") .format().toString() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index 4fdb70fa6b..f88141e23c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -36,7 +36,7 @@ class DateUtils @Inject constructor( private val defaultDateFormat = "dd/MM/yyyy" private val defaultTimeFormat = "HH:mm" private val twelveHourFormat = "h:mm a" - private val defaultDateTimeFormat = "h:mm a, d MMM yyyy" + private val defaultDateTimeFormat = "d MMM YYYY hh:mm a" // System defaults and patterns private val systemDefaultPattern by lazy { From 372585c82f9c20b8936a25ceb44f1d17debb1363 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 16 May 2025 16:39:09 +1000 Subject: [PATCH 298/867] More places to use message id (#1166) --- .../database/MessageDataProvider.kt | 10 +- .../libsession/database/StorageProtocol.kt | 32 ++-- .../messaging/jobs/AttachmentDownloadJob.kt | 28 ++-- .../messaging/jobs/BatchMessageReceiveJob.kt | 9 +- .../messaging/jobs/MessageSendJob.kt | 32 ++-- .../libsession/messaging/messages/Message.kt | 3 +- .../sending_receiving/MessageSender.kt | 49 +++--- .../ReceivedMessageHandler.kt | 121 +++++++++------ .../attachments/DatabaseAttachmentProvider.kt | 31 ++-- .../v2/AttachmentDownloadHandler.kt | 2 +- .../conversation/v2/ConversationActivityV2.kt | 21 +-- .../v2/ConversationReactionOverlay.kt | 14 +- .../conversation/v2/MessageDetailActivity.kt | 19 +-- .../v2/MessageDetailsViewModel.kt | 61 ++++---- .../v2/utilities/ResendMessageUtilities.kt | 6 +- .../securesms/database/ExpirationInfo.kt | 5 +- .../securesms/database/LokiMessageDatabase.kt | 28 ++-- .../securesms/database/MessagingDatabase.java | 2 +- .../securesms/database/MmsDatabase.kt | 16 +- .../securesms/database/MmsSmsDatabase.java | 30 ++-- .../securesms/database/SmsDatabase.java | 28 ++-- .../securesms/database/Storage.kt | 145 +++++++----------- .../securesms/database/ThreadDatabase.java | 5 +- .../database/helpers/SQLCipherOpenHelper.java | 7 +- .../securesms/database/model/MessageId.kt | 11 +- .../database/model/MessageRecord.java | 4 + .../notifications/MarkReadReceiver.kt | 17 +- .../notifications/RemoteReplyReceiver.java | 5 +- 28 files changed, 389 insertions(+), 352 deletions(-) diff --git a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index b0c3bc7780..c7f3704a09 100644 --- a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -12,6 +12,8 @@ import org.session.libsession.utilities.UploadResult import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentStream +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import java.io.InputStream interface MessageDataProvider { @@ -40,14 +42,14 @@ interface MessageDataProvider { fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream) fun updateAudioAttachmentDuration(attachmentId: AttachmentId, durationMs: Long, threadId: Long) fun isMmsOutgoing(mmsMessageId: Long): Boolean - fun isOutgoingMessage(timestamp: Long): Boolean - fun isDeletedMessage(timestamp: Long): Boolean + fun isOutgoingMessage(id: MessageId): Boolean + fun isDeletedMessage(id: MessageId): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) fun handleFailedAttachmentUpload(attachmentId: Long) fun getMessageForQuote(timestamp: Long, author: Address): Triple? fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List fun getMessageBodyFor(timestamp: Long, author: String): String - fun getAttachmentIDsFor(messageID: Long): List - fun getLinkPreviewAttachmentIDFor(messageID: Long): Long? + fun getAttachmentIDsFor(mmsMessageId: Long): List + fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? fun getIndividualRecipientForMms(mmsId: Long): Recipient? } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 979e8b81a8..000c0d149a 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -30,13 +30,14 @@ import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupDisplayInfo import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.RecipientSettings import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember @@ -118,18 +119,17 @@ interface StorageProtocol { * Returns the IDs of the saved attachments. */ fun persistAttachments(messageID: Long, attachments: List): List - fun getAttachmentsForMessage(messageID: Long): List - fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? // TODO: This is a weird name - fun getMessageType(timestamp: Long, author: String): MessageType? - fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) - fun markAsResyncing(timestamp: Long, author: String) - fun markAsSyncing(timestamp: Long, author: String) - fun markAsSending(timestamp: Long, author: String) - fun markAsSent(messageID: Long, isMms: Boolean) - fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) - fun markAsSentFailed(timestamp: Long, author: String, error: Exception) - fun clearErrorMessage(messageID: Long) - fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) + fun getAttachmentsForMessage(mmsMessageId: Long): List + fun getMessageBy(timestamp: Long, author: String): MessageRecord? + fun updateSentTimestamp(messageId: MessageId, openGroupSentTimestamp: Long, threadId: Long) + fun markAsResyncing(messageId: MessageId) + fun markAsSyncing(messageId: MessageId) + fun markAsSending(messageId: MessageId) + fun markAsSent(messageId: MessageId) + fun markAsSyncFailed(messageId: MessageId, error: Exception) + fun markAsSentFailed(messageId: MessageId, error: Exception) + fun clearErrorMessage(messageID: MessageId) + fun setMessageServerHash(messageId: MessageId, serverHash: String) // Legacy Closed Groups fun getGroup(groupID: String): GroupRecord? @@ -225,7 +225,7 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): Long? + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): MessageId? fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) fun getLastSeen(threadId: Long): Long fun ensureMessageHashesAreSender(hashes: Set, sender: String, closedGroupId: String): Boolean @@ -252,8 +252,8 @@ interface StorageProtocol { fun removeLastOutboxMessageId(server: String) fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping - fun addReaction(reaction: Reaction, messageSender: String, notifyUnread: Boolean) - fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) + fun addReaction(reaction: Reaction, threadId: Long, messageSender: String, notifyUnread: Boolean) + fun removeReaction(emoji: String, messageTimestamp: Long, threadId: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) fun deleteReactions(messageId: Long, mms: Boolean) fun deleteReactions(messageIds: List, mms: Boolean) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 31c9b79980..0f8124b993 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -24,7 +24,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream -class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) : Job { +class AttachmentDownloadJob(val attachmentID: Long, val mmsMessageId: Long) : Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 @@ -73,16 +73,16 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) override suspend fun execute(dispatcherName: String) { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val threadID = storage.getThreadIdForMms(databaseMessageID) + val threadID = storage.getThreadIdForMms(mmsMessageId) val handleFailure: (java.lang.Exception, attachmentId: AttachmentId?) -> Unit = { exception, attachment -> if(exception is HTTP.HTTPRequestFailedException && exception.statusCode == 404){ attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") - messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, id, databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, id, mmsMessageId) } ?: run { Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") - messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, AttachmentId(attachmentID,0), databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, AttachmentId(attachmentID,0), mmsMessageId) } } else if (exception == Error.NoAttachment || exception == Error.NoThread @@ -90,29 +90,29 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400)) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") - messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, mmsMessageId) } ?: run { Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") - messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), mmsMessageId) } this.handlePermanentFailure(dispatcherName, exception) } else if (exception == Error.DuplicateData) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") - messageDataProvider.setAttachmentState(AttachmentState.DONE, id, databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.DONE, id, mmsMessageId) } ?: run { Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") - messageDataProvider.setAttachmentState(AttachmentState.DONE, AttachmentId(attachmentID,0), databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.DONE, AttachmentId(attachmentID,0), mmsMessageId) } this.handleSuccess(dispatcherName) } else { if (failureCount + 1 >= maxFailureCount) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, have attachment") - messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, mmsMessageId) } ?: run { Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, don't have attachment") - messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), mmsMessageId) } } this.handleFailure(dispatcherName, exception) @@ -124,7 +124,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) return } - if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) { + if (!eligibleForDownload(threadID, storage, messageDataProvider, mmsMessageId)) { handleFailure(Error.NoSender, null) return } @@ -139,7 +139,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) handleFailure(Error.DuplicateData, attachment.attachmentId) return } - messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.databaseMessageID) + messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.mmsMessageId) tempFile = createTempFile() val openGroup = storage.getOpenGroup(threadID) if (openGroup == null) { @@ -157,7 +157,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) val inputStream = getInputStream(tempFile, attachment) Log.d("AttachmentDownloadJob", "inserting attachment") - messageDataProvider.insertAttachment(databaseMessageID, attachment.attachmentId, inputStream) + messageDataProvider.insertAttachment(mmsMessageId, attachment.attachmentId, inputStream) if (attachment.contentType.startsWith("audio/")) { // process the duration try { @@ -217,7 +217,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) override fun serialize(): Data { return Data.Builder() .putLong(ATTACHMENT_ID_KEY, attachmentID) - .putLong(TS_INCOMING_MESSAGE_ID_KEY, databaseMessageID) + .putLong(TS_INCOMING_MESSAGE_ID_KEY, mmsMessageId) .build(); } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 5770d8b0e1..e205d95005 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -36,6 +36,7 @@ import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.model.MessageId import kotlin.math.max data class MessageReceiveParameters( @@ -175,7 +176,7 @@ class BatchMessageReceiveJob( // iterate over threads and persist them (persistence is the longest constant in the batch process operation) fun processMessages(threadId: Long, messages: List) { // The LinkedHashMap should preserve insertion order - val messageIds = linkedMapOf>() + val messageIds = linkedMapOf>() val myLastSeen = storage.getLastSeen(threadId) var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 messages.forEach { (parameters, message, proto) -> @@ -216,11 +217,11 @@ class BatchMessageReceiveJob( } is UnsendRequest -> { - val deletedMessageId = MessageReceiver.handleUnsendRequest(message) + val deletedMessage = MessageReceiver.handleUnsendRequest(message) // If we removed a message then ensure it isn't in the 'messageIds' - if (deletedMessageId != null) { - messageIds.remove(deletedMessageId) + if (deletedMessage != null) { + messageIds.remove(deletedMessage) } } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 77ef4446b8..5ee673e09a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -35,33 +35,29 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta companion object { val TAG = MessageSendJob::class.simpleName - val KEY: String = "MessageSendJob" + const val KEY: String = "MessageSendJob" // Keys used for database storage - private val MESSAGE_KEY = "message" - private val DESTINATION_KEY = "destination" + private const val MESSAGE_KEY = "message" + private const val DESTINATION_KEY = "destination" } override suspend fun execute(dispatcherName: String) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val messageId = message.id val message = message as? VisibleMessage val storage = MessagingModuleConfiguration.shared.storage // do not attempt to send if the message is marked as deleted - message?.sentTimestamp?.let{ - if(messageDataProvider.isDeletedMessage(it)){ - return@execute - } + if (messageId != null && messageDataProvider.isDeletedMessage(messageId)) { + return } - val sentTimestamp = this.message.sentTimestamp - val sender = storage.getUserPublicKey() - if (sentTimestamp != null && sender != null) { - storage.markAsSending(sentTimestamp, sender) - } + messageId?.let(storage::markAsSending) if (message != null) { - if (!messageDataProvider.isOutgoingMessage(message.sentTimestamp!!) && message.reaction == null) return // The message has been deleted + val isOutgoing = messageId != null && messageDataProvider.isOutgoingMessage(messageId) + if (!isOutgoing && message.reaction == null) return // The message has been deleted val attachmentIDs = mutableListOf() attachmentIDs.addAll(message.attachmentIDs) message.quote?.let { it.attachmentID?.let { attachmentID -> attachmentIDs.add(attachmentID) } } @@ -81,7 +77,7 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta return } // Wait for all attachments to upload before continuing } - val isSync = destination is Destination.Contact && destination.publicKey == sender + val isSync = destination is Destination.Contact && destination.publicKey == storage.getUserPublicKey() try { withTimeout(20_000L) { @@ -134,11 +130,11 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta private fun handleFailure(dispatcherName: String, error: Exception) { Log.w(TAG, "Failed to send $message::class.simpleName.", error) - val message = message as? VisibleMessage - if (message != null) { + val messageId = message.id + if (message is VisibleMessage && messageId != null) { if ( - MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(message.sentTimestamp!!) || - !MessagingModuleConfiguration.shared.messageDataProvider.isOutgoingMessage(message.sentTimestamp!!) + MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId) || + !MessagingModuleConfiguration.shared.messageDataProvider.isOutgoingMessage(messageId) ) { return // The message has been deleted } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt index f80b26e718..366b49d24a 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -8,9 +8,10 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.snode.SnodeMessage import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType +import org.thoughtcrime.securesms.database.model.MessageId abstract class Message { - var id: Long? = null + var id: MessageId? = null // Message ID in the database. Not all messages will be saved to db. var threadID: Long? = null var sentTimestamp: Long? = null var receivedTimestamp: Long? = null diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index eb21a97e46..558e92b5ba 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -51,7 +51,6 @@ import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.hasNamespaces import org.session.libsignal.utilities.hexEncodedPublicKey import java.util.concurrent.TimeUnit -import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote @@ -432,13 +431,12 @@ object MessageSender { val threadId by lazy { requireNotNull(message.threadID) { "threadID for the message is null" } } val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! - val timestamp = message.sentTimestamp!! // Ignore future self-sends - storage.addReceivedMessageTimestamp(timestamp) - storage.getMessageIdInDatabase(timestamp, userPublicKey)?.let { (messageID, mms) -> + storage.addReceivedMessageTimestamp(message.sentTimestamp!!) + message.id?.let { messageId -> if (openGroupSentTimestamp != -1L && message is VisibleMessage) { storage.addReceivedMessageTimestamp(openGroupSentTimestamp) - storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, threadId) + storage.updateSentTimestamp(messageId, openGroupSentTimestamp, threadId) message.sentTimestamp = openGroupSentTimestamp } @@ -446,11 +444,11 @@ object MessageSender { // will be replaced by the hash value of the sync message. Since the hash value of the // real message has no use when we delete a message. It is OK to let it be. message.serverHash?.let { - storage.setMessageServerHash(messageID, mms, it) + storage.setMessageServerHash(messageId, it) } // in case any errors from previous sends - storage.clearErrorMessage(messageID) + storage.clearErrorMessage(messageId) // Track the open group server message ID val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) @@ -473,12 +471,12 @@ object MessageSender { val encoded = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) val communityThreadID = storage.getThreadId(Address.fromSerialized(encoded)) if (communityThreadID != null && communityThreadID >= 0) { - storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, communityThreadID, !(message as VisibleMessage).isMediaMessage()) + storage.setOpenGroupServerMessageID(messageId.id, message.openGroupServerMessageID!!, communityThreadID, !messageId.mms) } } // Mark the message as sent. - storage.markAsSent(messageID = messageID, isMms = mms) + storage.markAsSent(messageId) // Start the disappearing messages timer if needed SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message, startDisappearAfterRead = true) @@ -493,7 +491,7 @@ object MessageSender { if (message is VisibleMessage) message.syncTarget = destination.publicKey if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey - storage.markAsSyncing(timestamp, userPublicKey) + message.id?.let(storage::markAsSyncing) GlobalScope.launch { try { sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) @@ -506,32 +504,31 @@ object MessageSender { fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { val storage = MessagingModuleConfiguration.shared.storage - val timestamp = message.sentTimestamp!! + + val messageId = message.id ?: return // no need to handle if message is marked as deleted - if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(message.sentTimestamp!!)){ + if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId)){ return } - val userPublicKey = storage.getUserPublicKey()!! - - val author = message.sender ?: userPublicKey - - if (isSyncMessage) storage.markAsSyncFailed(timestamp, author, error) - else storage.markAsSentFailed(timestamp, author, error) + if (isSyncMessage) storage.markAsSyncFailed(messageId, error) + else storage.markAsSentFailed(messageId, error) } // Convenience @JvmStatic - fun send(message: VisibleMessage, address: Address, attachments: List, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { + fun send(message: VisibleMessage, address: Address, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val attachmentIDs = messageDataProvider.getAttachmentIDsFor(message.id!!) - message.attachmentIDs.addAll(attachmentIDs) + val messageId = message.id + if (messageId?.mms == true) { + message.attachmentIDs.addAll(messageDataProvider.getAttachmentIDsFor(messageId.id)) + } message.quote = Quote.from(quote) message.linkPreview = LinkPreview.from(linkPreview) message.linkPreview?.let { linkPreview -> - if (linkPreview.attachmentID == null) { - messageDataProvider.getLinkPreviewAttachmentIDFor(message.id!!)?.let { attachmentID -> + if (linkPreview.attachmentID == null && messageId?.mms == true) { + messageDataProvider.getLinkPreviewAttachmentIDFor(messageId.id)?.let { attachmentID -> linkPreview.attachmentID = attachmentID message.attachmentIDs.remove(attachmentID) } @@ -565,12 +562,6 @@ object MessageSender { resultChannel.receive().getOrThrow() } - fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address, isSyncMessage: Boolean): Promise { - val attachmentIDs = MessagingModuleConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!) - message.attachmentIDs.addAll(attachmentIDs) - return sendNonDurably(message, address, isSyncMessage) - } - fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise { val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 1b3c63f2c9..caa2c52d8c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -52,6 +52,7 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -65,6 +66,7 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.model.MessageId import java.security.MessageDigest import java.security.SignatureException import java.util.LinkedList @@ -265,7 +267,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { storage.addContacts(message.contacts) } -fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { +fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): MessageId? { val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() val storage = MessagingModuleConfiguration.shared.storage val userAuth = storage.userAuth ?: return null @@ -291,12 +293,12 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val timestamp = message.timestamp ?: return null val author = message.author ?: return null - val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null - val messageType = storage.getMessageType(timestamp, author) ?: return null + val messageToDelete = storage.getMessageBy(timestamp, author) ?: return null + val messageType = messageToDelete.individualRecipient.getType() // send a /delete rquest for 1on1 messages - if(messageType == MessageType.ONE_ON_ONE) { - messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash -> + if (messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageToDelete.id, messageToDelete.isMms)?.let { serverHash -> GlobalScope.launch(Dispatchers.IO) { // using GlobalScope as we are slowly migrating to coroutines but we can't migrate everything at once try { SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) @@ -310,7 +312,7 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { // the message is marked as deleted locally // except for 'note to self' where the message is completely deleted if(messageType == MessageType.NOTE_TO_SELF){ - messageDataProvider.deleteMessage(messageIdToDelete, !mms) + messageDataProvider.deleteMessage(messageToDelete.id, !messageToDelete.isMms) } else { messageDataProvider.markMessageAsDeleted( timestamp = timestamp, @@ -320,14 +322,14 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { } // delete reactions - storage.deleteReactions(messageId = messageIdToDelete, mms = mms) + storage.deleteReactions(messageId = messageToDelete.id, mms = messageToDelete.isMms) // update notification - if (!messageDataProvider.isOutgoingMessage(timestamp)) { + if (!messageToDelete.isOutgoing) { SSKEnvironment.shared.notificationManager.updateNotification(context) } - return messageIdToDelete + return messageToDelete.messageId } fun handleMessageRequestResponse(message: MessageRequestResponse) { @@ -350,7 +352,7 @@ fun MessageReceiver.handleVisibleMessage( threadId: Long, runThreadUpdate: Boolean, runProfileUpdate: Boolean -): Long? { +): MessageId? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val userPublicKey = storage.getUserPublicKey() @@ -479,9 +481,20 @@ fun MessageReceiver.handleVisibleMessage( reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() reaction.dateSent = message.sentTimestamp ?: 0 reaction.dateReceived = message.receivedTimestamp ?: 0 - storage.addReaction(reaction, messageSender, !threadIsGroup) + storage.addReaction( + reaction = reaction, + threadId = threadId, + messageSender = messageSender, + notifyUnread = !threadIsGroup + ) } else { - storage.removeReaction(reaction.emoji!!, reaction.timestamp!!, reaction.publicKey!!, threadIsGroup) + storage.removeReaction( + emoji = reaction.emoji!!, + messageTimestamp = reaction.timestamp!!, + threadId = threadId, + author = reaction.publicKey!!, + notifyUnread = threadIsGroup + ) } } ?: run { // A user is mentioned if their public key is in the body of a message or one of their messages @@ -498,17 +511,16 @@ fun MessageReceiver.handleVisibleMessage( val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null // Parse & persist attachments // Start attachment downloads if needed - if (threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey) { - storage.getAttachmentsForMessage(messageID).iterator().forEach { attachment -> + if (messageID.mms && (threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey)) { + storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> attachment.attachmentId?.let { id -> - val downloadJob = AttachmentDownloadJob(id.rowId, messageID) + val downloadJob = AttachmentDownloadJob(id.rowId, messageID.id) JobQueue.shared.add(downloadJob) } } } message.openGroupServerMessageID?.let { - val isSms = !message.isMediaMessage() && attachments.isEmpty() - storage.setOpenGroupServerMessageID(messageID, it, threadID, isSms) + storage.setOpenGroupServerMessageID(messageID.id, it, threadID, messageID.mms) } SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message) return messageID @@ -541,46 +553,61 @@ fun MessageReceiver.handleOpenGroupReactions( val count = if (reaction.you) reaction.count - 1 else reaction.count // Add the first reaction (with the count) reactorIds.firstOrNull()?.let { reactor -> - storage.addReaction(Reaction( - localId = messageId, - isMms = !isSms, - publicKey = reactor, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = count, - index = reaction.index - ), reactor, false) + storage.addReaction( + reaction = Reaction( + localId = messageId, + isMms = !isSms, + publicKey = reactor, + emoji = emoji, + react = true, + serverId = "$openGroupMessageServerID", + count = count, + index = reaction.index + ), + threadId = threadId, + messageSender = reactor, + notifyUnread = false + ) } // Add all other reactions val maxAllowed = if (shouldAddUserReaction) 4 else 5 val lastIndex = min(maxAllowed, reactorIds.size) reactorIds.slice(1 until lastIndex).map { reactor -> - storage.addReaction(Reaction( - localId = messageId, - isMms = !isSms, - publicKey = reactor, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = 0, // Only want this on the first reaction - index = reaction.index - ), reactor, false) + storage.addReaction( + reaction = Reaction( + localId = messageId, + isMms = !isSms, + publicKey = reactor, + emoji = emoji, + react = true, + serverId = "$openGroupMessageServerID", + count = 0, // Only want this on the first reaction + index = reaction.index + ), + threadId = threadId, + messageSender = reactor, + notifyUnread = false + ) } // Add the current user reaction (if applicable and not already included) if (shouldAddUserReaction) { - storage.addReaction(Reaction( - localId = messageId, - isMms = !isSms, - publicKey = userPublicKey, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = 1, - index = reaction.index - ), userPublicKey, false) + storage.addReaction( + reaction = Reaction( + localId = messageId, + isMms = !isSms, + publicKey = userPublicKey, + emoji = emoji, + react = true, + serverId = "$openGroupMessageServerID", + count = 1, + index = reaction.index + ), + threadId = threadId, + messageSender = userPublicKey, + notifyUnread = false + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index bdd45ad3c5..a76ab5275d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -4,7 +4,6 @@ import android.content.Context import android.text.TextUtils import com.google.protobuf.ByteString import org.session.libsession.database.MessageDataProvider -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -28,6 +27,8 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.PartAuthority @@ -90,17 +91,17 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider { + override fun getAttachmentIDsFor(mmsMessageId: Long): List { return DatabaseComponent.get(context) .attachmentDatabase() - .getAttachmentsForMessage(messageID).mapNotNull { + .getAttachmentsForMessage(mmsMessageId).mapNotNull { if (it.isQuote) return@mapNotNull null it.attachmentId.rowId } } - override fun getLinkPreviewAttachmentIDFor(messageID: Long): Long? { - val message = DatabaseComponent.get(context).mmsDatabase().getOutgoingMessage(messageID) + override fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? { + val message = DatabaseComponent.get(context).mmsDatabase().getOutgoingMessage(mmsMessageId) return message.linkPreviews.firstOrNull()?.attachmentId?.rowId } @@ -137,16 +138,20 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider - val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP) - ?.let(mmsSmsDb::getMessageForTimestamp) + val message = result.data?.let { IntentCompat.getParcelableExtra(it, MessageDetailActivity.MESSAGE_ID, MessageId::class.java) } + ?.let(mmsSmsDb::getMessageById) val set = setOfNotNull(message) @@ -2429,7 +2430,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun showMessageDetail(messages: Set) { Intent(this, MessageDetailActivity::class.java) - .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } + .apply { putExtra(MessageDetailActivity.MESSAGE_ID, messages.first().let { + MessageId(it.id, it.isMms) + }) } .let { handleMessageDetail.launch(it) overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 0e66f2e229..e2fc593df0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -27,8 +27,10 @@ import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R @@ -50,6 +52,7 @@ import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.repository.ConversationRepository @@ -104,7 +107,6 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager - private val scope = CoroutineScope(Dispatchers.Default) private var job: Job? = null private val iconMore by lazy { @@ -163,10 +165,14 @@ class ConversationReactionOverlay : FrameLayout { this.activity = activity doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) } - job = scope.launch(Dispatchers.IO) { + job = GlobalScope.launch { + // Wait for the message to be deleted repository.changes(messageRecord.threadId) - .filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null } - .collect { withContext(Dispatchers.Main) { hide() } } + .first { mmsSmsDatabase.getMessageById(messageRecord.messageId) == null } + + withContext(Dispatchers.Main) { + hide() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index b087906b54..176f533e46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -52,12 +52,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.IntentCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.bumptech.glide.integration.compose.placeholder import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding @@ -65,6 +66,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.CarouselNextButton @@ -96,11 +98,15 @@ class MessageDetailActivity : ScreenLockActionBarActivity(), ActivityDispatcher @Inject lateinit var storage: StorageProtocol - private val viewModel: MessageDetailsViewModel by viewModels() + private val viewModel: MessageDetailsViewModel by viewModels(extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { + it.create(IntentCompat.getParcelableExtra(intent, MESSAGE_ID, MessageId::class.java)!!) + } + }) companion object { // Extras - const val MESSAGE_TIMESTAMP = "message_timestamp" + const val MESSAGE_ID = "message_id" const val ON_REPLY = 1 const val ON_RESEND = 2 @@ -114,8 +120,6 @@ class MessageDetailActivity : ScreenLockActionBarActivity(), ActivityDispatcher title = resources.getString(R.string.messageInfo) - viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) - setComposeContent { MessageDetailsScreen() } lifecycleScope.launch { @@ -159,10 +163,7 @@ class MessageDetailActivity : ScreenLockActionBarActivity(), ActivityDispatcher } private fun setResultAndFinish(code: Int) { - Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) } - .let(Intent()::putExtras) - .let { setResult(code, it) } - + setResult(code, Intent().putExtra(MESSAGE_ID, viewModel.messageId)) finish() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index ee00c7718b..f844996f83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -5,15 +5,17 @@ import android.text.format.Formatter import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -24,7 +26,6 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.MediaPreviewArgs @@ -34,36 +35,32 @@ import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.Slide -import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.util.observeChanges import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -import javax.inject.Inject import kotlin.text.Typography.ellipsis -@HiltViewModel -class MessageDetailsViewModel @Inject constructor( +@HiltViewModel(assistedFactory = MessageDetailsViewModel.Factory::class) +class MessageDetailsViewModel @AssistedInject constructor( + @Assisted val messageId: MessageId, private val prefs: TextSecurePreferences, private val attachmentDb: AttachmentDatabase, private val lokiMessageDatabase: LokiMessageDatabase, private val mmsSmsDatabase: MmsSmsDatabase, private val threadDb: ThreadDatabase, - private val repository: ConversationRepository, private val deprecationManager: LegacyGroupDeprecationManager, private val context: ApplicationContext, - private val messageDataProvider: MessageDataProvider, - private val storage: Storage + messageDataProvider: MessageDataProvider, + storage: Storage ) : ViewModel() { - - private var job: Job? = null - private val state = MutableStateFlow(MessageDetailsState()) val stateFlow = state.asStateFlow() @@ -76,33 +73,34 @@ class MessageDetailsViewModel @Inject constructor( scope = viewModelScope, ) - @OptIn(FlowPreview::class) - var timestamp: Long = 0L - set(value) { - job?.cancel() - - field = value - val messageRecord = mmsSmsDatabase.getMessageForTimestamp(timestamp) + init { + viewModelScope.launch { + val messageRecord = withContext(Dispatchers.Default) { + mmsSmsDatabase.getMessageById(messageId) + } if (messageRecord == null) { - viewModelScope.launch { event.send(Event.Finish) } - return + event.send(Event.Finish) + return@launch } // listen to conversation and attachments changes - job = viewModelScope.launch { - (context.contentResolver.observeChanges(DatabaseContentProviders.Conversation.getUriForThread(messageRecord.threadId)) as Flow<*>) + (context.contentResolver.observeChanges(DatabaseContentProviders.Conversation.getUriForThread(messageRecord.threadId)) as Flow<*>) .debounce(200L) - .onStart { emit(Unit) } - .collect{ - val updatedRecord = mmsSmsDatabase.getMessageForTimestamp(value) + .map { + withContext(Dispatchers.Default) { + mmsSmsDatabase.getMessageById(messageId) + } + } + .onStart { emit(messageRecord) } + .collect { updatedRecord -> if(updatedRecord == null) event.send(Event.Finish) else { createStateFromRecord(updatedRecord) } } - } } + } private suspend fun createStateFromRecord(messageRecord: MessageRecord){ val mmsRecord = messageRecord as? MmsMessageRecord @@ -116,7 +114,7 @@ class MessageDetailsViewModel @Inject constructor( deprecationManager.isDeprecated - val errorString = lokiMessageDatabase.getErrorMessage(id) + val errorString = lokiMessageDatabase.getErrorMessage(messageId) var status: MessageStatus? = null // create a 'failed to send' status if appropriate @@ -228,6 +226,11 @@ class MessageDetailsViewModel @Inject constructor( fun retryFailedAttachments(attachments: List){ attachmentDownloadHandler.retryFailedAttachments(attachments) } + + @AssistedFactory + interface Factory { + fun create(id: MessageId) : MessageDetailsViewModel + } } data class MessageDetailsState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 203ce11f06..3fb6a77bac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -19,7 +19,7 @@ object ResendMessageUtilities { fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient: Recipient = messageRecord.recipient val message = VisibleMessage() - message.id = messageRecord.getId() + message.id = messageRecord.messageId if (messageRecord.isOpenGroupInvitation) { val openGroupInvitation = OpenGroupInvitation() UpdateMessageData.fromJSON(messageRecord.body)?.let { updateMessageData -> @@ -55,10 +55,10 @@ object ResendMessageUtilities { val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() if (sentTimestamp != null && sender != null) { if (isResync) { - MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender) + MessagingModuleConfiguration.shared.storage.markAsResyncing(messageRecord.messageId) MessageSender.sendNonDurably(message, Destination.from(recipient.address), isSyncMessage = true) } else { - MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) + MessagingModuleConfiguration.shared.storage.markAsSending(messageRecord.messageId) MessageSender.send(message, recipient.address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt index 40d38e8b70..ddd5894b80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt @@ -1,11 +1,12 @@ package org.thoughtcrime.securesms.database +import org.thoughtcrime.securesms.database.model.MessageId + data class ExpirationInfo( - val id: Long, + val id: MessageId, val timestamp: Long, val expiresIn: Long, val expireStarted: Long, - val isMms: Boolean ) { private fun isDisappearAfterSend() = timestamp == expireStarted fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 57fb743252..692a64b2be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -9,6 +9,7 @@ import org.session.libsession.database.ServerHashToMessageId import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.util.asSequence import javax.inject.Provider @@ -55,9 +56,14 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getString(errorMessage) - } + return database.get(errorMessageTable, + "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) + { cursor -> cursor.getString(errorMessage) } } - fun setErrorMessage(messageID: Long, errorMessage: String) { + fun setErrorMessage(messageID: MessageId, errorMessage: String) { val database = writableDatabase val contentValues = ContentValues(2) - contentValues.put(Companion.messageID, messageID) + contentValues.put(Companion.messageID, messageID.id) + contentValues.put(messageType, messageID.asMessageType) contentValues.put(Companion.errorMessage, errorMessage) - database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) } - fun clearErrorMessage(messageID: Long) { + fun clearErrorMessage(messageID: MessageId) { val database = writableDatabase - database.delete(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + database.delete(errorMessageTable, "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType)) } fun deleteThread(threadId: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index e7a5900f49..d141bdaa20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -37,7 +37,7 @@ public MessagingDatabase(Context context, Provider database public abstract void markExpireStarted(long messageId, long startTime); - public abstract void markAsSent(long messageId, boolean secure); + public abstract void markAsSent(long messageId, boolean sent); public abstract void markAsSyncing(long id); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 8351dc4641..5d1555b082 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord @@ -90,12 +91,12 @@ class MmsDatabase(context: Context, databaseHelper: Provider databaseHel super(context, databaseHelper); } - public @Nullable MessageRecord getMessageForTimestamp(long timestamp) { - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + public @Nullable MessageRecord getMessageForTimestamp(long threadId, long timestamp) { + final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; + + try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } } - public @Nullable MessageRecord getNonDeletedMessageForTimestamp(long timestamp) { - String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp; - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { - MmsSmsDatabase.Reader reader = readerFor(cursor); - return reader.getNext(); + public @Nullable MessageRecord getMessageById(@NonNull MessageId id) { + if (id.isMms()) { + final MmsDatabase db = DatabaseComponent.get(context).mmsDatabase(); + try (final Cursor cursor = db.getMessage(id.getId())) { + return db.readerFor(cursor, true).getNext(); + } + } else { + return DatabaseComponent.get(context).smsDatabase().getMessageOrNull(id.getId()); } } @@ -371,19 +378,16 @@ public long getLastOutgoingTimestamp(long threadId) { return -1; } - public long getLastMessageTimestamp(long threadId) { + @Nullable + public MessageRecord getLastMessage(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; // make sure the last message isn't marked as deleted String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - if (cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)); - } + return readerFor(cursor).getNext(); } - - return -1; } public Cursor getUnread() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index bc861bbcc6..b2c15e7132 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -46,6 +46,7 @@ import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; @@ -278,14 +279,14 @@ public void markAsNotified(long id) { database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); } - public boolean isOutgoingMessage(long timestamp) { + public boolean isOutgoingMessage(long id) { SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; boolean isOutgoing = false; try { cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, ADDRESS, TYPE }, - DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) }, + ID + " = ?", new String[] { String.valueOf(id) }, null, null, null, null); while (cursor.moveToNext()) { @@ -300,14 +301,14 @@ public boolean isOutgoingMessage(long timestamp) { return isOutgoing; } - public boolean isDeletedMessage(long timestamp) { + public boolean isDeletedMessage(long id) { SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; boolean isDeleted = false; try { cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, ADDRESS, TYPE }, - DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) }, + ID + " = ?", new String[] { String.valueOf(id) }, null, null, null, null); while (cursor.moveToNext()) { @@ -392,7 +393,7 @@ private List setMessagesRead(String where, String[] arguments while (cursor != null && cursor.moveToNext()) { long timestamp = cursor.getLong(2); SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), timestamp, cursor.getLong(4), cursor.getLong(5), false); + ExpirationInfo expirationInfo = new ExpirationInfo(new MessageId(cursor.getLong(0), false), timestamp, cursor.getLong(4), cursor.getLong(5)); results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); } @@ -634,22 +635,19 @@ public Cursor getExpirationNotStartedMessages() { return rawQuery(where, null); } + @NonNull public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { - Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""}); - Reader reader = new Reader(cursor); - SmsMessageRecord record = reader.getNext(); - - reader.close(); + final SmsMessageRecord record = getMessageOrNull(messageId); if (record == null) throw new NoSuchMessageException("No message for ID: " + messageId); else return record; } - public Cursor getMessageCursor(long messageId) { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null); - setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)); - return cursor; + @Nullable + public SmsMessageRecord getMessageOrNull(long messageId) { + try (final Cursor cursor = rawQuery(ID_WHERE, new String[]{String.valueOf(messageId)})) { + return new Reader(cursor).getNext(); + } } // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 4293c6ce44..71055f761e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -64,10 +64,8 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.getGroup -import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState -import org.session.libsession.utilities.recipients.getType import org.session.libsession.utilities.upsertContact import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -258,8 +256,8 @@ open class Storage @Inject constructor( return database.insertAttachments(messageID, databaseAttachments) } - override fun getAttachmentsForMessage(messageID: Long): List { - return attachmentDatabase.getAttachmentsForMessage(messageID) + override fun getAttachmentsForMessage(mmsMessageId: Long): List { + return attachmentDatabase.getAttachmentsForMessage(mmsMessageId) } override fun getLastSeen(threadId: Long): Long { @@ -345,14 +343,15 @@ open class Storage @Inject constructor( threadDb.update(threadId, unarchive) } - override fun persist(message: VisibleMessage, - quotes: QuoteModel?, - linkPreview: List, - groupPublicKey: String?, - openGroupID: String?, - attachments: List, - runThreadUpdate: Boolean): Long? { - var messageID: Long? = null + override fun persist( + message: VisibleMessage, + quotes: QuoteModel?, + linkPreview: List, + groupPublicKey: String?, + openGroupID: String?, + attachments: List, + runThreadUpdate: Boolean): MessageId? { + val messageID: MessageId? val senderAddress = fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey @@ -437,9 +436,9 @@ open class Storage @Inject constructor( val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } - if (insertResult.isPresent) { - messageID = insertResult.get().messageId - } + + messageID = insertResult.orNull()?.messageId?.let { MessageId(it, mms = true) } + } else { val isOpenGroupInvitation = (message.openGroupInvitation != null) @@ -453,17 +452,12 @@ open class Storage @Inject constructor( val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } - insertResult.orNull()?.let { result -> - messageID = result.messageId - } + messageID = insertResult.orNull()?.messageId?.let { MessageId(it, mms = false) } } + message.serverHash?.let { serverHash -> messageID?.let { id -> - // When a message with attachment is received, we don't immediately have - // attachments attached in the messages, but it's a mms from the db's perspective - // nonetheless. - val isMms = message.isMediaMessage() || attachments.isNotEmpty() - lokiMessageDatabase.setMessageServerHash(id, isMms, serverHash) + lokiMessageDatabase.setMessageServerHash(id.id, id.mms, serverHash) } } return messageID @@ -651,78 +645,55 @@ open class Storage @Inject constructor( SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? { + override fun getMessageBy(timestamp: Long, author: String): MessageRecord? { val database = mmsSmsDatabase val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.run { getId() to isMms } - } - - override fun getMessageType(timestamp: Long, author: String): MessageType? { - val address = fromSerialized(author) - return mmsSmsDatabase.getMessageFor(timestamp, address)?.individualRecipient?.getType() + return database.getMessageFor(timestamp, address) } override fun updateSentTimestamp( - messageID: Long, - isMms: Boolean, + messageId: MessageId, openGroupSentTimestamp: Long, threadId: Long ) { - if (isMms) { + if (messageId.mms) { val mmsDb = mmsDatabase - mmsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) + mmsDb.updateSentTimestamp(messageId.id, openGroupSentTimestamp, threadId) } else { val smsDb = smsDatabase - smsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) + smsDb.updateSentTimestamp(messageId.id, openGroupSentTimestamp, threadId) } } - override fun markAsSent(messageID: Long, isMms: Boolean) { - if (isMms) { - mmsDatabase.markAsSent(messageID, true) - } else { - smsDatabase.markAsSent(messageID, true) - } + override fun markAsSent(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsSent(messageId.id, true) } - override fun markAsSyncing(timestamp: Long, author: String) { - mmsSmsDatabase - .getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) } + override fun markAsSyncing(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsSyncing(messageId.id) } private fun getMmsDatabaseElseSms(isMms: Boolean) = if (isMms) mmsDatabase else smsDatabase - override fun markAsResyncing(timestamp: Long, author: String) { - mmsSmsDatabase - .getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) } + override fun markAsResyncing(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsResyncing(messageId.id) } - override fun markAsSending(timestamp: Long, author: String) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - if (messageRecord.isMms) { - val mmsDatabase = mmsDatabase - mmsDatabase.markAsSending(messageRecord.getId()) + override fun markAsSending(messageId: MessageId) { + if (messageId.mms) { + mmsDatabase.markAsSending(messageId.id) } else { - val smsDatabase = smsDatabase - smsDatabase.markAsSending(messageRecord.getId()) - messageRecord.isPending + smsDatabase.markAsSending(messageId.id) } } - override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - if (messageRecord.isMms) { - val mmsDatabase = mmsDatabase - mmsDatabase.markAsSentFailed(messageRecord.getId()) + override fun markAsSentFailed(messageId: MessageId, error: Exception) { + if (messageId.mms) { + mmsDatabase.markAsSentFailed(messageId.id) } else { - val smsDatabase = smsDatabase - smsDatabase.markAsSentFailed(messageRecord.getId()) + smsDatabase.markAsSentFailed(messageId.id) } if (error.localizedMessage != null) { val message: String @@ -731,18 +702,14 @@ open class Storage @Inject constructor( } else { message = error.localizedMessage!! } - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageId, message) } else { - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageId, error.javaClass.simpleName) } } - override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - - database.getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) } + override fun markAsSyncFailed(messageId: MessageId, error: Exception) { + getMmsDatabaseElseSms(messageId.mms).markAsSyncFailed(messageId.id) if (error.localizedMessage != null) { val message: String @@ -751,19 +718,18 @@ open class Storage @Inject constructor( } else { message = error.localizedMessage!! } - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageId, message) } else { - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageId, error.javaClass.simpleName) } } - override fun clearErrorMessage(messageID: Long) { - val db = lokiMessageDatabase - db.clearErrorMessage(messageID) + override fun clearErrorMessage(messageID: MessageId) { + lokiMessageDatabase.clearErrorMessage(messageID) } - override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { - lokiMessageDatabase.setMessageServerHash(messageID, mms, serverHash) + override fun setMessageServerHash(messageId: MessageId, serverHash: String) { + lokiMessageDatabase.setMessageServerHash(messageId.id, messageId.mms, serverHash) } override fun getGroup(groupID: String): GroupRecord? { @@ -1764,7 +1730,7 @@ open class Storage @Inject constructor( return mapping } - override fun addReaction(reaction: Reaction, messageSender: String, notifyUnread: Boolean) { + override fun addReaction(reaction: Reaction, threadId: Long, messageSender: String, notifyUnread: Boolean) { val timestamp = reaction.timestamp val localId = reaction.localId val isMms = reaction.isMms @@ -1776,7 +1742,7 @@ open class Storage @Inject constructor( MessageId(localId, isMms) } else if (timestamp != null && timestamp > 0) { - val messageRecord = mmsSmsDatabase.getMessageForTimestamp(timestamp) ?: return + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(threadId, timestamp) ?: return if (messageRecord.isDeleted) return MessageId(messageRecord.id, messageRecord.isMms) } else return @@ -1797,10 +1763,15 @@ open class Storage @Inject constructor( ) } - override fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) { - val messageRecord = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp) ?: return - val messageId = MessageId(messageRecord.id, messageRecord.isMms) - reactionDatabase.deleteReaction(emoji, messageId, author, notifyUnread) + override fun removeReaction( + emoji: String, + messageTimestamp: Long, + threadId: Long, + author: String, + notifyUnread: Boolean + ) { + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(threadId, messageTimestamp) ?: return + reactionDatabase.deleteReaction(emoji, MessageId(messageRecord.id, messageRecord.isMms), author, notifyUnread) } override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 797d75b5ed..ee31899f99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -904,10 +904,7 @@ public ThreadRecord getCurrent() { if (count > 0) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); - long messageTimestamp = mmsSmsDatabase.getLastMessageTimestamp(threadId); - if (messageTimestamp > 0) { - lastMessage = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp); - } + lastMessage = mmsSmsDatabase.getLastMessage(threadId); } final GroupThreadStatus groupThreadStatus; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index d50a01ae4b..853fc6775f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -99,9 +99,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV46 = 67; private static final int lokiV47 = 68; private static final int lokiV48 = 69; + private static final int lokiV49 = 70; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV48; + private static final int DATABASE_VERSION = lokiV49; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -528,6 +529,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(MmsDatabase.ADD_IS_GROUP_UPDATE_COLUMN); } + if (oldVersion < lokiV49) { + db.execSQL(LokiMessageDatabase.getUpdateErrorMessageTableCommand()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index e7155aa781..6d24486475 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -1,13 +1,22 @@ package org.thoughtcrime.securesms.database.model +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + /** * Represents a pair of values that can be used to find a message. Because we have two tables, * that means this has both the primary key and a boolean indicating which table it's in. */ +@Parcelize data class MessageId( val id: Long, @get:JvmName("isMms") val mms: Boolean -) { +): Parcelable { + // Exists only because Kryo wants it + @Keep + private constructor(): this(0, false) + fun serialize(): String { return "$id|$mms" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 52b68dc7d5..e5e375dac9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -69,6 +69,10 @@ public final boolean isNotDisappearAfterRead() { public abstract boolean isMms(); public abstract boolean isMmsNotification(); + public final MessageId getMessageId() { + return new MessageId(getId(), isMms()); + } + MessageRecord(long id, String body, Recipient conversationRecipient, Recipient individualRecipient, long dateSent, long dateReceived, long threadId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index f6b2478a1d..6ce0376bdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.database.ExpirationInfo import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt @@ -70,12 +71,12 @@ class MarkReadReceiver : BroadcastReceiver() { // start disappear after read messages except TimerUpdates in groups. markedReadMessages + .asSequence() .filter { it.expiryType == ExpiryType.AFTER_READ } - .map { it.syncMessageId } - .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { + .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunityRecipient == true } == false } - .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.toString()) } + .forEach { messageExpirationManager.startDisappearAfterRead(it.syncMessageId.timetamp, it.syncMessageId.address.toString()) } hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> GlobalScope.launch { @@ -97,7 +98,7 @@ class MarkReadReceiver : BroadcastReceiver() { return markedReadMessages .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id.id, id.mms) } } .takeIf { it.isNotEmpty() } } @@ -156,13 +157,13 @@ class MarkReadReceiver : BroadcastReceiver() { val expireStarted = expirationInfo.expireStarted if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) { - val db = DatabaseComponent.get(context).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() } - db.markExpireStarted(expirationInfo.id, now) + val db = DatabaseComponent.get(context).run { if (expirationInfo.id.mms) mmsDatabase() else smsDatabase() } + db.markExpireStarted(expirationInfo.id.id, now) } ApplicationContext.getInstance(context).expiringMessageManager.get().scheduleDeletion( - expirationInfo.id, - expirationInfo.isMms, + expirationInfo.id.id, + expirationInfo.id.mms, now, expiresIn ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 09c6c37c43..99798a7e0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; @@ -109,7 +110,7 @@ protected Void doInBackground(Void... params) { case GroupMessage: { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - message.setId(mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true)); + message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true), true)); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -118,7 +119,7 @@ protected Void doInBackground(Void... params) { } case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); - message.setId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true)); + message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true), false)); MessageSender.send(message, address); break; } From 7278abdf67e1021214962c54744f990a608d29cd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 09:36:58 +1000 Subject: [PATCH 299/867] todo --- app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle b/app/build.gradle index aa939460c3..2cce3a3f8f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,7 @@ android { } } + A FAIRE: SOLVE CONFLICTS TO BRING NETWORK PAGE TO DEV THEN DEV TO UCS - THEN LOOK INTO THOSE SMALL TICKETS I OPENED AND CONTINUE CLEANING BACKLOG splits { abi { From dfc9a53fb37e1b26b1db9ba48435517bf6fac4c1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 09:52:23 +1000 Subject: [PATCH 300/867] Update build.gradle Version bump --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0b4a8dd744..c0916eac81 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ configurations.configureEach { exclude module: "commons-logging" } -def canonicalVersionCode = 407 -def canonicalVersionName = "1.23.1" +def canonicalVersionCode = 408 +def canonicalVersionName = "1.23.2" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, From 518bc8d6371bd0ccb4c93c24e90e2759be7d3797 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 09:53:00 +1000 Subject: [PATCH 301/867] overlay lateinit crash --- .../conversation/v2/ConversationReactionOverlay.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 0e66f2e229..065db0d117 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -340,6 +340,11 @@ class ConversationReactionOverlay : FrameLayout { private fun hideInternal(onHideListener: OnHideListener?) { job?.cancel() overlayState = OverlayState.HIDDEN + contextMenu?.dismiss() + + // in case hide is called before show + if (!::selectedConversationModel.isInitialized) return + val animatorSet = newHideAnimatorSet() hideAnimatorSet = animatorSet revealAnimatorSet.end() @@ -352,7 +357,6 @@ class ConversationReactionOverlay : FrameLayout { onHideListener?.onHide() } }) - contextMenu?.dismiss() } val isShowing: Boolean From 694d07f596d3b4f2543be75866e7ca20478ab09a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 10:04:43 +1000 Subject: [PATCH 302/867] Fixing giphy crash --- .../securesms/giph/ui/GiphyFragment.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java index 6feb2641b2..2cfc742fe9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -46,6 +46,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, this.loadingProgress = ViewUtil.findById(container, R.id.loading_progress); this.noResultsView = ViewUtil.findById(container, R.id.no_results); + // Now that views are ready, apply the searchString if it's set + applySearchStringToUI(); + return container; } @@ -89,12 +92,21 @@ private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { : new LinearLayoutManager(getActivity()); } + public void setSearchString(@Nullable String searchString) { this.searchString = searchString; - this.noResultsView.setVisibility(View.GONE); - this.getLoaderManager().restartLoader(0, null, this); + if (this.noResultsView != null) { + applySearchStringToUI(); + } } + private void applySearchStringToUI() { + if (this.noResultsView != null) { + this.noResultsView.setVisibility(View.GONE); + this.getLoaderManager().restartLoader(0, null, this); + } + } + @Override public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { if (getActivity() instanceof GiphyAdapter.OnItemClickListener) { From 975a148223c0c73cf6faf3a6c8e4d6214ddf5508 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 10:16:40 +1000 Subject: [PATCH 303/867] Don't apply the grid layout too early --- .../securesms/giph/ui/GiphyFragment.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java index 2cfc742fe9..753e43b833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -38,6 +38,7 @@ public abstract class GiphyFragment extends Fragment implements LoaderManager.Lo private TextView noResultsView; protected String searchString; + private Boolean pendingGridLayout = null; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { @@ -49,6 +50,15 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, // Now that views are ready, apply the searchString if it's set applySearchStringToUI(); + // Apply pending layout if it was set before view was ready + if (pendingGridLayout != null) { + setLayoutManager(pendingGridLayout); + pendingGridLayout = null; + } else { + // Or set default + setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); + } + return container; } @@ -59,7 +69,6 @@ public void onActivityCreated(Bundle bundle) { this.giphyAdapter = new GiphyAdapter(getActivity(), Glide.with(this), new LinkedList<>()); this.giphyAdapter.setListener(this); - setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); this.recyclerView.setItemAnimator(new DefaultItemAnimator()); this.recyclerView.setAdapter(giphyAdapter); this.recyclerView.addOnScrollListener(new GiphyScrollListener()); @@ -84,7 +93,11 @@ public void onLoaderReset(@NonNull Loader> loader) { } public void setLayoutManager(boolean gridLayout) { - recyclerView.setLayoutManager(getLayoutManager(gridLayout)); + if (recyclerView != null) { + recyclerView.setLayoutManager(getLayoutManager(gridLayout)); + } else { + pendingGridLayout = gridLayout; + } } private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { @@ -106,7 +119,7 @@ private void applySearchStringToUI() { this.getLoaderManager().restartLoader(0, null, this); } } - + @Override public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { if (getActivity() instanceof GiphyAdapter.OnItemClickListener) { From 41b35f53f211831fc0c2878af6cbc70d29f56596 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 10:27:19 +1000 Subject: [PATCH 304/867] Bumping code --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 2cce3a3f8f..7a348563d9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ configurations.configureEach { exclude module: "commons-logging" } -def canonicalVersionCode = 408 +def canonicalVersionCode = 409 def canonicalVersionName = "1.24.0" def postFixSize = 10 From efbcbfacc874c2f502c7849fef8cf9603dea37ce Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sat, 17 May 2025 10:28:01 +1000 Subject: [PATCH 305/867] Clean --- app/build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7a348563d9..8adf1dc335 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,8 +55,6 @@ android { } } - A FAIRE: SOLVE CONFLICTS TO BRING NETWORK PAGE TO DEV THEN DEV TO UCS - THEN LOOK INTO THOSE SMALL TICKETS I OPENED AND CONTINUE CLEANING BACKLOG - splits { abi { enable !project.hasProperty('huawei') // huawei builds do not need the split variants From eac0a52e23a04a6f848b1a0e7f6cd8dda8981ebd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sun, 18 May 2025 18:03:03 +1000 Subject: [PATCH 306/867] Proper hint string --- .../conversation/v2/settings/ConversationSettingsDialogs.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 378848542f..79c8110cd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -95,7 +95,7 @@ fun ConversationSettingsDialogs( modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_nickname_input) .focusRequester(focusRequester) .padding(top = LocalDimensions.current.smallSpacing), - placeholder = stringResource(R.string.accountIdOrOnsEnter), + placeholder = stringResource(R.string.nicknameEnter), onChange = { updatedText -> sendCommand(UpdateNickname(updatedText)) }, From 0efbea28fdd2d410191ba2aac4f9a8fa213681de Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 09:54:15 +1000 Subject: [PATCH 307/867] Changed view type to respect 0dp constraint --- app/src/main/res/layout/activity_conversation_v2.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 44947edc9a..37496240ab 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -268,7 +268,7 @@ - Date: Mon, 19 May 2025 11:43:04 +1000 Subject: [PATCH 308/867] fixed builds that don't support adaptive icons Tweaked our default icon too --- app/src/main/AndroidManifest.xml | 1 + app/src/main/ic_launcher-playstore.png | Bin 0 -> 15961 bytes .../appearance/AppDisguiseSettings.kt | 16 +++++++++++---- .../res/drawable/ic_launcher_foreground.xml | 19 +++++++++--------- .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 - app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 1803 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 1300 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 2561 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 3881 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 5447 -> 0 bytes 10 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 app/src/main/ic_launcher-playstore.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6244803d77..b1e54d8365 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -225,6 +225,7 @@ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..700fd67cde603b87f97cc1eeed583fb8b25ccd86 GIT binary patch literal 15961 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 WZ6`1H$bjtvUAtm*`O_tb$NvYr9BTXk literal 0 HcmV?d00001 diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt index d2241df684..898cb60614 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke @@ -196,10 +198,16 @@ private fun IconItem( val resources = LocalContext.current.resources val theme = LocalContext.current.theme - val (path, bitmap) = remember(icon, resources, theme) { - val drawable = - ResourcesCompat.getDrawable(resources, icon, theme) as AdaptiveIconDrawable - drawable.iconMask.asComposePath() to drawable.toBitmap().asImageBitmap() + val (path: Path, bitmap) = remember(icon, resources, theme) { + val drawable = ResourcesCompat.getDrawable(resources, icon, theme) + + when(drawable){ + is AdaptiveIconDrawable -> drawable.iconMask.asComposePath() to drawable.toBitmap().asImageBitmap() + else -> { // if the system does not support adaptive icons (like Huawei phones) default to a rectangle shape + val bmp = drawable!!.toBitmap() + Path().apply { addRect(Rect(0f, 0f, bmp.width.toFloat(), bmp.height.toFloat())) } to bmp.asImageBitmap() + } + } } val textColor = LocalColors.current.text diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index a3203d25e9..4eb50efd1f 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,15 +1,14 @@ - - - - - + android:viewportWidth="68" + android:viewportHeight="69"> + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index ef49c99170..7353dbd1fd 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 6e73f903c6a0461fba6b4d34ab88aff9e51038ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1803 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 X1&C_g9nm@-00000NkvXXu0mjfluEfD diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 7a3b9ea8fabfe79dd156df22d5dddb8ac428eeeb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3881 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 ob0tevp-Lf&O8-|P>{kk%RC>3;+NC diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 532e7483246a349c3a2079e5e18ad264dc6c801f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5447 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 Q;RlyRT}1~~sbn4YKZJDiM*si- From d2151a4c50e0f602bde89adb76ef0e2ed4b6fcc0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 12:07:46 +1000 Subject: [PATCH 309/867] Not removing non standard recipients --- app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 4293c6ce44..b3cbef5929 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -1263,6 +1263,7 @@ open class Storage @Inject constructor( // which in the case of contacts we are messaging for the first time and who haven't yet approved us, it won't be the case // But that person is saved in the Recipient db. We might need to investigate how to clean the relationship between Recipients, Contacts and config Contacts. val removedContacts = recipientDatabase.allRecipients.filter { localContact -> + AccountId(localContact.address.toString()).prefix == IdPrefix.STANDARD && // only want standard address localContact.is1on1 && // only for conversations localContact.address.toString() != currentUserKey && // we don't want to remove ourselves (ie, our Note to Self) moreContacts.none { it.id == localContact.address.toString() } // we don't want to remove contacts that are present in the config From 914d52b4b0f2f89b4f4efaf1352c615af1d573bd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 13:39:51 +1000 Subject: [PATCH 310/867] Bringing in the bumped version --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97ba65a073..76480606b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 407 -val canonicalVersionName = "1.23.1" +val canonicalVersionCode = 409 +val canonicalVersionName = "1.24.0" val postFixSize = 10 val abiPostFix = mapOf( From 730d02ed1caa846517ebbf6d4492457976e973f0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 13:40:51 +1000 Subject: [PATCH 311/867] Accidentally removed --- gradle/libs.versions.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24f3311b2c..c83a044aa4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ biometricVersion = "1.1.0" cameraCamera2Version = "1.4.2" cardviewVersion = "1.0.0" composeBomVersion = "2025.05.00" +kotlinComposeCompilerVersion = "1.5.15" composeVersion = "1.0.0-beta01" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" From ca629013fd6273b45f598f4c4930bab86686cc47 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 13:49:40 +1000 Subject: [PATCH 312/867] version bump --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97ba65a073..04d58f9d47 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 407 -val canonicalVersionName = "1.23.1" +val canonicalVersionCode = 408 +val canonicalVersionName = "1.23.2" val postFixSize = 10 val abiPostFix = mapOf( From e399c1c87d58c8c20ac3d93fb05b156f81736b62 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 14:53:47 +1000 Subject: [PATCH 313/867] test import --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04d58f9d47..a658863a65 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -308,7 +308,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.assertj.core) testImplementation(libs.mockito.kotlin) - androidTestImplementation(libs.mockito.android) + androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.kotlin) testImplementation(libs.androidx.core) testImplementation(libs.androidx.core.testing) From cede61f3329eea6cbe3ce83dd831d07003e194e8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 15:50:49 +1000 Subject: [PATCH 314/867] Removing subtitle style as it's not needed --- .../java/org/thoughtcrime/securesms/ui/Components.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 9b8839a764..02f179e95e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -286,7 +286,6 @@ fun ItemButton( @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, - subtitleStyle: TextStyle = LocalType.current.small, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -307,7 +306,6 @@ fun ItemButton( colors = colors, subtitle = subtitle, subtitleQaTag = subtitleQaTag, - subtitleStyle = subtitleStyle, onClick = onClick, ) } @@ -324,7 +322,6 @@ fun ItemButton( @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, - subtitleStyle: TextStyle = LocalType.current.small, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -339,7 +336,6 @@ fun ItemButton( colors = colors, subtitle = subtitle, subtitleQaTag = subtitleQaTag, - subtitleStyle = subtitleStyle, onClick = onClick ) } @@ -353,7 +349,6 @@ fun ItemButton( @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, - subtitleStyle: TextStyle = LocalType.current.small, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -372,7 +367,6 @@ fun ItemButton( }, minHeight = minHeight, textStyle = textStyle, - subtitleStyle = subtitleStyle, colors = colors, shape = shape, onClick = onClick @@ -394,7 +388,6 @@ fun ItemButton( @StringRes subtitleQaTag: Int? = null, minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, - subtitleStyle: TextStyle = LocalType.current.small, colors: ButtonColors = transparentButtonColors(), shape: Shape = RectangleShape, onClick: () -> Unit @@ -430,7 +423,7 @@ fun ItemButton( text = it, modifier = Modifier.fillMaxWidth() .qaTag(subtitleQaTag), - style = subtitleStyle, + style = LocalType.current.small, ) } } From 0a09e1a704dfa6e542584e7b62745e3dfc427f93 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 16:48:29 +1000 Subject: [PATCH 315/867] Safely popping back stack --- .../settings/ConversationSettingsNavHost.kt | 54 +++++++++++++------ .../thoughtcrime/securesms/ui/Navigation.kt | 1 - 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 9f8b275f0f..e4ced5ed5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.settings import android.annotation.SuppressLint -import android.content.Intent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -18,6 +17,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -30,7 +30,15 @@ import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteAllMedia +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteConversationSettings +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteDisappearingMessages +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteFullscreenAvatar +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteGroupMembers +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToCommunity +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteInviteToGroup +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteManageMembers +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.RouteNotifications import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel import org.thoughtcrime.securesms.groups.EditGroupViewModel @@ -194,7 +202,9 @@ fun ConversationSettingsNavHost( GroupMembersScreen( viewModel = viewModel, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, ) } // Edit Group @@ -216,7 +226,9 @@ fun ConversationSettingsNavHost( ) ) }, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, ) } @@ -232,7 +244,7 @@ fun ConversationSettingsNavHost( } // grab a hold of manage group's VM - val parentEntry = remember(navController.currentBackStackEntry) { + val parentEntry = remember(backStackEntry) { navController.getBackStackEntry( RouteManageMembers(data.groupId) ) @@ -241,13 +253,15 @@ fun ConversationSettingsNavHost( InviteContactsScreen( viewModel = viewModel, - onDoneClicked = { + onDoneClicked = dropUnlessResumed { //send invites from the manage group screen editGroupViewModel.onContactSelected(viewModel.currentSelected) navController.popBackStack() }, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, banner = { GroupMinimumVersionBanner() } @@ -262,7 +276,7 @@ fun ConversationSettingsNavHost( } // grab a hold of settings' VM - val parentEntry = remember(navController.currentBackStackEntry) { + val parentEntry = remember(backStackEntry) { navController.getBackStackEntry( RouteConversationSettings ) @@ -278,7 +292,9 @@ fun ConversationSettingsNavHost( // clear selected contacts viewModel.clearSelection() }, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, ) } @@ -295,7 +311,9 @@ fun ConversationSettingsNavHost( DisappearingMessagesScreen( viewModel = viewModel, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, ) } @@ -313,27 +331,31 @@ fun ConversationSettingsNavHost( MediaOverviewScreen( viewModel = viewModel, - onClose = navController::popBackStack, + onClose = dropUnlessResumed { + navController.popBackStack() + }, ) } // Notifications horizontalSlideComposable { - val viewModel = + val viewModel = hiltViewModel { factory -> factory.create(threadId) } NotificationSettingsScreen( viewModel = viewModel, - onBack = navController::popBackStack + onBack = dropUnlessResumed { + navController.popBackStack() + } ) } // Fullscreen Avatar composable { // grab a hold of manage convo setting's VM - val parentEntry = remember(navController.currentBackStackEntry) { + val parentEntry = remember(it) { navController.getBackStackEntry( RouteConversationSettings ) @@ -345,7 +367,9 @@ fun ConversationSettingsNavHost( data = data.avatarUIData, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this, - onBack = navController::popBackStack, + onBack = dropUnlessResumed { + navController.popBackStack() + }, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt index 319b58a3be..f05cfe994e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.ui import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.core.EaseIn import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.LinearEasing From 334f59e9f399f7a9b0a53103996b10f12f312ba3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 May 2025 17:09:54 +1000 Subject: [PATCH 316/867] SES-3253 - Hide blocked contacts from the thread list --- .../java/org/thoughtcrime/securesms/home/HomeViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index eb4c71377a..7f09134425 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -102,8 +102,12 @@ class HomeViewModel @Inject constructor( messageRequests?.let { add(it) } threads.mapNotNullTo(this) { thread -> - // if the note to self is marked as hidden, do not add it - if (thread.recipient.isLocalNumber && hideNoteToSelf) { + // if the note to self is marked as hidden, + // or if the contact is blocked, do not add it + if ( + thread.recipient.isLocalNumber && hideNoteToSelf || + thread.recipient.isBlocked + ) { return@mapNotNullTo null } From 56845e2dcf01d7558ed215d2c86df12fa900949f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 20 May 2025 11:15:01 +1000 Subject: [PATCH 317/867] Showing the eye icon for read threads --- .../java/org/thoughtcrime/securesms/home/ConversationView.kt | 2 +- app/src/main/res/layout/view_input_bar.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 3c7907fd21..e0776a6244 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -136,7 +136,7 @@ class ConversationView : LinearLayout { binding.statusIndicatorImageView.imageTintList = ColorStateList.valueOf(ThemeUtil.getThemedColor(context, R.attr.danger)) } thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dots_custom) - thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) + thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_eye) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index b891456428..ce81370d28 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -47,7 +47,7 @@ android:contentDescription="@string/AccessibilityId_inputBox" android:gravity="center_vertical" android:hint="@string/message" - android:inputType="textCapSentences|textMultiLine|textAutoComplete" + android:inputType="text|textCapSentences|textMultiLine|textAutoComplete" android:maxLength="@integer/max_input_chars" android:textColor="?input_bar_text_user" android:textColorHint="?attr/input_bar_text_hint" From 3c531e4431a83a6c633262da6c4ca6b5740d8d06 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 20 May 2025 14:25:33 +1000 Subject: [PATCH 318/867] Compare read status for conversation item equality --- .../main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index d77eb5b19e..52cc772424 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -55,6 +55,7 @@ class HomeDiffUtil( if (isSameItem) { isSameItem = (oldItem.count == newItem.count) } if (isSameItem) { isSameItem = (oldItem.unreadCount == newItem.unreadCount) } if (isSameItem) { isSameItem = (oldItem.isPinned == newItem.isPinned) } + if (isSameItem) { isSameItem = (oldItem.isRead == newItem.isRead) } // The recipient is passed as a reference and changes to recipients update the reference so we // need to cache the hashCode for the recipient and use that for diffing - unfortunately From 70a50c3351b8511090a82f475aead8b4ba1a3ad7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 20 May 2025 16:26:13 +1000 Subject: [PATCH 319/867] SES-3805 - Do not unpin NTS for wrong reasons the logic was broken with making sure NTS were visible when sending a message there. We need to make sure it only happens when sending a Visible Message, but also only when the NTS is configured as hidden --- .../messaging/sending_receiving/MessageSender.kt | 9 ++++++++- .../securesms/conversation/v2/ConversationViewModel.kt | 2 -- .../securesms/home/ConversationOptionsBottomSheet.kt | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 558e92b5ba..a8de83c29a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.Promise @@ -548,8 +549,14 @@ object MessageSender { JobQueue.shared.add(job) // if we are sending a 'Note to Self' make sure it is not hidden - if(address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey()){ + if( message is VisibleMessage && + address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && + // only show the NTW if it is currently marked as hidden + MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } + ){ + // make sure note to self is not hidden MessagingModuleConfiguration.shared.preferences.setHasHiddenNoteToSelf(false) + // update config in case it was marked as hidden there MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { it.userProfile.setNtsPriority(PRIORITY_VISIBLE) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 98489b1519..788b8cd7fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -718,8 +718,6 @@ class ConversationViewModel( private fun markAsDeletedForEveryone( data: DeleteForEveryoneDialogData ) = viewModelScope.launch { - val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") - // make sure to stop audio messages, if any data.messages.filterIsInstance() .mapNotNull { it.slideDeck.audioSlide } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 8e4f4099a9..cccb83d525 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -131,7 +131,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto recipient.isLocalNumber -> { text = context.getString(R.string.hide) contentDescription = context.getString(R.string.AccessibilityId_clear) - drawableStartRes = R.drawable.ic_trash_2 + drawableStartRes = R.drawable.ic_eye_off } // 1on1 From f32951f6562a357137b8b957965f4d45a9d92bb6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 20 May 2025 16:31:35 +1000 Subject: [PATCH 320/867] typo --- .../libsession/messaging/sending_receiving/MessageSender.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index a8de83c29a..9f6e778961 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -551,7 +551,7 @@ object MessageSender { // if we are sending a 'Note to Self' make sure it is not hidden if( message is VisibleMessage && address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && - // only show the NTW if it is currently marked as hidden + // only show the NTS if it is currently marked as hidden MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } ){ // make sure note to self is not hidden From 9699d938606632a8ae66ad07c710d0ddc5a667b4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 20 May 2025 16:36:25 +1000 Subject: [PATCH 321/867] todos handled --- .../v2/settings/ConversationSettingsViewModel.kt | 2 +- .../notification/NotificationSettingsViewModel.kt | 9 +++------ .../java/org/thoughtcrime/securesms/util/DateUtils.kt | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index d32869c50d..b40bb3693a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -193,7 +193,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } - conversation.isCommunityRecipient -> { //todo UCS currently this property is null for existing communities and is never updated if the community was already added before caring for the description + conversation.isCommunityRecipient -> { ( community?.description to // description context.getString(R.string.qa_conversation_settings_description_community) // description qa tag diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index af70aedacd..ed84594ce0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OptionsCardData import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.DateUtils import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -42,6 +43,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( @ApplicationContext private val context: Context, private val recipientDatabase: RecipientDatabase, private val repository: ConversationRepository, + private val dateUtils: DateUtils, ) : ViewModel() { private var thread: Recipient? = null @@ -173,13 +175,8 @@ class NotificationSettingsViewModel @AssistedInject constructor( } } - //todo UCS update with date utils functions once we have the code from the network page private fun formatTime(timestamp: Long): String{ - val formatter = DateTimeFormatter.ofPattern("HH:mm dd/MM/yy") - - return Instant.ofEpochMilli(timestamp) - .atZone(ZoneId.systemDefault()) - .format(formatter) + return dateUtils.formatTime(timestamp, "HH:mm dd/MM/yy") } private fun shouldEnableSetButton(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index f88141e23c..a693998ad3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -104,7 +104,7 @@ class DateUtils @Inject constructor( } // Format a given timestamp with a specific pattern - private fun formatTime(timestamp: Long, pattern: String, locale: Locale = Locale.getDefault()): String { + fun formatTime(timestamp: Long, pattern: String, locale: Locale = Locale.getDefault()): String { val formatter = DateTimeFormatter.ofPattern(pattern, locale) return Instant.ofEpochMilli(timestamp) From 0d55bee5110881cc99cb0ca47363358ec3e12155 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 21 May 2025 09:58:12 +1000 Subject: [PATCH 322/867] Fix missing table column (#1184) --- .../org/thoughtcrime/securesms/database/LokiMessageDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index bf32c48518..4176a467d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -41,7 +41,7 @@ class LokiMessageDatabase(context: Context, helper: Provider Date: Wed, 21 May 2025 10:12:24 +1000 Subject: [PATCH 323/867] Cleaning up old menu code --- .../conversation/v2/ConversationActivityV2.kt | 24 +- .../v2/ConversationReactionOverlay.kt | 9 +- .../v2/menus/ConversationMenuHelper.kt | 472 ------------------ .../v2/menus/ConversationMenuItemHelper.kt | 17 - .../settings/ConversationSettingsViewModel.kt | 2 +- .../home/ConversationOptionsBottomSheet.kt | 6 +- .../securesms/home/HomeActivity.kt | 94 +++- .../securesms/home/HomeViewModel.kt | 10 +- app/src/main/res/menu/menu_conversation.xml | 22 - .../main/res/menu/menu_conversation_block.xml | 10 - .../menu_conversation_copy_account_id.xml | 10 - .../res/menu/menu_conversation_expiration.xml | 10 - .../res/menu/menu_conversation_groups_v2.xml | 18 - .../menu_conversation_groups_v2_admin.xml | 18 - .../menu/menu_conversation_legacy_group.xml | 18 - .../main/res/menu/menu_conversation_muted.xml | 9 - ...enu_conversation_notification_settings.xml | 7 - .../res/menu/menu_conversation_open_group.xml | 13 - .../res/menu/menu_conversation_unblock.xml | 9 - .../res/menu/menu_conversation_unmuted.xml | 10 - 20 files changed, 110 insertions(+), 678 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt delete mode 100644 app/src/main/res/menu/menu_conversation.xml delete mode 100644 app/src/main/res/menu/menu_conversation_block.xml delete mode 100644 app/src/main/res/menu/menu_conversation_copy_account_id.xml delete mode 100644 app/src/main/res/menu/menu_conversation_expiration.xml delete mode 100644 app/src/main/res/menu/menu_conversation_groups_v2.xml delete mode 100644 app/src/main/res/menu/menu_conversation_groups_v2_admin.xml delete mode 100644 app/src/main/res/menu/menu_conversation_legacy_group.xml delete mode 100644 app/src/main/res/menu/menu_conversation_muted.xml delete mode 100644 app/src/main/res/menu/menu_conversation_notification_settings.xml delete mode 100644 app/src/main/res/menu/menu_conversation_open_group.xml delete mode 100644 app/src/main/res/menu/menu_conversation_unblock.xml delete mode 100644 app/src/main/res/menu/menu_conversation_unmuted.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index c3130c3370..4cf10e5800 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Resources import android.database.Cursor -import android.graphics.Color import android.graphics.Rect import android.graphics.Typeface import android.net.Uri @@ -40,7 +39,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat -import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -140,7 +138,6 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCand import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate @@ -238,7 +235,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, - ConversationMenuHelper.ConversationMenuListener, UserDetailsBottomSheet.UserDetailsBottomSheetCallback { + UserDetailsBottomSheet.UserDetailsBottomSheetCallback { private lateinit var binding: ActivityConversationV2Binding @@ -1515,25 +1512,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - override fun copyAccountID(accountId: String) { - val clip = ClipData.newPlainText("Account ID", accountId) - val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() - } - - override fun copyOpenGroupUrl(thread: Recipient) { - if (!thread.isCommunityRecipient) { return } - - val threadId = threadDb.getThreadIdIfExistsFor(thread) - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return - - val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) - val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() - } - override fun unblockUserFromInput() { unblock() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index ead075e379..88b7beff31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.TextSecurePreferences @@ -51,7 +52,6 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.DateUtils @@ -639,6 +640,12 @@ class ConversationReactionOverlay : FrameLayout { return items } + private fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String, blindedPublicKey: String?): Boolean { + if (openGroup == null) return false + if (message.isOutgoing) return false // Users can't ban themselves + return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) + } + private fun handleActionItemClicked(action: Action) { hideInternal(object : OnHideListener { override fun startHide() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt deleted file mode 100644 index fa706e9ab8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ /dev/null @@ -1,472 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.menus - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.os.AsyncTask -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.widget.Toast -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import com.squareup.phrase.Phrase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.leave -import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.wasKickedFromGroupV2 -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional -import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.ShortcutLauncherActivity -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity -import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity.Companion.groupIDKey -import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.util.BitmapUtil -import java.io.IOException - -object ConversationMenuHelper { - - fun onPrepareOptionsMenu( - menu: Menu, - inflater: MenuInflater, - thread: Recipient, - context: Context, - configFactory: ConfigFactory, - deprecationManager: LegacyGroupDeprecationManager, - ) { - val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient && - deprecationManager.isDeprecated - - // Prepare - menu.clear() - val isCommunity = thread.isCommunityRecipient - // Base menu (options that should always be present) - inflater.inflate(R.menu.menu_conversation, menu) - menu.findItem(R.id.menu_add_shortcut).isVisible = !isDeprecatedLegacyGroup - - // Expiring messages - if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber) - && !isDeprecatedLegacyGroup) { - inflater.inflate(R.menu.menu_conversation_expiration, menu) - } - // One-on-one chat menu allows copying the account id - if (thread.isContactRecipient) { - inflater.inflate(R.menu.menu_conversation_copy_account_id, menu) - } - // One-on-one chat menu (options that should only be present for one-on-one chats) - /* if (thread.isContactRecipient) { - if (thread.isBlocked) { - inflater.inflate(R.menu.menu_conversation_unblock, menu) - } else if (!thread.isLocalNumber) { - inflater.inflate(R.menu.menu_conversation_block, menu) - } - }*/ - // (Legacy) Closed group menu (options that should only be present in closed groups) - if (thread.isLegacyGroupRecipient) { - inflater.inflate(R.menu.menu_conversation_legacy_group, menu) - - menu.findItem(R.id.menu_edit_group).isVisible = !isDeprecatedLegacyGroup - } - - // Groups v2 menu - if (thread.isGroupV2Recipient) { - val hasAdminKey = configFactory.withUserConfigs { it.userGroups.getClosedGroup(thread.address.toString())?.hasAdminKey() } - if (hasAdminKey == true) { - inflater.inflate(R.menu.menu_conversation_groups_v2_admin, menu) - } else { - inflater.inflate(R.menu.menu_conversation_groups_v2, menu) - } - - // If the current user was kicked from the group - // the menu should say 'Delete' instead of 'Leave' - if (configFactory.wasKickedFromGroupV2(thread)) { - menu.findItem(R.id.menu_leave_group).title = context.getString(R.string.groupDelete) - } - } - - // Open group menu - if (isCommunity) { - inflater.inflate(R.menu.menu_conversation_open_group, menu) - } - // Muting - if (!isDeprecatedLegacyGroup) { - if (thread.isMuted) { - inflater.inflate(R.menu.menu_conversation_muted, menu) - } else { - inflater.inflate(R.menu.menu_conversation_unmuted, menu) - } - } - - if (thread.isGroupOrCommunityRecipient && !thread.isMuted && !isDeprecatedLegacyGroup) { - inflater.inflate(R.menu.menu_conversation_notification_settings, menu) - } - - /*if (thread.showCallMenu()) { - inflater.inflate(R.menu.menu_conversation_call, menu) - }*/ - - // Search - /*val searchViewItem = menu.findItem(R.id.menu_search) - (context as ConversationActivityV2).searchViewItem = searchViewItem - val searchView = searchViewItem.actionView as SearchView - val queryListener = object : OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - return true - } - - override fun onQueryTextChange(query: String): Boolean { - context.onSearchQueryUpdated(query) - return true - } - } - searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - searchView.setOnQueryTextListener(queryListener) - context.onSearchOpened() - for (i in 0 until menu.size()) { - if (menu.getItem(i) != searchViewItem) { - menu.getItem(i).isVisible = false - } - } - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - searchView.setOnQueryTextListener(null) - context.onSearchClosed() - return true - } - })*/ - } - - /** - * Handle the selected option - * - * @return An asynchronous channel that can be used to wait for the action to complete. Null if - * the action does not require waiting. - */ - fun onOptionItemSelected( - context: Context, - item: MenuItem, - thread: Recipient, - threadID: Long, - factory: ConfigFactory, - storage: StorageProtocol, - groupManager: GroupManagerV2, - deprecationManager: LegacyGroupDeprecationManager, - ): ReceiveChannel? { - when (item.itemId) { - R.id.menu_view_all_media -> { showAllMedia(context, thread) } - // R.id.menu_search -> { search(context) } - R.id.menu_add_shortcut -> { addShortcut(context, thread) } - //R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } - /* R.id.menu_unblock -> { unblock(context, thread) } - R.id.menu_block -> { block(context, thread, deleteThread = false) }*/ - R.id.menu_copy_account_id -> { copyAccountID(context, thread) } - R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } - R.id.menu_edit_group -> { editGroup(context, thread) } - R.id.menu_group_members -> { showGroupMembers(context, thread) } - /* R.id.menu_leave_group -> { return leaveGroup( - context, thread, threadID, factory, storage, groupManager, deprecationManager - ) }*/ -// R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } -// R.id.menu_unmute_notifications -> { unmute(context, thread) } -// R.id.menu_mute_notifications -> { mute(context, thread) } -// R.id.menu_notification_settings -> { setNotifyType(context, thread) } - } - - return null - } - - private fun showAllMedia(context: Context, thread: Recipient) { -// val activity = context as AppCompatActivity -// activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address)) - } - -// private fun search(context: Context) { -// val searchViewModel = (context as ConversationActivityV2).searchViewModel -// searchViewModel.onSearchOpened() -// } - - @SuppressLint("StaticFieldLeak") - private fun addShortcut(context: Context, thread: Recipient) { - object : AsyncTask() { - - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg params: Void?): IconCompat? { - var icon: IconCompat? = null - val contactPhoto = thread.contactPhoto - if (contactPhoto != null) { - try { - var bitmap = BitmapFactory.decodeStream(contactPhoto.openInputStream(context)) - bitmap = BitmapUtil.createScaledBitmap(bitmap, 300, 300) - icon = IconCompat.createWithAdaptiveBitmap(bitmap) - } catch (e: IOException) { - // Do nothing - } - } - if (icon == null) { - icon = IconCompat.createWithResource(context, if (thread.isGroupOrCommunityRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut) - } - return icon - } - - @Deprecated("Deprecated in Java") - override fun onPostExecute(icon: IconCompat?) { - val name = Optional.fromNullable(thread.name) - .or(Optional.fromNullable(thread.profileName)) - .or(thread.name) - - val shortcutInfo = ShortcutInfoCompat.Builder(context, thread.address.toString() + '-' + System.currentTimeMillis()) - .setShortLabel(name) - .setIcon(icon) - .setIntent(ShortcutLauncherActivity.createIntent(context, thread.address)) - .build() - if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) { - Toast.makeText(context, context.resources.getString(R.string.conversationsAddedToHome), Toast.LENGTH_LONG).show() - } - } - }.execute() - } - - /* private fun showDisappearingMessages(context: Context, thread: Recipient) { - val listener = context as? ConversationMenuListener ?: return - listener.showDisappearingMessages(thread) - }*/ - - /* private fun unblock(context: Context, thread: Recipient) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.unblock() - } - - private fun block(context: Context, thread: Recipient, deleteThread: Boolean) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.block(deleteThread) - }*/ - - private fun copyAccountID(context: Context, thread: Recipient) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.copyAccountID(thread.address.toString()) - } - - private fun copyOpenGroupUrl(context: Context, thread: Recipient) { - if (!thread.isCommunityRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.copyOpenGroupUrl(thread) - } - - private fun editGroup(context: Context, thread: Recipient) { - when { - thread.isGroupV2Recipient -> { - //context.startActivity(EditGroupActivity.createIntent(context, thread.address.toString())) - } - - thread.isLegacyGroupRecipient -> { - val intent = Intent(context, EditLegacyGroupActivity::class.java) - val groupID: String = thread.address.toGroupString() - intent.putExtra(groupIDKey, groupID) - context.startActivity(intent) - } - } - } - - - private fun showGroupMembers(context: Context, thread: Recipient) { - // context.startActivity(GroupMembersActivity.createIntent(context, thread.address.toString())) - } - - enum class GroupLeavingStatus { - Leaving, - Left, - Error, - } - - fun leaveGroup( - context: Context, - thread: Recipient, - threadID: Long, - configFactory: ConfigFactory, - storage: StorageProtocol, - groupManager: GroupManagerV2, - deprecationManager: LegacyGroupDeprecationManager, - ): ReceiveChannel? { - val channel = Channel() - - when { - thread.isLegacyGroupRecipient -> { - val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() - - // we do not want admin related messaging once legacy groups are deprecated - val isGroupAdmin = if(deprecationManager.isDeprecated){ - false - } else { // prior to the deprecated state, calculate admin rights properly - val admins = group.admins - val accountID = TextSecurePreferences.getLocalNumber(context) - admins.any { it.toString() == accountID } - } - - confirmAndLeaveGroup( - context = context, - groupName = group.title, - isAdmin = isGroupAdmin, - isKicked = false, - isDestroyed = false, - threadID = threadID, - storage = storage, - doLeave = { - val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() - - check(DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)) { - "Invalid group public key" - } - try { - channel.trySend(GroupLeavingStatus.Leaving) - MessageSender.leave(groupPublicKey) - channel.trySend(GroupLeavingStatus.Left) - } catch (e: Exception) { - channel.trySend(GroupLeavingStatus.Error) - throw e - } - } - ) - } - - thread.isGroupV2Recipient -> { - val accountId = AccountId(thread.address.toString()) - val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return null - val name = configFactory.withGroupConfigs(accountId) { - it.groupInfo.getName() - } ?: group.name - - confirmAndLeaveGroup( - context = context, - groupName = name, - isAdmin = group.hasAdminKey(), - isKicked = configFactory.wasKickedFromGroupV2(thread), - isDestroyed = group.destroyed, - threadID = threadID, - storage = storage, - doLeave = { - try { - channel.trySend(GroupLeavingStatus.Leaving) - groupManager.leaveGroup(accountId) - channel.trySend(GroupLeavingStatus.Left) - } catch (e: Exception) { - channel.trySend(GroupLeavingStatus.Error) - throw e - } - } - ) - - return channel - } - } - - return null - } - - private fun confirmAndLeaveGroup( - context: Context, - groupName: String, - isAdmin: Boolean, - isKicked: Boolean, - isDestroyed: Boolean, - threadID: Long, - storage: StorageProtocol, - doLeave: suspend () -> Unit, - ) { - var title = R.string.groupLeave - var message: CharSequence = "" - var positiveButton = R.string.leave - - if(isKicked || isDestroyed){ - message = Phrase.from(context, R.string.groupDeleteDescriptionMember) - .put(GROUP_NAME_KEY, groupName) - .format() - - title = R.string.groupDelete - positiveButton = R.string.delete - } else if (isAdmin) { - message = Phrase.from(context, R.string.groupLeaveDescriptionAdmin) - .put(GROUP_NAME_KEY, groupName) - .format() - } else { - message = Phrase.from(context, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, groupName) - .format() - } - - fun onLeaveFailed() { - val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) - .put(GROUP_NAME_KEY, groupName) - .format().toString() - Toast.makeText(context, txt, Toast.LENGTH_LONG).show() - } - - context.showSessionDialog { - title(title) - text(message) - dangerButton(positiveButton) { - GlobalScope.launch(Dispatchers.Default) { - try { - // Cancel any outstanding jobs - storage.cancelPendingMessageSendJobs(threadID) - - doLeave() - } catch (e: Exception) { - Log.e("Conversation", "Error leaving group", e) - withContext(Dispatchers.Main) { - onLeaveFailed() - } - } - } - - } - button(R.string.cancel) - } - } - -/* private fun unmute(context: Context, thread: Recipient) { - DatabaseComponent.get(context).recipientDatabase().setMuted(thread, 0) - } - - private fun mute(context: Context, thread: Recipient) { - showMuteDialog(ContextThemeWrapper(context, context.theme)) { until: Long -> - DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until) - } - } - - private fun setNotifyType(context: Context, thread: Recipient) { - NotificationUtils.showNotifyDialog(context, thread) { notifyType -> - DatabaseComponent.get(context).recipientDatabase().setNotifyType(thread, notifyType) - } - }*/ - - interface ConversationMenuListener { - fun copyAccountID(accountId: String) - fun copyOpenGroupUrl(thread: Recipient) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt deleted file mode 100644 index 3356453596..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.menus - -import android.content.Context -import org.session.libsession.messaging.open_groups.OpenGroup -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.groups.OpenGroupManager - -object ConversationMenuItemHelper { - - @JvmStatic - fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String, blindedPublicKey: String?): Boolean { - if (openGroup == null) return false - if (message.isOutgoing) return false // Users can't ban themselves - return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index b40bb3693a..172f5948bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -1312,7 +1312,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } ) - } + } //todo UCS it seems this can crash when going to the settings right after creating a group? private val optionInviteMembers: OptionsItem by lazy{ OptionsItem( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 8e4f4099a9..25b31e5cc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -113,10 +113,12 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto when { // groups and communities recipient.isGroupOrCommunityRecipient -> { + val accountId = AccountId(recipient.address.toString()) + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return // if you are in a group V2 and have been kicked of that group, or the group was destroyed, + // or if the user is an admin // the button should read 'Delete' instead of 'Leave' - if (configFactory.wasKickedFromGroupV2(recipient) || - configFactory.isGroupDestroyed(recipient)) { + if (!group.shouldPoll || group.hasAdminKey()) { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) drawableStartRes = R.drawable.ic_trash_2 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 5254214fce..c8833faf92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -26,6 +26,7 @@ import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull @@ -38,6 +39,7 @@ import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue @@ -49,12 +51,13 @@ import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.wasKickedFromGroupV2 +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.start.StartConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase @@ -668,14 +671,31 @@ class HomeActivity : ScreenLockActionBarActivity(), val recipient = thread.recipient if (recipient.isGroupV2Recipient) { - ConversationMenuHelper.leaveGroup( - context = this, - thread = recipient, + val accountId = AccountId(recipient.address.toString()) + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return + val name = configFactory.withGroupConfigs(accountId) { + it.groupInfo.getName() + } ?: group.name + + confirmAndLeaveGroup( + groupName = name, + isAdmin = group.hasAdminKey(), + isKicked = configFactory.wasKickedFromGroupV2(recipient), + isDestroyed = group.destroyed, threadID = threadID, - configFactory = configFactory, storage = storage, - groupManager = groupManagerV2, - deprecationManager = deprecationManager + doLeave = { + try { + //todo UCS are we missing this feature from the UCS group leave? + //FIX ISSUES AND ADD BACK FEATURE BELOW BUT ALSO THE CHANNEL CODE HERE AND IN THE UCS MENU + // channel.trySend(GroupLeavingStatus.Leaving) + homeViewModel.leaveGroup(accountId) + //channel.trySend(GroupLeavingStatus.Left) + } catch (e: Exception) { + //channel.trySend(GroupLeavingStatus.Error) + throw e + } + } ) return @@ -766,6 +786,66 @@ class HomeActivity : ScreenLockActionBarActivity(), } } + private fun confirmAndLeaveGroup( + groupName: String, + isAdmin: Boolean, + isKicked: Boolean, + isDestroyed: Boolean, + threadID: Long, + storage: StorageProtocol, + doLeave: suspend () -> Unit, + ) { + var title = R.string.groupLeave + var message: CharSequence = "" + var positiveButton = R.string.leave + + if(isKicked || isDestroyed){ + message = Phrase.from(this, R.string.groupDeleteDescriptionMember) + .put(GROUP_NAME_KEY, groupName) + .format() + + title = R.string.groupDelete + positiveButton = R.string.delete + } else if (isAdmin) { + message = Phrase.from(this, R.string.groupLeaveDescriptionAdmin) + .put(GROUP_NAME_KEY, groupName) + .format() + } else { + message = Phrase.from(this, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, groupName) + .format() + } + + fun onLeaveFailed() { + val txt = Phrase.from(this, R.string.groupLeaveErrorFailed) + .put(GROUP_NAME_KEY, groupName) + .format().toString() + Toast.makeText(this, txt, Toast.LENGTH_LONG).show() + } + + showSessionDialog { + title(title) + text(message) + dangerButton(positiveButton) { + GlobalScope.launch(Dispatchers.Default) { + try { + // Cancel any outstanding jobs + storage.cancelPendingMessageSendJobs(threadID) + + doLeave() + } catch (e: Exception) { + Log.e("Conversation", "Error leaving group", e) + withContext(Dispatchers.Main) { + onLeaveFailed() + } + } + } + + } + button(R.string.cancel) + } + } + private fun openSettings() { val intent = Intent(this, SettingsActivity::class.java) show(intent, isForResult = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index eb4c71377a..672966d345 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY @@ -65,7 +66,8 @@ class HomeViewModel @Inject constructor( private val configFactory: ConfigFactory, private val callManager: CallManager, private val usernameUtils: UsernameUtils, - private val storage: StorageProtocol + private val storage: StorageProtocol, + private val groupManager: GroupManagerV2 ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -227,6 +229,12 @@ class HomeViewModel @Inject constructor( } } + fun leaveGroup(accountId: AccountId) { + viewModelScope.launch(Dispatchers.Default) { + groupManager.leaveGroup(accountId) + } + } + companion object { private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L } diff --git a/app/src/main/res/menu/menu_conversation.xml b/app/src/main/res/menu/menu_conversation.xml deleted file mode 100644 index a48fc90d13..0000000000 --- a/app/src/main/res/menu/menu_conversation.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_conversation_block.xml b/app/src/main/res/menu/menu_conversation_block.xml deleted file mode 100644 index 266ddbcb4c..0000000000 --- a/app/src/main/res/menu/menu_conversation_block.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_copy_account_id.xml b/app/src/main/res/menu/menu_conversation_copy_account_id.xml deleted file mode 100644 index 3ceed5b16b..0000000000 --- a/app/src/main/res/menu/menu_conversation_copy_account_id.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_expiration.xml b/app/src/main/res/menu/menu_conversation_expiration.xml deleted file mode 100644 index 004f732459..0000000000 --- a/app/src/main/res/menu/menu_conversation_expiration.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_groups_v2.xml b/app/src/main/res/menu/menu_conversation_groups_v2.xml deleted file mode 100644 index 5391c24cdf..0000000000 --- a/app/src/main/res/menu/menu_conversation_groups_v2.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml b/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml deleted file mode 100644 index fb6bc6da92..0000000000 --- a/app/src/main/res/menu/menu_conversation_groups_v2_admin.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_legacy_group.xml b/app/src/main/res/menu/menu_conversation_legacy_group.xml deleted file mode 100644 index fb6bc6da92..0000000000 --- a/app/src/main/res/menu/menu_conversation_legacy_group.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_muted.xml b/app/src/main/res/menu/menu_conversation_muted.xml deleted file mode 100644 index 35f486ec31..0000000000 --- a/app/src/main/res/menu/menu_conversation_muted.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_notification_settings.xml b/app/src/main/res/menu/menu_conversation_notification_settings.xml deleted file mode 100644 index 5fdcd7fac8..0000000000 --- a/app/src/main/res/menu/menu_conversation_notification_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_open_group.xml b/app/src/main/res/menu/menu_conversation_open_group.xml deleted file mode 100644 index ac31c1b4f1..0000000000 --- a/app/src/main/res/menu/menu_conversation_open_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_unblock.xml b/app/src/main/res/menu/menu_conversation_unblock.xml deleted file mode 100644 index b4d9eaa906..0000000000 --- a/app/src/main/res/menu/menu_conversation_unblock.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_unmuted.xml b/app/src/main/res/menu/menu_conversation_unmuted.xml deleted file mode 100644 index dc529f5e1d..0000000000 --- a/app/src/main/res/menu/menu_conversation_unmuted.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file From 0589e2f14a8e56a7ceb32fb804c3d664ed01f901 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 10:59:37 +1000 Subject: [PATCH 324/867] Sharing logic for group leave confirmation --- .../messaging/groups/GroupManagerV2.kt | 13 +++++ .../settings/ConversationSettingsViewModel.kt | 49 ++++------------- .../securesms/groups/GroupManagerV2Impl.kt | 43 +++++++++++++++ .../securesms/home/HomeActivity.kt | 54 +++++-------------- 4 files changed, 81 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index dff1063ee3..65499fb722 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -1,10 +1,12 @@ package org.session.libsession.messaging.groups +import androidx.annotation.StringRes import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.GroupManagerV2Impl /** * Business logic handling group v2 operations like inviting members, @@ -115,4 +117,15 @@ interface GroupManagerV2 { * Should be called whenever a group invite is blocked */ fun onBlocked(groupAccountId: AccountId) + + fun getLeaveGroupConfirmationDialogData(groupId: AccountId): ConfirmDialogData? + + data class ConfirmDialogData( + val title: String, + val message: CharSequence, + @StringRes val positiveText: Int, + @StringRes val negativeText: Int, + @StringRes val positiveQaTag: Int?, + @StringRes val negativeQaTag: Int?, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 172f5948bd..4ddbd72151 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -810,45 +810,18 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun confirmLeaveGroup(){ val groupData = groupV2 ?: return - _dialogState.update { - - var title = R.string.groupDelete - var message: CharSequence = "" - var positiveButton = R.string.delete - var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm - var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel - - val groupName = _uiState.value.name - - if(!groupData.shouldPoll){ - message = Phrase.from(context, R.string.groupDeleteDescriptionMember) - .put(GROUP_NAME_KEY, groupName) - .format() - - } else if (groupData.hasAdminKey()) { - message = Phrase.from(context, R.string.groupLeaveDescriptionAdmin) - .put(GROUP_NAME_KEY, groupName) - .format() - } else { - message = Phrase.from(context, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, groupName) - .format() - - title = R.string.groupLeave - positiveButton = R.string.leave - positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm - negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel - } - + _dialogState.update { state -> + val dialogData = groupManager.getLeaveGroupConfirmationDialogData(AccountId(groupData.groupAccountId)) + ?: return - it.copy( + state.copy( showSimpleDialog = Dialog( - title = context.getString(title), - message = message, - positiveText = context.getString(positiveButton), - negativeText = context.getString(R.string.cancel), - positiveQaTag = context.getString(positiveQaTag), - negativeQaTag = context.getString(negativeQaTag), + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, onPositive = ::leaveGroup, onNegative = {} ) @@ -1312,7 +1285,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } ) - } //todo UCS it seems this can crash when going to the settings right after creating a group? + } private val optionInviteMembers: OptionsItem by lazy{ OptionsItem( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index f84d9e60e5..8edacc7336 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context import com.google.protobuf.ByteString +import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -44,6 +45,7 @@ import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.waitUntilGroupConfigsPushed @@ -1220,6 +1222,47 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(message, groupId) } + override fun getLeaveGroupConfirmationDialogData(groupId: AccountId): GroupManagerV2.ConfirmDialogData? { + val groupData = configFactory.getGroup(groupId) ?: return null + + var title = R.string.groupDelete + var message: CharSequence = "" + var positiveButton = R.string.delete + var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel + + + if(!groupData.shouldPoll){ + message = Phrase.from(application, R.string.groupDeleteDescriptionMember) + .put(GROUP_NAME_KEY, groupData.name) + .format() + + } else if (groupData.hasAdminKey()) { + message = Phrase.from(application, R.string.groupLeaveDescriptionAdmin) + .put(GROUP_NAME_KEY, groupData.name) + .format() + } else { + message = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, groupData.name) + .format() + + title = R.string.groupLeave + positiveButton = R.string.leave + positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + } + + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = R.string.cancel, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + ) + } + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index c8833faf92..1d16e9fbf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -679,22 +679,11 @@ class HomeActivity : ScreenLockActionBarActivity(), confirmAndLeaveGroup( groupName = name, - isAdmin = group.hasAdminKey(), - isKicked = configFactory.wasKickedFromGroupV2(recipient), - isDestroyed = group.destroyed, + dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(accountId), threadID = threadID, storage = storage, doLeave = { - try { - //todo UCS are we missing this feature from the UCS group leave? - //FIX ISSUES AND ADD BACK FEATURE BELOW BUT ALSO THE CHANNEL CODE HERE AND IN THE UCS MENU - // channel.trySend(GroupLeavingStatus.Leaving) - homeViewModel.leaveGroup(accountId) - //channel.trySend(GroupLeavingStatus.Left) - } catch (e: Exception) { - //channel.trySend(GroupLeavingStatus.Error) - throw e - } + homeViewModel.leaveGroup(accountId) } ) @@ -788,33 +777,12 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun confirmAndLeaveGroup( groupName: String, - isAdmin: Boolean, - isKicked: Boolean, - isDestroyed: Boolean, + dialogData: GroupManagerV2.ConfirmDialogData?, threadID: Long, storage: StorageProtocol, doLeave: suspend () -> Unit, ) { - var title = R.string.groupLeave - var message: CharSequence = "" - var positiveButton = R.string.leave - - if(isKicked || isDestroyed){ - message = Phrase.from(this, R.string.groupDeleteDescriptionMember) - .put(GROUP_NAME_KEY, groupName) - .format() - - title = R.string.groupDelete - positiveButton = R.string.delete - } else if (isAdmin) { - message = Phrase.from(this, R.string.groupLeaveDescriptionAdmin) - .put(GROUP_NAME_KEY, groupName) - .format() - } else { - message = Phrase.from(this, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, groupName) - .format() - } + if (dialogData == null) return fun onLeaveFailed() { val txt = Phrase.from(this, R.string.groupLeaveErrorFailed) @@ -824,9 +792,12 @@ class HomeActivity : ScreenLockActionBarActivity(), } showSessionDialog { - title(title) - text(message) - dangerButton(positiveButton) { + title(dialogData.title) + text(dialogData.message) + dangerButton( + dialogData.positiveText, + contentDescriptionRes = dialogData.positiveQaTag ?: dialogData.positiveText + ) { GlobalScope.launch(Dispatchers.Default) { try { // Cancel any outstanding jobs @@ -842,7 +813,10 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - button(R.string.cancel) + button( + dialogData.negativeText, + contentDescriptionRes = dialogData.negativeQaTag ?: dialogData.negativeText + ) } } From e3a9436b7327216350a47b48aa25f12bf86b598a Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 21 May 2025 11:45:25 +1000 Subject: [PATCH 325/867] Fix reactions being put into wrong messages (#1185) --- .../database/MessageDataProvider.kt | 6 +- .../libsession/database/StorageProtocol.kt | 16 +++++- .../messaging/messages/visible/Reaction.kt | 10 +--- .../sending_receiving/MessageSender.kt | 6 +- .../ReceivedMessageHandler.kt | 32 +++++------ .../database/LokiMessageDatabaseProtocol.kt | 4 +- .../attachments/DatabaseAttachmentProvider.kt | 7 +-- .../conversation/v2/ConversationActivityV2.kt | 7 +-- .../v2/messages/EmojiReactionsView.kt | 29 +++++----- .../v2/messages/VisibleMessageView.kt | 2 +- .../securesms/database/LokiMessageDatabase.kt | 37 +++--------- .../securesms/database/ReactionDatabase.kt | 57 +++++++++---------- .../securesms/database/Storage.kt | 41 ++++++------- .../database/model/ReactionRecord.kt | 3 +- .../securesms/reactions/ReactionDetails.kt | 4 +- .../reactions/ReactionRecipientsAdapter.java | 15 +++-- .../reactions/ReactionsRepository.kt | 1 - 17 files changed, 124 insertions(+), 153 deletions(-) diff --git a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index c7f3704a09..b45bb898f9 100644 --- a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -18,11 +18,7 @@ import java.io.InputStream interface MessageDataProvider { - fun getMessageID(serverID: Long): Long? - /** - * @return pair of sms or mms table-specific ID and whether it is in SMS table - */ - fun getMessageID(serverId: Long, threadId: Long): Pair? + fun getMessageID(serverId: Long, threadId: Long): MessageId? fun getMessageIDs(serverIDs: List, threadID: Long): Pair, List> fun getUserMessageHashes(threadId: Long, userPubKey: String): List fun deleteMessage(messageID: Long, isSms: Boolean) diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index a23b333afe..f3e5e6c8ab 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -85,7 +85,7 @@ interface StorageProtocol { suspend fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean - fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) + fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) fun getOpenGroup(room: String, server: String): OpenGroup? fun setGroupMemberRoles(members: List) @@ -253,10 +253,20 @@ interface StorageProtocol { fun removeLastOutboxMessageId(server: String) fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping - fun addReaction(reaction: Reaction, threadId: Long, messageSender: String, notifyUnread: Boolean) + /** + * Add reaction to a message that has the timestamp given by [reaction]. This is less than + * ideal because timestamp it not a very good identifier for a message, but it is the best we can do + * if the swarm doesn't give us anything else. [threadId] will help narrow down the message. + */ + fun addReaction(threadId: Long, reaction: Reaction, messageSender: String, notifyUnread: Boolean) + + /** + * Add reaction to a specific message. This is preferable to the timestamp lookup. + */ + fun addReaction(messageId: MessageId, reaction: Reaction, messageSender: String, notifyUnread: Boolean) fun removeReaction(emoji: String, messageTimestamp: Long, threadId: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) - fun deleteReactions(messageId: Long, mms: Boolean) + fun deleteReactions(messageId: MessageId) fun deleteReactions(messageIds: List, mms: Boolean) fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean = false) fun setRecipientHash(recipient: Recipient, recipientHash: String?) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt index 2a34e883bd..6e6327b248 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt @@ -6,8 +6,6 @@ import org.session.libsignal.utilities.Log class Reaction() { var timestamp: Long? = 0 - var localId: Long? = 0 - var isMms: Boolean? = false var publicKey: String? = null var emoji: String? = null var react: Boolean? = true @@ -24,24 +22,22 @@ class Reaction() { companion object { const val TAG = "Quote" - fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction? { + fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction { val react = proto.action == Action.REACT return Reaction(publicKey = proto.author, emoji = proto.emoji, react = react, timestamp = proto.id, count = 1) } - fun from(timestamp: Long, author: String, emoji: String, react: Boolean): Reaction? { + fun from(timestamp: Long, author: String, emoji: String, react: Boolean): Reaction { return Reaction(author, emoji, react, timestamp) } } - internal constructor(publicKey: String, emoji: String, react: Boolean, timestamp: Long? = 0, localId: Long? = 0, isMms: Boolean? = false, serverId: String? = null, count: Long? = 0, index: Long? = 0) : this() { + internal constructor(publicKey: String, emoji: String, react: Boolean, timestamp: Long? = 0, serverId: String? = null, count: Long? = 0, index: Long? = 0) : this() { this.timestamp = timestamp this.publicKey = publicKey this.emoji = emoji this.react = react this.serverId = serverId - this.localId = localId - this.isMms = isMms this.count = count this.index = index } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 9f6e778961..5ac03c9d72 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -472,7 +472,11 @@ object MessageSender { val encoded = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) val communityThreadID = storage.getThreadId(Address.fromSerialized(encoded)) if (communityThreadID != null && communityThreadID >= 0) { - storage.setOpenGroupServerMessageID(messageId.id, message.openGroupServerMessageID!!, communityThreadID, !messageId.mms) + storage.setOpenGroupServerMessageID( + messageID = messageId, + serverID = message.openGroupServerMessageID!!, + threadID = communityThreadID + ) } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index caa2c52d8c..66db63f525 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -4,9 +4,9 @@ import android.text.TextUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.Sodium -import network.loki.messenger.R import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration @@ -18,11 +18,11 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration.Companion.isNewConfigEnabled import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage -import org.session.libsession.messaging.messages.control.LegacyGroupControlMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.LegacyGroupControlMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.messages.control.TypingIndicator @@ -50,8 +50,8 @@ import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.getType import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey @@ -322,7 +322,7 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): MessageId? { } // delete reactions - storage.deleteReactions(messageId = messageToDelete.id, mms = messageToDelete.isMms) + storage.deleteReactions(messageToDelete.messageId) // update notification if (!messageToDelete.isOutgoing) { @@ -482,8 +482,8 @@ fun MessageReceiver.handleVisibleMessage( reaction.dateSent = message.sentTimestamp ?: 0 reaction.dateReceived = message.receivedTimestamp ?: 0 storage.addReaction( - reaction = reaction, threadId = threadId, + reaction = reaction, messageSender = messageSender, notifyUnread = !threadIsGroup ) @@ -520,7 +520,11 @@ fun MessageReceiver.handleVisibleMessage( } } message.openGroupServerMessageID?.let { - storage.setOpenGroupServerMessageID(messageID.id, it, threadID, messageID.mms) + storage.setOpenGroupServerMessageID( + messageID = messageID, + serverID = it, + threadID = threadID + ) } SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message) return messageID @@ -535,8 +539,8 @@ fun MessageReceiver.handleOpenGroupReactions( ) { if (reactions.isNullOrEmpty()) return val storage = MessagingModuleConfiguration.shared.storage - val (messageId, isSms) = MessagingModuleConfiguration.shared.messageDataProvider.getMessageID(openGroupMessageServerID, threadId) ?: return - storage.deleteReactions(messageId, !isSms) + val messageId = MessagingModuleConfiguration.shared.messageDataProvider.getMessageID(openGroupMessageServerID, threadId) ?: return + storage.deleteReactions(messageId) val userPublicKey = storage.getUserPublicKey()!! val openGroup = storage.getOpenGroup(threadId) val blindedPublicKey = openGroup?.publicKey?.let { serverPublicKey -> @@ -554,9 +558,8 @@ fun MessageReceiver.handleOpenGroupReactions( // Add the first reaction (with the count) reactorIds.firstOrNull()?.let { reactor -> storage.addReaction( + messageId = messageId, reaction = Reaction( - localId = messageId, - isMms = !isSms, publicKey = reactor, emoji = emoji, react = true, @@ -564,7 +567,6 @@ fun MessageReceiver.handleOpenGroupReactions( count = count, index = reaction.index ), - threadId = threadId, messageSender = reactor, notifyUnread = false ) @@ -575,9 +577,8 @@ fun MessageReceiver.handleOpenGroupReactions( val lastIndex = min(maxAllowed, reactorIds.size) reactorIds.slice(1 until lastIndex).map { reactor -> storage.addReaction( + messageId = messageId, reaction = Reaction( - localId = messageId, - isMms = !isSms, publicKey = reactor, emoji = emoji, react = true, @@ -585,7 +586,6 @@ fun MessageReceiver.handleOpenGroupReactions( count = 0, // Only want this on the first reaction index = reaction.index ), - threadId = threadId, messageSender = reactor, notifyUnread = false ) @@ -594,9 +594,8 @@ fun MessageReceiver.handleOpenGroupReactions( // Add the current user reaction (if applicable and not already included) if (shouldAddUserReaction) { storage.addReaction( + messageId = messageId, reaction = Reaction( - localId = messageId, - isMms = !isSms, publicKey = userPublicKey, emoji = emoji, react = true, @@ -604,7 +603,6 @@ fun MessageReceiver.handleOpenGroupReactions( count = 1, index = reaction.index ), - threadId = threadId, messageSender = userPublicKey, notifyUnread = false ) diff --git a/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt index 633471bf5e..542acb3680 100644 --- a/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt @@ -1,6 +1,8 @@ package org.session.libsignal.database +import org.thoughtcrime.securesms.database.model.MessageId + interface LokiMessageDatabaseProtocol { - fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) + fun setServerID(messageID: MessageId, serverID: Long) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index a76ab5275d..991e8e089e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -178,12 +178,7 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider? { + override fun getMessageID(serverId: Long, threadId: Long): MessageId? { val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() return messageDB.getMessageID(serverId, threadId) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index c3130c3370..7d11a0b45a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Resources import android.database.Cursor -import android.graphics.Color import android.graphics.Rect import android.graphics.Typeface import android.net.Uri @@ -40,7 +39,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat -import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -1722,15 +1720,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } else { // Put the message in the database val reaction = ReactionRecord( - messageId = originalMessage.id, - isMms = originalMessage.isMms, + messageId = originalMessage.messageId, author = author, emoji = emoji, count = 1, dateSent = emojiTimestamp, dateReceived = emojiTimestamp ) - reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) + reactionDb.addReaction(reaction, false) val originalAuthor = if (originalMessage.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt index 27714fbc05..9c7df9dd4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt @@ -38,7 +38,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { // Normally 6dp, but we have 1dp left+right margin on the pills themselves private val OUTER_MARGIN = ViewUtil.dpToPx(2) private var records: MutableList? = null - private var messageId: Long = 0 + private var messageId: MessageId? = null private var delegate: VisibleMessageViewDelegate? = null private val gestureHandler = Handler(Looper.getMainLooper()) private var pressCallback: Runnable? = null @@ -66,7 +66,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { binding.layoutEmojiContainer.removeAllViews() } - fun setReactions(messageId: Long, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { + fun setReactions(messageId: MessageId, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { this.delegate = delegate if (records == this.records) { return @@ -79,22 +79,22 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { extended = false } this.messageId = messageId - displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) + displayReactions(messageId, if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) } override fun onTouch(v: View, event: MotionEvent): Boolean { if (v.tag == null) return false val reaction = v.tag as Reaction val action = event.action - if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms), reaction.emoji) + if (action == MotionEvent.ACTION_DOWN) onDown(reaction.messageId, reaction.emoji) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction) return true } - private fun displayReactions(threshold: Int) { + private fun displayReactions(messageId: MessageId, threshold: Int) { val userPublicKey = getLocalNumber(context) - val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold) + val reactions = buildSortedReactionsList(messageId, records!!, userPublicKey, threshold) binding.layoutEmojiContainer.removeAllViews() val overflowContainer = LinearLayout(context) overflowContainer.orientation = LinearLayout.HORIZONTAL @@ -111,7 +111,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val pill = buildPill(context, this, reaction, true) pill.setOnClickListener { v: View? -> extended = true - displayReactions(Int.MAX_VALUE) + displayReactions(messageId, Int.MAX_VALUE) } pill.findViewById(R.id.reactions_pill_count).visibility = GONE pill.findViewById(R.id.reactions_pill_spacer).visibility = GONE @@ -143,7 +143,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { for (id in binding.groupShowLess.referencedIds) { findViewById(id).setOnClickListener { view: View? -> extended = false - displayReactions(DEFAULT_THRESHOLD) + displayReactions(messageId, DEFAULT_THRESHOLD) } } } else { @@ -151,7 +151,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } } - private fun buildSortedReactionsList(records: List, userPublicKey: String?, threshold: Int): List { + private fun buildSortedReactionsList(messageId: MessageId, records: List, userPublicKey: String?, threshold: Int): List { val counters: MutableMap = LinkedHashMap() records.forEach { @@ -159,7 +159,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val info = counters[baseEmoji] if (info == null) { - counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) + counters[baseEmoji] = Reaction(messageId, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) } else { info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author) @@ -214,10 +214,8 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } private fun onReactionClicked(reaction: Reaction) { - if (reaction.messageId != 0L) { - val messageId = MessageId(reaction.messageId, reaction.isMms) - delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) - } + val messageId = this.messageId ?: return + delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) } private fun onDown(messageId: MessageId, emoji: String?) { @@ -257,8 +255,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } internal class Reaction( - internal val messageId: Long, - internal val isMms: Boolean, + internal val messageId: MessageId, internal var emoji: String?, internal var count: Long, internal val sortIndex: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 3a6a542f17..02750daaa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -282,7 +282,7 @@ class VisibleMessageView : FrameLayout { val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { emojiReactionsBinding.value.root.let { root -> - root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) + root.setReactions(message.messageId, message.reactions, message.isOutgoing, delegate) root.isVisible = true (root.layoutParams as ConstraintLayout.LayoutParams).apply { horizontalBias = if (message.isOutgoing) 1f else 0f diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 4176a467d8..ee44e7af49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -66,13 +66,6 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getInt(serverID) - }?.toLong() - } - fun getServerID(messageID: Long, isSms: Boolean): Long? { val database = readableDatabase return database.get(messageIDTable, "${Companion.messageID} = ? AND $messageType = ?", arrayOf(messageID.toString(), if (isSms) SMS_TYPE.toString() else MMS_TYPE.toString())) { cursor -> @@ -80,13 +73,6 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getInt(messageID) - }?.toLong() - } - fun deleteMessage(messageID: Long, isSms: Boolean) { val database = writableDatabase @@ -129,10 +115,7 @@ class LokiMessageDatabase(context: Context, helper: Provider? { + fun getMessageID(serverID: Long, threadID: Long): MessageId? { val database = readableDatabase val mappingResult = database.get(messageThreadMappingTable, "${Companion.serverID} = ? AND ${Companion.threadID} = ?", arrayOf(serverID.toString(), threadID.toString())) { cursor -> @@ -144,7 +127,10 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getInt(messageID).toLong() to (cursor.getInt(messageType) == SMS_TYPE) + MessageId( + id = cursor.getInt(messageID).toLong(), + mms = cursor.getInt(messageType) == MMS_TYPE + ) } } @@ -179,22 +165,15 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getInt(threadID) - }?.toLong() ?: -1L - } - fun setOriginalThreadID(messageID: Long, serverID: Long, threadID: Long) { val database = writableDatabase val contentValues = ContentValues(3) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index ef2a638bba..63d9f98feb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -75,8 +75,7 @@ class ReactionDatabase(context: Context, helper: Provider) private fun readReaction(cursor: Cursor): ReactionRecord { return ReactionRecord( - messageId = CursorUtil.requireLong(cursor, MESSAGE_ID), - isMms = CursorUtil.requireInt(cursor, IS_MMS) == 1, + messageId = MessageId(CursorUtil.requireLong(cursor, MESSAGE_ID), CursorUtil.requireInt(cursor, IS_MMS) == 1), emoji = CursorUtil.requireString(cursor, EMOJI), author = CursorUtil.requireString(cursor, AUTHOR_ID), serverId = CursorUtil.requireString(cursor, SERVER_ID), @@ -103,13 +102,13 @@ class ReactionDatabase(context: Context, helper: Provider) return reactions } - fun addReaction(messageId: MessageId, reaction: ReactionRecord, notifyUnread: Boolean) { + fun addReaction(reaction: ReactionRecord, notifyUnread: Boolean) { writableDatabase.beginTransaction() try { val values = ContentValues().apply { - put(MESSAGE_ID, messageId.id) - put(IS_MMS, if (messageId.mms) 1 else 0) + put(MESSAGE_ID, reaction.messageId.id) + put(IS_MMS, reaction.messageId.mms) put(EMOJI, reaction.emoji) put(AUTHOR_ID, reaction.author) put(SERVER_ID, reaction.serverId) @@ -121,10 +120,10 @@ class ReactionDatabase(context: Context, helper: Provider) writableDatabase.insert(TABLE_NAME, null, values) - if (messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false, notifyUnread) + if (reaction.messageId.mms) { + DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, reaction.messageId.id, true, false, notifyUnread) } else { - DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false, notifyUnread) + DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, reaction.messageId.id, true, false, notifyUnread) } writableDatabase.setTransactionSuccessful() @@ -242,20 +241,19 @@ class ReactionDatabase(context: Context, helper: Provider) val result = mutableSetOf() val array = JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS))) for (i in 0 until array.length()) { - val `object` = SaneJSONObject(array.getJSONObject(i)) - if (!`object`.isNull(ROW_ID)) { + val obj = SaneJSONObject(array.getJSONObject(i)) + if (!obj.isNull(ROW_ID)) { result.add( ReactionRecord( - `object`.getLong(ROW_ID), - `object`.getLong(MESSAGE_ID), - `object`.getInt(IS_MMS) == 1, - `object`.getString(AUTHOR_ID), - `object`.getString(EMOJI), - `object`.getString(SERVER_ID), - `object`.getLong(COUNT), - `object`.getLong(SORT_ID), - `object`.getLong(DATE_SENT), - `object`.getLong(DATE_RECEIVED) + id = obj.getLong(ROW_ID), + messageId = MessageId(obj.getLong(MESSAGE_ID), obj.getInt(IS_MMS) == 1), + author = obj.getString(AUTHOR_ID), + emoji = obj.getString(EMOJI), + serverId = obj.getString(SERVER_ID), + count = obj.getLong(COUNT), + sortId = obj.getLong(SORT_ID), + dateSent = obj.getLong(DATE_SENT), + dateReceived = obj.getLong(DATE_RECEIVED) ) ) } @@ -264,16 +262,15 @@ class ReactionDatabase(context: Context, helper: Provider) } else { listOf( ReactionRecord( - cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_ID)), - cursor.getInt(cursor.getColumnIndexOrThrow(IS_MMS)) == 1, - cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)), - cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)), - cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)), - cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)) + id = cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), + messageId = MessageId(cursor.getLong(MESSAGE_ID), cursor.getInt(IS_MMS) == 1), + author = cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)), + emoji = cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)), + serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)), + count = cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)), + sortId = cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)), + dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)), + dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)) ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index ccec7c9562..5035846b79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -613,9 +613,9 @@ open class Storage @Inject constructor( lokiAPIDatabase.setUserCount(room, server, newValue) } - override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) { - lokiMessageDatabase.setServerID(messageID, serverID, isSms) - lokiMessageDatabase.setOriginalThreadID(messageID, serverID, threadID) + override fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) { + lokiMessageDatabase.setServerID(messageID, serverID) + lokiMessageDatabase.setOriginalThreadID(messageID.id, serverID, threadID) } override fun getOpenGroup(room: String, server: String): OpenGroup? { @@ -1744,27 +1744,30 @@ open class Storage @Inject constructor( return mapping } - override fun addReaction(reaction: Reaction, threadId: Long, messageSender: String, notifyUnread: Boolean) { + override fun addReaction( + threadId: Long, + reaction: Reaction, + messageSender: String, + notifyUnread: Boolean + ) { val timestamp = reaction.timestamp - val localId = reaction.localId - val isMms = reaction.isMms - - val messageId = if (localId != null && localId > 0 && isMms != null) { - // bail early is the message is marked as deleted - val messagingDatabase: MessagingDatabase = if (isMms == true) mmsDatabase else smsDatabase - if(messagingDatabase.getMessageRecord(localId)?.isDeleted == true) return - MessageId(localId, isMms) - } else if (timestamp != null && timestamp > 0) { + val messageId = if (timestamp != null && timestamp > 0) { val messageRecord = mmsSmsDatabase.getMessageForTimestamp(threadId, timestamp) ?: return if (messageRecord.isDeleted) return MessageId(messageRecord.id, messageRecord.isMms) - } else return + } else { + Log.d(TAG, "Invalid reaction timestamp: $timestamp. Not adding") + return + } + + addReaction(messageId, reaction, messageSender, notifyUnread) + } + + override fun addReaction(messageId: MessageId, reaction: Reaction, messageSender: String, notifyUnread: Boolean) { reactionDatabase.addReaction( - messageId, ReactionRecord( - messageId = messageId.id, - isMms = messageId.mms, + messageId = messageId, author = messageSender, emoji = reaction.emoji!!, serverId = reaction.serverId!!, @@ -1804,8 +1807,8 @@ open class Storage @Inject constructor( database.updateReaction(reaction) } - override fun deleteReactions(messageId: Long, mms: Boolean) { - reactionDatabase.deleteMessageReactions(MessageId(messageId, mms)) + override fun deleteReactions(messageId: MessageId) { + reactionDatabase.deleteMessageReactions(messageId) } override fun deleteReactions(messageIds: List, mms: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt index 187c5b2d4b..bbe7d854b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt @@ -2,8 +2,7 @@ package org.thoughtcrime.securesms.database.model data class ReactionRecord( val id: Long = 0, - val messageId: Long, - val isMms: Boolean, + val messageId: MessageId, val author: String, val emoji: String, val serverId: String = "", diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.kt index b405f3b364..86c8b83c35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.reactions import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.model.MessageId /** * A UI model for a reaction in the [ReactionsDialogFragment] @@ -11,7 +12,6 @@ data class ReactionDetails( val displayEmoji: String, val timestamp: Long, val serverId: String, - val localId: Long, - val isMms: Boolean, + val localId: MessageId, val count: Int ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 28662e894a..4607246897 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -1,23 +1,23 @@ package org.thoughtcrime.securesms.reactions; -import static org.session.libsession.utilities.IdUtilKt.truncateIdForDisplay; - import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; + import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import org.session.libsignal.utilities.AccountId; -import java.util.Collections; -import java.util.List; -import network.loki.messenger.R; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; +import java.util.Collections; +import java.util.List; + +import network.loki.messenger.R; + final class ReactionRecipientsAdapter extends RecyclerView.Adapter { private static final int MAX_REACTORS = 5; @@ -152,8 +152,7 @@ public RecipientViewHolder(ReactionViewPagerAdapter.Listener callback, @NonNull void bind(@NonNull ReactionDetails reaction) { this.remove.setOnClickListener((v) -> { - MessageId messageId = new MessageId(reaction.getLocalId(), reaction.isMms()); - callback.onRemoveReaction(reaction.getBaseEmoji(), messageId, reaction.getTimestamp()); + callback.onRemoveReaction(reaction.getBaseEmoji(), reaction.getLocalId(), reaction.getTimestamp()); }); this.avatar.update(reaction.getSender()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt index 8687a0f9bb..b48ab5ad59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt @@ -31,7 +31,6 @@ class ReactionsRepository { timestamp = reaction.dateReceived, serverId = reaction.serverId, localId = reaction.messageId, - isMms = reaction.isMms, count = reaction.count.toInt() ) } From 441523382be8d30bc38575a565113d0901640606 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 12:04:02 +1000 Subject: [PATCH 326/867] Making sure the latest edited name is used in dialog --- .../libsession/messaging/groups/GroupManagerV2.kt | 2 +- .../v2/settings/ConversationSettingsViewModel.kt | 13 +++++++------ .../securesms/groups/GroupManagerV2Impl.kt | 8 ++++---- .../org/thoughtcrime/securesms/home/HomeActivity.kt | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 65499fb722..cd48ece0a1 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -118,7 +118,7 @@ interface GroupManagerV2 { */ fun onBlocked(groupAccountId: AccountId) - fun getLeaveGroupConfirmationDialogData(groupId: AccountId): ConfirmDialogData? + fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? data class ConfirmDialogData( val title: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 4ddbd72151..d8ef37df15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -108,10 +108,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private var recipient: Recipient? = null - private val groupV2: GroupInfo.ClosedGroupInfo? by lazy { - if(recipient == null) return@lazy null - configFactory.getGroup(AccountId(recipient!!.address.toString())) - } + private var groupV2: GroupInfo.ClosedGroupInfo? = null private val community: OpenGroup? by lazy { storage.getOpenGroup(threadId) @@ -149,6 +146,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( configs.contacts.get(conversation.address.toString()) } + groupV2 = configFactory.getGroup(AccountId(recipient!!.address.toString())) + // admin val isAdmin: Boolean = when { // for Groups V2 @@ -811,8 +810,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( private fun confirmLeaveGroup(){ val groupData = groupV2 ?: return _dialogState.update { state -> - val dialogData = groupManager.getLeaveGroupConfirmationDialogData(AccountId(groupData.groupAccountId)) - ?: return + val dialogData = groupManager.getLeaveGroupConfirmationDialogData( + AccountId(groupData.groupAccountId), + _uiState.value.name + ) ?: return state.copy( showSimpleDialog = Dialog( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 8edacc7336..0d001b4b86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -1222,7 +1222,7 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(message, groupId) } - override fun getLeaveGroupConfirmationDialogData(groupId: AccountId): GroupManagerV2.ConfirmDialogData? { + override fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): GroupManagerV2.ConfirmDialogData? { val groupData = configFactory.getGroup(groupId) ?: return null var title = R.string.groupDelete @@ -1234,16 +1234,16 @@ class GroupManagerV2Impl @Inject constructor( if(!groupData.shouldPoll){ message = Phrase.from(application, R.string.groupDeleteDescriptionMember) - .put(GROUP_NAME_KEY, groupData.name) + .put(GROUP_NAME_KEY, name) .format() } else if (groupData.hasAdminKey()) { message = Phrase.from(application, R.string.groupLeaveDescriptionAdmin) - .put(GROUP_NAME_KEY, groupData.name) + .put(GROUP_NAME_KEY, name) .format() } else { message = Phrase.from(application, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, groupData.name) + .put(GROUP_NAME_KEY, name) .format() title = R.string.groupLeave diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 1d16e9fbf4..ee45f23d37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -679,7 +679,7 @@ class HomeActivity : ScreenLockActionBarActivity(), confirmAndLeaveGroup( groupName = name, - dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(accountId), + dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(accountId, name), threadID = threadID, storage = storage, doLeave = { From 8308d56dbff6fe74d71aa579e1a97347e5fd5469 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 12:07:09 +1000 Subject: [PATCH 327/867] Added padding --- .../thoughtcrime/securesms/ui/components/ConversationAppBar.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index ef246f8258..1612b118f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -131,6 +131,7 @@ fun ConversationAppBar( if (data.showAvatar) { Avatar( modifier = Modifier.qaTag(R.string.qa_conversation_avatar) + .padding(end = LocalDimensions.current.xsSpacing) .clickable { onAvatarPressed() }, size = LocalDimensions.current.iconLargeAvatar, data = data.avatarUIData From 19e837160821dcf0cabdeb0104e68257529f0314 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 13:46:24 +1000 Subject: [PATCH 328/867] Proper group access Making sure the ripple on the avatar is round --- .../conversation/v2/settings/ConversationSettingsViewModel.kt | 3 ++- .../thoughtcrime/securesms/ui/components/ConversationAppBar.kt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index d8ef37df15..f07cfd6af1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -146,7 +146,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( configs.contacts.get(conversation.address.toString()) } - groupV2 = configFactory.getGroup(AccountId(recipient!!.address.toString())) + groupV2 = if(conversation.isGroupV2Recipient) configFactory.getGroup(AccountId(conversation.address.toString())) + else null // admin val isAdmin: Boolean = when { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 1612b118f5..aab3549870 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -132,6 +132,7 @@ fun ConversationAppBar( Avatar( modifier = Modifier.qaTag(R.string.qa_conversation_avatar) .padding(end = LocalDimensions.current.xsSpacing) + .clip(CircleShape) .clickable { onAvatarPressed() }, size = LocalDimensions.current.iconLargeAvatar, data = data.avatarUIData From 695bff02448fd12d6718493f1cae706ecab593b7 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 21 May 2025 13:49:48 +1000 Subject: [PATCH 329/867] Change more message querying to using messageId (#1171) --- .../database/MessageDataProvider.kt | 10 +- .../database/ServerHashToMessageId.kt | 5 +- .../messaging/jobs/AttachmentDownloadJob.kt | 5 +- .../messaging/jobs/OpenGroupDeleteJob.kt | 12 +-- .../messages/MarkAsDeletedMessage.kt | 4 +- .../ReceivedMessageHandler.kt | 14 +-- .../attachments/DatabaseAttachmentProvider.kt | 102 ++++++------------ .../conversation/v2/ConversationActivityV2.kt | 4 +- .../conversation/v2/ConversationViewModel.kt | 4 +- .../securesms/database/LokiMessageDatabase.kt | 37 ++++--- .../securesms/database/MmsSmsDatabase.java | 60 ----------- .../securesms/database/Storage.kt | 6 +- .../securesms/database/model/MessageId.kt | 2 + .../securesms/groups/GroupManagerV2Impl.kt | 2 +- .../notifications/MarkReadReceiver.kt | 2 +- .../repository/ConversationRepository.kt | 34 +++--- 16 files changed, 103 insertions(+), 200 deletions(-) diff --git a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index b45bb898f9..dbe35e6f4f 100644 --- a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -13,7 +13,6 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentStream import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.database.model.MessageRecord import java.io.InputStream interface MessageDataProvider { @@ -21,13 +20,13 @@ interface MessageDataProvider { fun getMessageID(serverId: Long, threadId: Long): MessageId? fun getMessageIDs(serverIDs: List, threadID: Long): Pair, List> fun getUserMessageHashes(threadId: Long, userPubKey: String): List - fun deleteMessage(messageID: Long, isSms: Boolean) + fun deleteMessage(messageId: MessageId) fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) - fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) - fun markMessagesAsDeleted(messages: List, isSms: Boolean, displayedMessage: String) + fun markMessageAsDeleted(messageId: MessageId, displayedMessage: String) + fun markMessagesAsDeleted(messages: List, displayedMessage: String) fun markMessagesAsDeleted(threadId: Long, serverHashes: List, displayedMessage: String) fun markUserMessagesAsDeleted(threadId: Long, until: Long, sender: String, displayedMessage: String) - fun getServerHashForMessage(messageID: Long, mms: Boolean): String? + fun getServerHashForMessage(messageID: MessageId): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? @@ -37,7 +36,6 @@ interface MessageDataProvider { fun setAttachmentState(attachmentState: AttachmentState, attachmentId: AttachmentId, messageID: Long) fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream) fun updateAudioAttachmentDuration(attachmentId: AttachmentId, durationMs: Long, threadId: Long) - fun isMmsOutgoing(mmsMessageId: Long): Boolean fun isOutgoingMessage(id: MessageId): Boolean fun isDeletedMessage(id: MessageId): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) diff --git a/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt b/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt index 84fc643702..c32c64063c 100644 --- a/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt +++ b/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt @@ -1,5 +1,7 @@ package org.session.libsession.database +import org.thoughtcrime.securesms.database.model.MessageId + data class ServerHashToMessageId( val serverHash: String, /** @@ -8,7 +10,6 @@ data class ServerHashToMessageId( * meaning of this field. */ val sender: String, - val messageId: Long, - val isSms: Boolean, + val messageId: MessageId, val isOutgoing: Boolean, ) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 0f8124b993..0629a4f97b 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -19,6 +19,7 @@ import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ByteArraySlice.Companion.write +import org.thoughtcrime.securesms.database.model.MessageId import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -57,11 +58,11 @@ class AttachmentDownloadJob(val attachmentID: Long, val mmsMessageId: Long) : Jo fun eligibleForDownload(threadID: Long, storage: StorageProtocol, messageDataProvider: MessageDataProvider, - databaseMessageID: Long): Boolean { + mmsId: Long): Boolean { val threadRecipient = storage.getRecipientForThread(threadID) ?: return false // if we are the sender we are always eligible - val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) + val selfSend = messageDataProvider.isOutgoingMessage(MessageId(mmsId, true)) if (selfSend) { return true } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 271549c410..95515304bd 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -26,19 +26,19 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th // FIXME: This entire process should probably run in a transaction (with the attachment deletion happening only if it succeeded) try { - val messageIds = dataProvider.getMessageIDs(messageServerIds.toList(), threadId) + val (smsMessages, mmsMessages) = dataProvider.getMessageIDs(messageServerIds.toList(), threadId) // Delete the SMS messages - if (messageIds.first.isNotEmpty()) { - dataProvider.deleteMessages(messageIds.first, threadId, true) + if (smsMessages.isNotEmpty()) { + dataProvider.deleteMessages(smsMessages, threadId, true) } // Delete the MMS messages - if (messageIds.second.isNotEmpty()) { - dataProvider.deleteMessages(messageIds.second, threadId, false) + if (mmsMessages.isNotEmpty()) { + dataProvider.deleteMessages(mmsMessages, threadId, false) } - Log.d(TAG, "Deleted ${messageIds.first.size + messageIds.second.size} messages successfully") + Log.d(TAG, "Deleted ${smsMessages.size + mmsMessages.size} messages successfully") delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { diff --git a/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt index 4ed0e880c9..6126cb7c7d 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt @@ -1,6 +1,8 @@ package org.session.libsession.messaging.messages +import org.thoughtcrime.securesms.database.model.MessageId + data class MarkAsDeletedMessage( - val messageId: Long, + val messageId: MessageId, val isOutgoing: Boolean ) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 66db63f525..794f6736a7 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -294,11 +294,12 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): MessageId? { val timestamp = message.timestamp ?: return null val author = message.author ?: return null val messageToDelete = storage.getMessageBy(timestamp, author) ?: return null - val messageType = messageToDelete.individualRecipient.getType() + val messageIdToDelete = messageToDelete.messageId + val messageType = messageToDelete.individualRecipient?.getType() // send a /delete rquest for 1on1 messages if (messageType == MessageType.ONE_ON_ONE) { - messageDataProvider.getServerHashForMessage(messageToDelete.id, messageToDelete.isMms)?.let { serverHash -> + messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> GlobalScope.launch(Dispatchers.IO) { // using GlobalScope as we are slowly migrating to coroutines but we can't migrate everything at once try { SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) @@ -311,12 +312,11 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): MessageId? { // the message is marked as deleted locally // except for 'note to self' where the message is completely deleted - if(messageType == MessageType.NOTE_TO_SELF){ - messageDataProvider.deleteMessage(messageToDelete.id, !messageToDelete.isMms) + if (messageType == MessageType.NOTE_TO_SELF){ + messageDataProvider.deleteMessage(messageIdToDelete) } else { messageDataProvider.markMessageAsDeleted( - timestamp = timestamp, - author = author, + messageIdToDelete, displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) ) } @@ -329,7 +329,7 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): MessageId? { SSKEnvironment.shared.notificationManager.updateNotification(context) } - return messageToDelete.messageId + return messageIdToDelete } fun handleMessageRequestResponse(message: MessageRequestResponse) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 991e8e089e..57188d6fe1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.PartAuthority @@ -131,13 +130,6 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider - mmsDb.readerFor(cursor).next - }?.isOutgoing ?: false - } - override fun isOutgoingMessage(id: MessageId): Boolean { return if (id.mms) { DatabaseComponent.get(context).mmsDatabase().isOutgoingMessage(id.id) @@ -193,65 +185,56 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider, threadId: Long, isSms: Boolean) { val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() - val messages = messageIDs.mapNotNull { runCatching { messagingDatabase.getMessageRecord(it) }.getOrNull() } - - // Perform local delete messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) - - // Perform online delete - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs, isSms = isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) } - override fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) { + override fun markMessageAsDeleted(messageId: MessageId, displayedMessage: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return Log.w("", "Failed to find message to mark as deleted") + val message = database.getMessageById(messageId) ?: return Log.w("", "Failed to find message to mark as deleted") markMessagesAsDeleted( messages = listOf(MarkAsDeletedMessage( - messageId = message.id, + messageId = message.messageId, isOutgoing = message.isOutgoing )), - isSms = !message.isMms, displayedMessage = displayedMessage ) } override fun markMessagesAsDeleted( messages: List, - isSms: Boolean, displayedMessage: String ) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() - else DatabaseComponent.get(context).mmsDatabase() + val smsDatabase = DatabaseComponent.get(context).smsDatabase() + val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() messages.forEach { message -> - messagingDatabase.markAsDeleted(message.messageId, message.isOutgoing, displayedMessage) + if (message.messageId.mms) { + mmsDatabase.markAsDeleted(message.messageId.id, message.isOutgoing, displayedMessage) + } else { + smsDatabase.markAsDeleted(message.messageId.id, message.isOutgoing, displayedMessage) + } } } @@ -260,21 +243,11 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider, displayedMessage: String ) { - val sendersForHashes = DatabaseComponent.get(context).lokiMessageDatabase() + val markAsDeleteMessages = DatabaseComponent.get(context).lokiMessageDatabase() .getSendersForHashes(threadId, serverHashes.toSet()) + .map { MarkAsDeletedMessage(messageId = it.messageId, isOutgoing = it.isOutgoing) } - val smsMessages = sendersForHashes.asSequence() - .filter { it.isSms } - .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } - .toList() - - val mmsMessages = sendersForHashes.asSequence() - .filter { !it.isSms } - .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } - .toList() - - markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) - markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + markMessagesAsDeleted(markAsDeleteMessages, displayedMessage) } override fun markUserMessagesAsDeleted( @@ -283,25 +256,19 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider() - val smsMessages = mutableListOf() - - DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + val toDelete = DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + .asSequence() .filter { it.timestamp <= until } - .forEach { record -> - if (record.isMms) { - mmsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) - } else { - smsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) - } + .map { record -> + MarkAsDeletedMessage(messageId = record.messageId, isOutgoing = record.isOutgoing) } + .toList() - markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) - markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + markMessagesAsDeleted(toDelete, displayedMessage) } - override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = - DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) + override fun getServerHashForMessage(messageID: MessageId): String? = + DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID) override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? = DatabaseComponent.get(context).attachmentDatabase() @@ -353,10 +320,6 @@ fun DatabaseAttachment.toAttachmentPointer(): SessionServiceAttachmentPointer { return SessionServiceAttachmentPointer(attachmentId.rowId, contentType, key?.toByteArray(), Optional.fromNullable(size.toInt()), Optional.absent(), width, height, Optional.fromNullable(digest), filename, isVoiceNote, Optional.fromNullable(caption), url) } -fun SessionServiceAttachmentPointer.toSignalPointer(): SignalServiceAttachmentPointer { - return SignalServiceAttachmentPointer(id,contentType,key?.toByteArray() ?: byteArrayOf(), size, preview, width, height, digest, filename, voiceNote, caption, url) -} - fun DatabaseAttachment.toAttachmentStream(context: Context): SessionServiceAttachmentStream { val stream = PartAuthority.getAttachmentStream(context, this.dataUri!!) @@ -406,6 +369,3 @@ fun DatabaseAttachment.toSignalAttachmentStream(context: Context): SignalService return SignalServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption)) } -fun DatabaseAttachment.shouldHaveImageSize(): Boolean { - return (MediaUtil.isVideo(this) || MediaUtil.isImage(this) || MediaUtil.isGif(this)); -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 7d11a0b45a..9c073fe30f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1737,7 +1737,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, true) if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when adding emoji reaction") viewModel.openGroup?.let { @@ -1773,7 +1773,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, false) if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") viewModel.openGroup?.let { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 788b8cd7fa..a260d67ddf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -613,7 +613,7 @@ class ConversationViewModel( val canDeleteForEveryone = messages.all{ !it.isDeleted && !it.isControlMessage } && ( messages.all { it.isOutgoing } || conversationType == MessageType.COMMUNITY || - messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } + messages.all { lokiMessageDb.getMessageServerHash(it.messageId) != null } ) // There are three types of dialogs for deletion: @@ -1179,7 +1179,7 @@ class ConversationViewModel( viewModelScope.launch(Dispatchers.Default) { reactionDb.deleteEmojiReactions(emoji, messageId) openGroup?.let { openGroup -> - lokiMessageDb.getServerID(messageId.id, !messageId.mms)?.let { serverId -> + lokiMessageDb.getServerID(messageId)?.let { serverId -> OpenGroupApi.deleteAllReactions( openGroup.room, openGroup.server, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index ee44e7af49..1df2b81f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -66,19 +66,21 @@ class LokiMessageDatabase(context: Context, helper: Provider + return database.get(messageIDTable, + "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.toString(), messageID.asMessageType.toString())) { cursor -> cursor.getInt(serverID) }?.toLong() } - fun deleteMessage(messageID: Long, isSms: Boolean) { + fun deleteMessage(messageID: MessageId) { val database = writableDatabase val serverID = database.get(messageIDTable, "${Companion.messageID} = ? AND $messageType = ?", - arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> + arrayOf(messageID.toString(), messageID.asMessageType.toString())) { cursor -> cursor.getInt(serverID).toLong() } @@ -96,18 +98,20 @@ class LokiMessageDatabase(context: Context, helper: Provider) { + fun deleteMessages(messageIDs: List, isSms: Boolean) { val database = writableDatabase database.beginTransaction() + val messageTypeValue = if (isSms) SMS_TYPE else MMS_TYPE + database.delete( messageIDTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }}) AND $messageType = $messageTypeValue", messageIDs.map { "$it" }.toTypedArray() ) database.delete( messageThreadMappingTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() ) @@ -265,9 +269,8 @@ class LokiMessageDatabase(context: Context, helper: Provider cursor.getString(serverHash) } + arrayOf(messageID.id.toString())) { cursor -> cursor.getString(serverHash) } } - fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + fun setMessageServerHash(messageID: MessageId, serverHash: String) { val contentValues = ContentValues(2).apply { - put(Companion.messageID, messageID) + put(Companion.messageID, messageID.id) put(Companion.serverHash, serverHash) } writableDatabase.apply { - insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + insertOrUpdate(getMessageTable(messageID.mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.id.toString())) } } - fun deleteMessageServerHash(messageID: Long, mms: Boolean) { - writableDatabase.delete(getMessageTable(mms), "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun deleteMessageServerHash(messageID: MessageId) { + writableDatabase.delete(getMessageTable(messageID.mms), "${Companion.messageID} = ?", arrayOf(messageID.id.toString())) } fun deleteMessageServerHashes(messageIDs: List, mms: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 500453634f..dd30f26a0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -17,9 +17,6 @@ package org.thoughtcrime.securesms.database; import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_TYPE_MASK; import android.content.Context; import android.database.Cursor; @@ -40,7 +37,6 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.io.Closeable; @@ -295,16 +291,6 @@ public Cursor getConversationSnippet(long threadId) { return queryTables(PROJECTION, selection, order, null); } - public long getLastMessageID(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - - try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - cursor.moveToFirst(); - return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); - } - } - public List getUserMessages(long threadId, String sender) { List idList = new ArrayList<>(); @@ -378,27 +364,6 @@ public List> getAllMessagesWithHash(long threadId) { } return identifiedMessages; } - public long getLastOutgoingTimestamp(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - long attempts = 0; - long maxAttempts = 20; - while ((messageRecord = reader.getNext()) != null) { - // Note: We rely on the message order to get us the most recent outgoing message - so we - // take the first outgoing message we find as the last outgoing message. - if (messageRecord.isOutgoing()) return messageRecord.getTimestamp(); - if (attempts++ > maxAttempts) break; - } - } - } - Log.i(TAG, "Could not find last sent message from us - returning -1."); - return -1; - } @Nullable public MessageRecord getLastMessage(long threadId) { @@ -455,36 +420,11 @@ public long getConversationCount(long threadId) { return count; } - public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { - DatabaseComponent.get(context).smsDatabase().incrementReceiptCount(syncMessageId, true, false); - DatabaseComponent.get(context).mmsDatabase().incrementReceiptCount(syncMessageId, timestamp, true, false); - } - public void incrementReadReceiptCount(SyncMessageId syncMessageId, long timestamp) { DatabaseComponent.get(context).smsDatabase().incrementReceiptCount(syncMessageId, false, true); DatabaseComponent.get(context).mmsDatabase().incrementReceiptCount(syncMessageId, timestamp, false, true); } - public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull Address address) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - - try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { - String serializedAddress = address.toString(); - boolean isOwnNumber = Util.isOwnNumber(context, address.toString()); - - while (cursor != null && cursor.moveToNext()) { - boolean quoteIdMatches = cursor.getLong(0) == quoteId; - boolean addressMatches = serializedAddress.equals(cursor.getString(1)); - - if (quoteIdMatches && (addressMatches || isOwnNumber)) { - return cursor.getPosition(); - } - } - } - return -1; - } - public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 5035846b79..e53c6d307d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -279,7 +279,7 @@ open class Storage @Inject constructor( override fun deleteMessagesByHash(threadId: Long, hashes: List) { for (info in lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet())) { - messageDataProvider.deleteMessage(info.messageId, info.isSms) + messageDataProvider.deleteMessage(info.messageId) if (!info.isOutgoing) { notificationManager.updateNotification(context) } @@ -470,7 +470,7 @@ open class Storage @Inject constructor( message.serverHash?.let { serverHash -> messageID?.let { id -> - lokiMessageDatabase.setMessageServerHash(id.id, id.mms, serverHash) + lokiMessageDatabase.setMessageServerHash(id, serverHash) } } return messageID @@ -742,7 +742,7 @@ open class Storage @Inject constructor( } override fun setMessageServerHash(messageId: MessageId, serverHash: String) { - lokiMessageDatabase.setMessageServerHash(messageId.id, messageId.mms, serverHash) + lokiMessageDatabase.setMessageServerHash(messageId, serverHash) } override fun getGroup(groupID: String): GroupRecord? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index 6d24486475..0beef4738a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -17,6 +17,8 @@ data class MessageId( @Keep private constructor(): this(0, false) + val sms: Boolean get() = !mms + fun serialize(): String { return "$id|$mms" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index f84d9e60e5..64ab9bd43d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -405,7 +405,7 @@ class GroupManagerV2Impl @Inject constructor( if (threadId != null) { for (member in members) { for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { - val serverHash = lokiDatabase.getMessageServerHash(msg.id, msg.isMms) + val serverHash = lokiDatabase.getMessageServerHash(msg.messageId) if (serverHash != null) { messagesToDelete.add(serverHash) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 6ce0376bdf..76a298b380 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -98,7 +98,7 @@ class MarkReadReceiver : BroadcastReceiver() { return markedReadMessages .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id.id, id.mms) } } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } .takeIf { it.isNotEmpty() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index b6c05b14c8..552cb1ffc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -242,11 +242,11 @@ class DefaultConversationRepository @Inject constructor( val (mms, sms) = messages.partition { it.isMms } if(mms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted(mms.map { MarkAsDeletedMessage( - messageId = it.id, - isOutgoing = it.isOutgoing - ) }, - isSms = false, + messageDataProvider.markMessagesAsDeleted( + mms.map { MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) }, displayedMessage = displayedMessage ) @@ -255,11 +255,11 @@ class DefaultConversationRepository @Inject constructor( } if(sms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted(sms.map { MarkAsDeletedMessage( - messageId = it.id, - isOutgoing = it.isOutgoing - ) }, - isSms = true, + messageDataProvider.markMessagesAsDeleted( + sms.map { MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) }, displayedMessage = displayedMessage ) @@ -273,7 +273,7 @@ class DefaultConversationRepository @Inject constructor( val senderId = messageRecord.recipient.address.contactIdentifier() val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) for (message in messageRecordsToRemoveFromLocalStorage) { - messageDataProvider.deleteMessage(message.id, !message.isMms) + messageDataProvider.deleteMessage(messageId = message.messageId) } } @@ -288,7 +288,7 @@ class DefaultConversationRepository @Inject constructor( val community = checkNotNull(lokiThreadDb.getOpenGroupChat(threadId)) { "Not a community" } messages.forEach { message -> - lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> OpenGroupApi.deleteMessage(messageServerID, community.room, community.server).await() } } @@ -308,7 +308,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // delete from swarm - messageDataProvider.getServerHashForMessage(message.id, message.isMms) + messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } @@ -349,7 +349,7 @@ class DefaultConversationRepository @Inject constructor( val groupId = AccountId(recipient.address.toString()) val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> - messageDataProvider.getServerHashForMessage(msg.id, msg.isMms) + messageDataProvider.getServerHashForMessage(msg.messageId) } groupManager.requestMessageDeletion(groupId, hashes) @@ -369,7 +369,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // delete from swarm - messageDataProvider.getServerHashForMessage(message.id, message.isMms) + messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } @@ -381,10 +381,6 @@ class DefaultConversationRepository @Inject constructor( } } - private fun shouldSendUnsendRequest(recipient: Recipient): Boolean { - return recipient.is1on1 || recipient.isLegacyGroupRecipient - } - private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { return UnsendRequest( author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), From 59fc3f6f3e69825bed7c86ef1d3f2a14e93e9a53 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 13:50:59 +1000 Subject: [PATCH 330/867] Removing unneeded code --- .../securesms/home/HomeActivity.kt | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index ee45f23d37..b2bb3dd5e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -678,7 +678,6 @@ class HomeActivity : ScreenLockActionBarActivity(), } ?: group.name confirmAndLeaveGroup( - groupName = name, dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(accountId, name), threadID = threadID, storage = storage, @@ -776,7 +775,6 @@ class HomeActivity : ScreenLockActionBarActivity(), } private fun confirmAndLeaveGroup( - groupName: String, dialogData: GroupManagerV2.ConfirmDialogData?, threadID: Long, storage: StorageProtocol, @@ -784,13 +782,6 @@ class HomeActivity : ScreenLockActionBarActivity(), ) { if (dialogData == null) return - fun onLeaveFailed() { - val txt = Phrase.from(this, R.string.groupLeaveErrorFailed) - .put(GROUP_NAME_KEY, groupName) - .format().toString() - Toast.makeText(this, txt, Toast.LENGTH_LONG).show() - } - showSessionDialog { title(dialogData.title) text(dialogData.message) @@ -799,17 +790,10 @@ class HomeActivity : ScreenLockActionBarActivity(), contentDescriptionRes = dialogData.positiveQaTag ?: dialogData.positiveText ) { GlobalScope.launch(Dispatchers.Default) { - try { - // Cancel any outstanding jobs - storage.cancelPendingMessageSendJobs(threadID) - - doLeave() - } catch (e: Exception) { - Log.e("Conversation", "Error leaving group", e) - withContext(Dispatchers.Main) { - onLeaveFailed() - } - } + // Cancel any outstanding jobs + storage.cancelPendingMessageSendJobs(threadID) + + doLeave() } } From 8024dc40d6281790fb08f660104b7cbae797bcc9 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 21 May 2025 15:46:06 +1000 Subject: [PATCH 331/867] Added subproject support for libsession-util-android development (#1187) --- app/build.gradle.kts | 13 ++++++++++++- settings.gradle.kts | 6 ++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ab228b70b..0b0aa0c88f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,7 @@ plugins { } val huaweiEnabled = project.properties["huawei"] != null +val hasIncludedLibSessionUtilProject: Boolean = System.getProperty("session.libsession_util.project.path", "").isNotBlank() configurations.configureEach { exclude(module = "commons-logging") @@ -302,7 +303,17 @@ dependencies { implementation(libs.opencsv) implementation(libs.androidx.work.runtime.ktx) implementation(libs.rxbinding) - implementation(libs.libsession.util.android) + + if (hasIncludedLibSessionUtilProject) { + implementation( + group = libs.libsession.util.android.get().group, + name = libs.libsession.util.android.get().name, + version = "dev-snapshot" + ) + } else { + implementation(libs.libsession.util.android) + } + implementation(libs.kryo) implementation(libs.curve25519.java) testImplementation(libs.junit) diff --git a/settings.gradle.kts b/settings.gradle.kts index b6cdc45b3c..213a5009d4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,12 @@ rootProject.name = "session-android" includeBuild("build-logic") +// If libsession_util_project_path is set, include it as a build dependency +val libSessionUtilProjectPath: String = System.getProperty("session.libsession_util.project.path", "") +if (libSessionUtilProjectPath.isNotBlank()) { + includeBuild(libSessionUtilProjectPath) +} + include(":app") include(":liblazysodium") include(":content-descriptions") // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing \ No newline at end of file From a6c1d28e8ea1822a09611d70e96ea6ac06c032a0 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 21 May 2025 16:14:10 +1000 Subject: [PATCH 332/867] Replace signing/verify with libsession-util (#1188) --- .../messaging/jobs/InviteContactsJob.kt | 7 ++-- .../ReceivedMessageHandler.kt | 3 +- .../messaging/utilities/SodiumUtilities.kt | 24 ------------ .../securesms/groups/GroupManagerV2Impl.kt | 38 +++++++++---------- .../handler/RemoveGroupMemberHandler.kt | 8 ++-- gradle/libs.versions.toml | 2 +- 6 files changed, 31 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 2201c143c9..0a05faf912 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.ED25519 import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.messages.Destination @@ -60,9 +61,9 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< } val timestamp = SnodeAPI.nowWithOffset - val signature = SodiumUtilities.sign( - buildGroupInviteSignature(memberId, timestamp), - adminKey.data + val signature = ED25519.sign( + ed25519PrivateKey = adminKey.data, + message = buildGroupInviteSignature(memberId, timestamp), ) val groupInvite = GroupUpdateInviteMessage.newBuilder() diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 794f6736a7..f9487fae5c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.Sodium import org.session.libsession.avatars.AvatarHelper @@ -833,7 +834,7 @@ private fun MessageReceiver.handleNewLibSessionClosedGroupMessage(message: Group */ private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { val groupPubKey = groupSessionId.pubKeyBytes - if (!SodiumUtilities.verifySignature(signatureData, groupPubKey, messageToValidate)) { + if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { throw SignatureException("Verification failed for signature data") } } diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt index d01352c11d..f086f3b7e5 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -253,29 +253,5 @@ object SodiumUtilities { } else null } - /** - * Returns true only if the signature verified successfully - */ - fun verifySignature( - signature: ByteArray, - publicKey: ByteArray, - messageToVerify: ByteArray - ): Boolean { - return sodium.cryptoSignVerifyDetached( - signature, - messageToVerify, messageToVerify.size, publicKey) - } - - /** - * For signing - */ - fun sign(message: ByteArray, signingKey: ByteArray): ByteArray { - val signature = ByteArray(Sign.BYTES) - - if (!sodium.cryptoSignDetached(signature, message, message.size.toLong(), signingKey)) { - throw SecurityException("Couldn't sign the message with the signing key") - } - return signature - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 57c58c9829..91cb83c6ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation @@ -35,7 +36,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI @@ -336,9 +336,9 @@ class GroupManagerV2Impl @Inject constructor( newMembers: Collection, ) { val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), - adminKey + val signature = ED25519.sign( + message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), + ed25519PrivateKey = adminKey ) val updatedMessage = GroupUpdated( @@ -373,12 +373,12 @@ class GroupManagerV2Impl @Inject constructor( ) val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature( + val signature = ED25519.sign( + message = buildMemberChangeSignature( GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp ), - adminKey + ed25519PrivateKey = adminKey ) val updateMessage = GroupUpdateMessage.newBuilder() @@ -547,9 +547,9 @@ class GroupManagerV2Impl @Inject constructor( // Build a group update message to the group telling members someone has been promoted val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), - adminKey + val signature = ED25519.sign( + message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( @@ -981,9 +981,9 @@ class GroupManagerV2Impl @Inject constructor( } val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), - adminKey + val signature = ED25519.sign( + message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( @@ -1054,13 +1054,13 @@ class GroupManagerV2Impl @Inject constructor( // Construct a message to ask members to delete the messages, sign if we are admin, then send val timestamp = clock.currentTimeMills() val signature = group.adminKey?.data?.let { key -> - SodiumUtilities.sign( - buildDeleteMemberContentSignature( + ED25519.sign( + message = buildDeleteMemberContentSignature( memberIds = emptyList(), messageHashes, timestamp ), - key + ed25519PrivateKey = key ) } val message = GroupUpdated( @@ -1197,9 +1197,9 @@ class GroupManagerV2Impl @Inject constructor( // Construct a message to notify the group members about the expiration timer change val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES, timestamp), - adminKey + val signature = ED25519.sign( + message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 645374b822..eeac756073 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.ReadableGroupKeysConfig import network.loki.messenger.libsession_util.allWithStatus @@ -230,13 +231,14 @@ class RemoveGroupMemberHandler @Inject constructor( } .setAdminSignature( ByteString.copyFrom( - SodiumUtilities.sign( - MessageAuthentication.buildDeleteMemberContentSignature( + ED25519.sign( + message = MessageAuthentication.buildDeleteMemberContentSignature( memberIds = memberSessionIDs.map { AccountId(it) } .toList(), messageHashes = emptyList(), timestamp = timestamp, - ), adminKey + ), + ed25519PrivateKey = adminKey ) ) ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80b9c9413e..eac8ebbea8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ kotlinxDatetimeVersion = "0.6.0" kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.4-1-g7d21285" +libsessionUtilAndroidVersion = "1.0.4-2-g602cde7" media3ExoplayerVersion = "1.4.0" mockitoCoreVersion = "5.17.0" navVersion = "2.9.0" From f8c0174d5cf28cdb2c5e4a2db7e5cb7fe8cd6b88 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 21 May 2025 16:19:21 +1000 Subject: [PATCH 333/867] Remove scroll animation when opening an image from the conversation --- .../java/org/thoughtcrime/securesms/MediaPreviewActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 9fcfb07f64..f1688ef4f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -540,7 +540,7 @@ public boolean onSingleTapConfirmed(@NonNull MotionEvent e) { binding.mediaPager.registerOnPageChangeCallback(viewPagerListener); try { - binding.mediaPager.setCurrentItem(item); + binding.mediaPager.setCurrentItem(item, false); } catch (CursorIndexOutOfBoundsException e) { throw new RuntimeException("restartItem = " + restartItem + ", data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e); } From 43b96a909d22e36f40c6572a667663522751e4c4 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 22 May 2025 09:29:27 +1000 Subject: [PATCH 334/867] Encryption/decryption with libsession-util (#1189) --- .../sending_receiving/MessageDecrypter.kt | 43 +++-------------- .../sending_receiving/MessageEncrypter.kt | 48 +++++-------------- .../messaging/utilities/SodiumUtilities.kt | 39 +-------------- .../snode/OnionRequestEncryption.kt | 7 +-- .../org/session/libsession/snode/SnodeAPI.kt | 17 ++++--- gradle/libs.versions.toml | 2 +- 6 files changed, 34 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index afb23c476e..61fa165ef2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -1,16 +1,11 @@ package org.session.libsession.messaging.sending_receiving -import com.goterl.lazysodium.interfaces.Box -import com.goterl.lazysodium.interfaces.Sign import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.ecc.ECKeyPair -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded @@ -25,40 +20,16 @@ object MessageDecrypter { * * @return the padded plaintext. */ - public fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { + fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) - val signatureSize = Sign.BYTES - val ed25519PublicKeySize = Sign.PUBLICKEYBYTES + val (id, data) = SessionEncrypt.decryptIncoming( + x25519PubKey = recipientX25519PublicKey, + x25519PrivKey = recipientX25519PrivateKey, + ciphertext = ciphertext + ) - // 1. ) Decrypt the message - val plaintextWithMetadata = ByteArray(ciphertext.size - Box.SEALBYTES) - try { - sodium.cryptoBoxSealOpen(plaintextWithMetadata, ciphertext, ciphertext.size.toLong(), recipientX25519PublicKey, recipientX25519PrivateKey) - } catch (exception: Exception) { - Log.d("Loki", "Couldn't decrypt message due to error: $exception.") - throw Error.DecryptionFailed - } - if (plaintextWithMetadata.size <= (signatureSize + ed25519PublicKeySize)) { throw Error.DecryptionFailed } - // 2. ) Get the message parts - val signature = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - signatureSize until plaintextWithMetadata.size) - val senderED25519PublicKey = plaintextWithMetadata.sliceArray(plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize) until plaintextWithMetadata.size - signatureSize) - val plaintext = plaintextWithMetadata.sliceArray(0 until plaintextWithMetadata.size - (signatureSize + ed25519PublicKeySize)) - // 3. ) Verify the signature - val verificationData = (plaintext + senderED25519PublicKey + recipientX25519PublicKey) - try { - val isValid = sodium.cryptoSignVerifyDetached(signature, verificationData, verificationData.size, senderED25519PublicKey) - if (!isValid) { throw Error.InvalidSignature } - } catch (exception: Exception) { - Log.d("Loki", "Couldn't verify message signature due to error: $exception.") - throw Error.InvalidSignature - } - // 4. ) Get the sender's X25519 public key - val senderX25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) - sodium.convertPublicKeyEd25519ToCurve25519(senderX25519PublicKey, senderED25519PublicKey) - - val id = AccountId(IdPrefix.STANDARD, senderX25519PublicKey) - return Pair(plaintext, id.hexString) + return data.data to id } fun decryptBlinded( diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 7cfe862301..057e6a5eff 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -1,13 +1,8 @@ package org.session.libsession.messaging.sending_receiving -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.interfaces.Box -import com.goterl.lazysodium.interfaces.Sign +import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageSender.Error -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -27,24 +22,16 @@ object MessageEncrypter { val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) - val verificationData = plaintext + userED25519KeyPair.publicKey.asBytes + recipientX25519PublicKey - val signature = ByteArray(Sign.BYTES) try { - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) - } catch (exception: Exception) { - Log.d("Loki", "Couldn't sign message due to error: $exception.") - throw Error.SigningFailed - } - val plaintextWithMetadata = plaintext + userED25519KeyPair.publicKey.asBytes + signature - val ciphertext = ByteArray(plaintextWithMetadata.size + Box.SEALBYTES) - try { - sodium.cryptoBoxSeal(ciphertext, plaintextWithMetadata, plaintextWithMetadata.size.toLong(), recipientX25519PublicKey) + return SessionEncrypt.encryptForRecipient( + userED25519KeyPair.secretKey.asBytes, + recipientX25519PublicKey, + plaintext + ).data } catch (exception: Exception) { Log.d("Loki", "Couldn't encrypt message due to error: $exception.") throw Error.EncryptionFailed } - - return ciphertext } internal fun encryptBlinded( @@ -55,25 +42,14 @@ object MessageEncrypter { if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair - val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.SigningFailed val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded()) - // Calculate the shared encryption key, sending from A to B - val encryptionKey = SodiumUtilities.sharedBlindedEncryptionKey( - userEdKeyPair.secretKey.asBytes, - recipientBlindedPublicKey, - blindedKeyPair.publicKey.asBytes, - recipientBlindedPublicKey - ) ?: throw Error.SigningFailed - - // Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) - val message = plaintext + userEdKeyPair.publicKey.asBytes - - // Encrypt using xchacha20-poly1305 - val nonce = sodium.nonce(24) - val ciphertext = SodiumUtilities.encrypt(message, encryptionKey, nonce) ?: throw Error.EncryptionFailed - // data = b'\x00' + ciphertext + nonce - return byteArrayOf(0.toByte()) + ciphertext + nonce + return SessionEncrypt.encryptForBlindedRecipient( + message = plaintext, + myEd25519Privkey = userEdKeyPair.secretKey.asBytes, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + recipientBlindId = byteArrayOf(0x15) + recipientBlindedPublicKey + ).data } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt index f086f3b7e5..dc20b82bc8 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -5,7 +5,6 @@ import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.Hash -import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.KeyPair import org.session.libsignal.utilities.AccountId @@ -25,7 +24,7 @@ object SodiumUtilities { private const val SECRET_KEY_LENGTH: Int = 64 //crypto_sign_secretkeybytes /* 64-byte blake2b hash then reduce to get the blinding factor */ - fun generateBlindingFactor(serverPublicKey: String): ByteArray? { + private fun generateBlindingFactor(serverPublicKey: String): ByteArray? { // k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey) if (serverPubKeyData.size != PUBLIC_KEY_LENGTH) return null @@ -53,7 +52,7 @@ object SodiumUtilities { same secret scalar secret (and so this is just the most convenient way to get 'a' out of a sodium Ed25519 secret key) */ - fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? { + private fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? { // a = s.to_curve25519_private_key().encode() val aBytes = ByteArray(SCALAR_MULT_LENGTH) return if (sodium.convertSecretKeyEd25519ToCurve25519(aBytes, secretKey)) { @@ -145,33 +144,6 @@ object SodiumUtilities { } else null } - /* - Calculate a shared secret for a message from A to B: - BLAKE2b(a kB || kA || kB) - The receiver can calculate the same value via: - BLAKE2b(b kA || kA || kB) - */ - fun sharedBlindedEncryptionKey( - secretKey: ByteArray, - otherBlindedPublicKey: ByteArray, - kA: ByteArray, /*fromBlindedPublicKey*/ - kB: ByteArray /*toBlindedPublicKey*/ - ): ByteArray? { - val aBytes = generatePrivateKeyScalar(secretKey) ?: return null - val combinedKeyBytes = combineKeys(aBytes, otherBlindedPublicKey) ?: return null - val outputHash = ByteArray(GenericHash.KEYBYTES) - val inputBytes = combinedKeyBytes + kA + kB - return if (sodium.cryptoGenericHash( - outputHash, - outputHash.size, - inputBytes, - inputBytes.size.toLong() - ) - ) { - outputHash - } else null - } - /* This method should be used to check if a users standard accountId matches a blinded one */ fun accountId( standardAccountId: String, @@ -246,12 +218,5 @@ object SodiumUtilities { } else null } - fun toX25519(ed25519PublicKey: ByteArray): ByteArray? { - val x25519PublicKey = ByteArray(PUBLIC_KEY_LENGTH) - return if (sodium.convertPublicKeyEd25519ToCurve25519(x25519PublicKey, ed25519PublicKey)) { - x25519PublicKey - } else null - } - } diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt index e762fefcfe..dc3435b65f 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt @@ -1,15 +1,10 @@ package org.session.libsession.snode -import kotlinx.coroutines.GlobalScope -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred import org.session.libsession.snode.OnionRequestAPI.Destination -import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.ThreadUtils +import org.session.libsignal.utilities.toHexString import java.nio.Buffer import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 63ddb01679..eb91326591 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import network.loki.messenger.libsession_util.ED25519 import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind @@ -924,11 +925,11 @@ object SnodeAPI { .plus(serverHashes) .plus(hashes) .toByteArray() - sodium.cryptoSignVerifyDetached( - Base64.decode(signature), - message, - message.size, - snodePublicKey.asBytes + + ED25519.verify( + ed25519PublicKey = snodePublicKey.asBytes, + signature = Base64.decode(signature), + message = message, ) } } @@ -1085,7 +1086,11 @@ object SnodeAPI { val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() - sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) + ED25519.verify( + ed25519PublicKey = snodePublicKey.asBytes, + signature = Base64.decode(signature), + message = message, + ) } } ?: mapOf() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eac8ebbea8..695331135c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ kotlinxDatetimeVersion = "0.6.0" kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.4-2-g602cde7" +libsessionUtilAndroidVersion = "1.0.4-3-ge192c9a" media3ExoplayerVersion = "1.4.0" mockitoCoreVersion = "5.17.0" navVersion = "2.9.0" From 64db965aabf5f9dbc8868c957b087973d8dd7c72 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 22 May 2025 20:05:53 +1000 Subject: [PATCH 335/867] Cleaning up edge to edge on some screens --- .../thoughtcrime/securesms/ShareActivity.kt | 20 +++++++--- .../contacts/ContactSelectionListFragment.kt | 14 +++++++ .../start/NewConversationFragment.kt | 7 ++++ .../start/home/StartConversation.kt | 4 +- .../securesms/home/HomeActivity.kt | 24 +++++------ .../onboarding/loadaccount/LoadAccount.kt | 40 ++++++++++++++----- .../loadaccount/LoadAccountActivity.kt | 11 +++++ .../securesms/preferences/QRCodeActivity.kt | 15 +++++++ .../securesms/ui/components/QR.kt | 2 +- .../securesms/util/ViewUtilities.kt | 20 ++++++---- gradle/libs.versions.toml | 2 +- 11 files changed, 117 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt index 4a2a6c1b4a..38bb0fb686 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -28,12 +28,8 @@ import android.view.MenuItem import android.view.View import android.widget.ImageView import android.widget.TextView -import androidx.activity.addCallback import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import java.io.FileInputStream -import java.io.IOException -import java.lang.IllegalArgumentException import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromExternal @@ -42,7 +38,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ShareActivity.ResolveMediaTask import org.thoughtcrime.securesms.components.SearchToolbar import org.thoughtcrime.securesms.components.SearchToolbar.SearchListener import org.thoughtcrime.securesms.contacts.ContactSelectionListFragment @@ -53,8 +48,11 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import java.io.FileInputStream +import java.io.IOException - // An activity to quickly share content with contacts. +// An activity to quickly share content with contacts. @AndroidEntryPoint class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { @@ -66,6 +64,9 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { const val EXTRA_DISTRIBUTION_TYPE = "distribution_type" } + override val applyDefaultWindowInsets: Boolean + get() = false + // Lateinit UI elements private lateinit var contactsFragment: ContactSelectionListFragment private lateinit var searchToolbar: SearchToolbar @@ -92,6 +93,13 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { initializeResources() initializeSearch() initializeMedia() + + + // only apply inset padding at the top, so the child fragment can allow its recyclerview all the way down + findViewById(android.R.id.content).applySafeInsetsPaddings( + consumeInsets = false, + applyBottom = false, + ) } override fun onNewIntent(intent: Intent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt index 1e3220aaf4..041f4a4569 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt @@ -4,6 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.loader.app.LoaderManager import androidx.loader.content.Loader @@ -13,6 +17,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import javax.inject.Inject @@ -61,6 +66,15 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks + val bottomInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()).bottom + + binding.recyclerView.updatePadding(bottom = bottomInsets) + + // There shouldn't be anything else needing the insets so we'll consume all of them + WindowInsetsCompat.CONSUMED + } } override fun onStop() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 2cdaed0ebe..206e853e7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -79,6 +79,13 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation skipCollapsed = true state = BottomSheetBehavior.STATE_EXPANDED } + + // Set transparent navigation bar on older android version otherwise it colors the navbar + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + window?.apply { + navigationBarColor = android.graphics.Color.TRANSPARENT + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt index 334ba60f54..2c8a1a6c2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -21,6 +22,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate @@ -47,7 +49,7 @@ internal fun StartConversationScreen( Column(modifier = Modifier.background( LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small + shape = MaterialTheme.shapes.small.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) )) { BasicAppBar( title = stringResource(R.string.conversationsStart), diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index b2bb3dd5e8..a4df43a87a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -83,6 +83,7 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut @@ -370,22 +371,15 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> - // Apply status bar insets to the toolbar - binding.toolbar.updatePadding( - top = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top - ) - - val bottomInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()).bottom - - binding.globalSearchRecycler.updatePadding(bottom = bottomInsets) - binding.newConversationButton.updateLayoutParams { - bottomMargin = bottomInsets + resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) + binding.root.applySafeInsetsPaddings( + applyBottom = false, + alsoApply = { insets -> + binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) + binding.newConversationButton.updateLayoutParams { + bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) + } } - - // There shouldn't be anything else needing the insets so we'll consume all of them - WindowInsetsCompat.CONSUMED - } + ) } override fun onCancelClicked() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index ec96992c82..7295142cb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.onboarding.loadaccount import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,10 +16,12 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -45,15 +49,24 @@ internal fun LoadAccountScreen( ) { val pagerState = rememberPagerState { TITLES.size } - Column { - SessionTabRow(pagerState, TITLES) - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f) - ) { page -> - when (TITLES[page]) { - R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) - R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan) + Scaffold { paddingValues -> + Column { + SessionTabRow(pagerState, TITLES) + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + when (TITLES[page]) { + R.string.sessionRecoveryPassword -> RecoveryPassword( + modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()) + .consumeWindowInsets(paddingValues), + state = state, + onChange = onChange, + onContinue = onContinue + ) + + R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan) + } } } } @@ -68,9 +81,14 @@ private fun PreviewRecoveryPassword() { } @Composable -private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) { +private fun RecoveryPassword( + state: State, + modifier: Modifier = Modifier, + onChange: (String) -> Unit = {}, + onContinue: () -> Unit = {} +) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt index 2e4da626fb..c698f3b062 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.start @AndroidEntryPoint @@ -32,8 +33,18 @@ class LoadAccountActivity : BaseActionBarActivity() { private val viewModel: LoadAccountViewModel by viewModels() + override val applyDefaultWindowInsets: Boolean + get() = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // only apply inset padding at the top, so the compose children can choose how to handle the bottom + findViewById(android.R.id.content).applySafeInsetsPaddings( + consumeInsets = false, + applyBottom = false, + ) + supportActionBar?.setTitle(R.string.loadAccount) prefs.setConfigurationMessageSynced(false) prefs.setRestorationTime(System.currentTimeMillis()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index b7025ff526..98d4ef0e0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle +import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -12,6 +13,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -33,16 +37,27 @@ import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.start private val TITLES = listOf(R.string.view, R.string.scan) class QRCodeActivity : ScreenLockActionBarActivity() { + override val applyDefaultWindowInsets: Boolean + get() = false + private val errors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + + // only apply inset padding at the top so that the bottom qr scanning can go all the way + findViewById(android.R.id.content).applySafeInsetsPaddings( + consumeInsets = false, + applyBottom = false, + ) + supportActionBar!!.title = resources.getString(R.string.qrCode) setComposeContent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index a3b508f481..4cd5ef363f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -226,7 +226,7 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { } } ) { padding -> - Box(modifier = Modifier.padding(padding)) { + Box { AndroidView( modifier = Modifier.fillMaxSize(), factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 268f188479..4c275ca886 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -12,15 +12,14 @@ import android.util.Size import android.util.TypedValue import android.view.View import android.view.ViewGroup.MarginLayoutParams -import androidx.annotation.ColorInt -import androidx.annotation.DimenRes -import network.loki.messenger.R -import org.session.libsession.utilities.getColorFromAttr import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.ScrollView import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.core.graphics.Insets import androidx.core.graphics.applyCanvas import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -28,6 +27,8 @@ import androidx.core.view.WindowInsetsCompat.Type.InsetsType import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.R +import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.Log import kotlin.math.roundToInt @@ -138,17 +139,22 @@ fun View.applySafeInsetsPaddings( @InsetsType typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), consumeInsets: Boolean = true, + applyTop: Boolean = true, + applyBottom: Boolean = true, + alsoApply: (Insets) -> Unit = {} ) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getInsets(typeMask) view.updatePadding( left = insets.left, - top = insets.top, + top = if(applyTop) insets.top else 0, right = insets.right, - bottom = insets.bottom + bottom = if(applyBottom) insets.bottom else 0 ) + alsoApply(insets) + if (consumeInsets) { windowInsets.inset(insets) } else { @@ -159,7 +165,7 @@ fun View.applySafeInsetsPaddings( } /** - * Applies the system insets to the view's paddings. + * Applies the system insets to the view's margins. */ @JvmOverloads fun View.applySafeInsetsMargins( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80b9c9413e..7d62f11c0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ assertjCoreVersion = "3.11.1" biometricVersion = "1.1.0" cameraCamera2Version = "1.4.2" cardviewVersion = "1.0.0" -composeBomVersion = "2025.05.00" +composeBomVersion = "2025.05.01" kotlinComposeCompilerVersion = "1.5.15" composeVersion = "1.0.0-beta01" conscryptAndroidVersion = "2.5.3" From 1a9a3c3f2805b2178260a21345e36b6413b307fc Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 23 May 2025 09:31:47 +1000 Subject: [PATCH 336/867] Fix unable to react on community messages (#1193) --- .../securesms/database/LokiMessageDatabase.kt | 14 +++++++------- .../securesms/notifications/PushReceiver.kt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 1df2b81f27..2283612690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -70,9 +70,9 @@ class LokiMessageDatabase(context: Context, helper: Provider - cursor.getInt(serverID) - }?.toLong() + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) { cursor -> + cursor.getLong(serverID) + } } fun deleteMessage(messageID: MessageId) { @@ -80,7 +80,7 @@ class LokiMessageDatabase(context: Context, helper: Provider + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) { cursor -> cursor.getInt(serverID).toLong() } @@ -91,8 +91,8 @@ class LokiMessageDatabase(context: Context, helper: Provider?) { - Log.d("", "Push data received: $dataMap") + Log.d(TAG, "Push data received: $dataMap") addMessageReceiveJob(dataMap?.asPushData()) } From 3a3632773fe515ccf5485e0f51e53334b78e0ae5 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 23 May 2025 10:14:08 +1000 Subject: [PATCH 337/867] Final push to remove libsodium (#1192) --- app/build.gradle.kts | 6 - .../libsession/database/StorageProtocol.kt | 2 +- .../libsession/database/StorageProtocolExt.kt | 5 +- .../messaging/file_server/FileServerApi.kt | 2 +- .../messaging/jobs/BatchMessageReceiveJob.kt | 12 +- .../messaging/jobs/InviteContactsJob.kt | 1 - .../messages/visible/VisibleMessage.kt | 5 +- .../messaging/open_groups/OpenGroupApi.kt | 158 ++++--------- .../messaging/open_groups/OpenGroupMessage.kt | 24 +- .../sending_receiving/MessageDecrypter.kt | 19 +- .../sending_receiving/MessageEncrypter.kt | 4 +- .../sending_receiving/MessageReceiver.kt | 10 +- .../sending_receiving/MessageSender.kt | 20 +- .../ReceivedMessageHandler.kt | 20 +- .../sending_receiving/notifications/Models.kt | 11 - .../messaging/utilities/SodiumUtilities.kt | 222 ------------------ .../libsession/snode/OwnedSwarmAuth.kt | 15 +- .../org/session/libsession/snode/SnodeAPI.kt | 56 +---- .../session/libsession/utilities/AESGCM.kt | 2 +- .../libsignal/utilities/HexEncoding.kt | 2 +- .../conversation/v2/ConversationViewModel.kt | 11 +- .../v2/mention/MentionViewModel.kt | 1 - .../menus/ConversationActionModeCallback.kt | 10 +- .../settings/ConversationSettingsViewModel.kt | 10 +- .../v2/utilities/MentionUtilities.kt | 10 +- .../securesms/crypto/KeyPairUtilities.kt | 38 ++- .../securesms/database/Storage.kt | 38 ++- .../securesms/debugmenu/DebugMenuViewModel.kt | 2 +- .../securesms/dependencies/ConfigFactory.kt | 11 +- .../groups/GroupRevokedMessageHandler.kt | 4 +- .../handler/RemoveGroupMemberHandler.kt | 7 +- .../notifications/DefaultMessageNotifier.kt | 10 +- .../securesms/notifications/PushReceiver.kt | 30 ++- .../securesms/notifications/PushRegistryV2.kt | 3 +- .../securesms/tokenpage/TokenRepository.kt | 2 +- .../v2/ConversationViewModelTest.kt | 3 +- gradle/libs.versions.toml | 4 +- liblazysodium/build.gradle.kts | 2 - liblazysodium/session-lazysodium-android.aar | Bin 790229 -> 0 bytes settings.gradle.kts | 1 - 40 files changed, 247 insertions(+), 546 deletions(-) delete mode 100644 app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt delete mode 100644 liblazysodium/build.gradle.kts delete mode 100644 liblazysodium/session-lazysodium-android.aar diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b0aa0c88f..8bd1786e3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -286,7 +286,6 @@ dependencies { implementation(libs.androidx.sqlite.ktx) implementation(libs.sqlcipher.android) implementation(libs.kotlinx.serialization.json) - implementation(project(":liblazysodium")) implementation(libs.protobuf.java) implementation(libs.jackson.databind) implementation(libs.okhttp) @@ -295,11 +294,6 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kovenant) implementation(libs.kovenant.android) - implementation(libs.jna) { - artifact { - type = "aar" - } - } implementation(libs.opencsv) implementation(libs.androidx.work.runtime.ktx) implementation(libs.rxbinding) diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index f3e5e6c8ab..e48e002a62 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -2,7 +2,7 @@ package org.session.libsession.database import android.content.Context import android.net.Uri -import com.goterl.lazysodium.utils.KeyPair +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt b/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt index 6f900a6761..90f26d1c96 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt @@ -2,14 +2,15 @@ package org.session.libsession.database import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.toHexString val StorageProtocol.userAuth: OwnedSwarmAuth? get() = getUserPublicKey()?.let { accountId -> getUserED25519KeyPair()?.let { keyPair -> OwnedSwarmAuth( accountId = AccountId(hexString = accountId), - ed25519PublicKeyHex = keyPair.publicKey.asHexString, - ed25519PrivateKey = keyPair.secretKey.asBytes + ed25519PublicKeyHex = keyPair.pubKey.data.toHexString(), + ed25519PrivateKey = keyPair.secretKey.data ) } } diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index b000c9d449..b5ee2ae2cc 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -126,7 +126,7 @@ object FileServerApi { */ suspend fun getClientVersion(): VersionData { // Generate the auth signature - val secretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.asBytes + val secretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.data ?: throw (Error.NoEd25519KeyPair) val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index e205d95005..864b2ada6e 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message @@ -29,11 +30,11 @@ import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactio import org.session.libsession.messaging.sending_receiving.handleUnsendRequest import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.UserConfigType import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.model.MessageId @@ -185,12 +186,13 @@ class BatchMessageReceiveJob( is VisibleMessage -> { val isUserBlindedSender = message.sender == serverPublicKey?.let { - SodiumUtilities.blindedKeyPair( - serverPublicKey = it, - edKeyPair = storage.getUserED25519KeyPair()!! + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = storage.getUserED25519KeyPair()!! + .secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), ) }?.let { - AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString + AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } if (message.sender == localUserPublicKey || isUserBlindedSender) { // use sent timestamp here since that is technically the last one we have diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 0a05faf912..707883cbc4 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -15,7 +15,6 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.getGroup diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index 9a143dca27..42059c1c50 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -1,13 +1,10 @@ package org.session.libsession.messaging.messages.visible -import com.goterl.lazysodium.BuildConfig +import network.loki.messenger.BuildConfig import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 2726f1f218..fa74925bd0 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -6,10 +6,11 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.annotation.JsonNaming import com.fasterxml.jackson.databind.type.TypeFactory -import com.goterl.lazysodium.interfaces.GenericHash -import com.goterl.lazysodium.interfaces.Sign import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import okhttp3.Headers.Companion.toHeaders @@ -18,8 +19,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI @@ -27,8 +26,8 @@ import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Base64.decode import org.session.libsignal.utilities.Base64.encodeBytes +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.HTTP.Verb.DELETE import org.session.libsignal.utilities.HTTP.Verb.GET @@ -38,16 +37,13 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.whispersystems.curve25519.Curve25519 +import java.security.SecureRandom import java.util.concurrent.TimeUnit import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set object OpenGroupApi { - private val curve = Curve25519.getInstance(Curve25519.BEST) val defaultRooms = MutableSharedFlow>(replay = 1) private val hasPerformedInitialPoll = mutableMapOf() private var hasUpdatedLastOpenDate = false @@ -313,72 +309,61 @@ object OpenGroupApi { } fun execute(): Promise { val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(request.server) - val publicKey = + val serverPublicKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) ?: return Promise.ofFail(Error.NoPublicKey) val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoEd25519KeyPair) val urlRequest = urlBuilder.toString() val headers = request.headers.toMutableMap() - val nonce = sodium.nonce(16) + val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - var pubKey = "" - var signature = ByteArray(Sign.BYTES) - var bodyHash = ByteArray(0) - if (request.parameters != null) { + val bodyHash = if (request.parameters != null) { val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - val parameterHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - parameterHash, - parameterHash.size, - parameterBytes, - parameterBytes.size.toLong() - ) - ) { - bodyHash = parameterHash - } + Hash.hash64(parameterBytes) } else if (request.body != null) { - val byteHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - byteHash, - byteHash.size, - request.body, - request.body.size.toLong() - ) - ) { - bodyHash = byteHash - } + Hash.hash64(request.body) + } else { + byteArrayOf() } - val messageBytes = Hex.fromStringCondensed(publicKey) + + val messageBytes = Hex.fromStringCondensed(serverPublicKey) .plus(nonce) .plus("$timestamp".toByteArray(Charsets.US_ASCII)) .plus(request.verb.rawValue.toByteArray()) .plus("/${request.endpoint.value}".toByteArray()) .plus(bodyHash) + + val signature: ByteArray + val pubKey: String + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> - pubKey = AccountId( - IdPrefix.BLINDED, - keyPair.publicKey.asBytes - ).hexString - - signature = SodiumUtilities.sogsSignature( - messageBytes, - ed25519KeyPair.secretKey.asBytes, - keyPair.secretKey.asBytes, - keyPair.publicKey.asBytes - ) ?: return Promise.ofFail(Error.SigningFailed) - } ?: return Promise.ofFail(Error.SigningFailed) + pubKey = AccountId( + IdPrefix.BLINDED, + BlindKeyAPI.blind15KeyPair( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey) + ).pubKey.data + ).hexString + + try { + signature = BlindKeyAPI.blind15Sign( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = serverPublicKey, + message = messageBytes + ) + } catch (e: Exception) { + throw Error.SigningFailed + } } else { pubKey = AccountId( IdPrefix.UN_BLINDED, - ed25519KeyPair.publicKey.asBytes + ed25519KeyPair.pubKey.data ).hexString - sodium.cryptoSignDetached( - signature, - messageBytes, - messageBytes.size.toLong(), - ed25519KeyPair.secretKey.asBytes + + signature = ED25519.sign( + ed25519PrivateKey = ed25519KeyPair.secretKey.data, + message = messageBytes ) } headers["X-SOGS-Nonce"] = encodeBytes(nonce) @@ -399,7 +384,7 @@ object OpenGroupApi { requestBuilder.header("Room", request.room) } return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e -> + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> when (e) { // No need for the stack trace for HTTP errors is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") @@ -491,67 +476,6 @@ object OpenGroupApi { } // endregion - // region Messages - fun getMessages(room: String, server: String): Promise, Exception> { - val storage = MessagingModuleConfiguration.shared.storage - val queryParameters = mutableMapOf() - storage.getLastMessageServerID(room, server)?.let { lastId -> - queryParameters += "from_server_id" to lastId.toString() - } - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.RoomMessage(room), - queryParameters = queryParameters - ) - return getResponseBodyJson(request).map { json -> - @Suppress("UNCHECKED_CAST") val rawMessages = - json["messages"] as? List> - ?: throw Error.ParsingFailed - parseMessages(room, server, rawMessages) - } - } - - private fun parseMessages( - room: String, - server: String, - rawMessages: List> - ): List { - val messages = rawMessages.mapNotNull { json -> - json as Map - try { - val message = OpenGroupMessage.fromJSON(json) ?: return@mapNotNull null - if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null - val sender = message.sender - val data = decode(message.base64EncodedData) - val signature = decode(message.base64EncodedSignature) - val publicKey = Hex.fromStringCondensed(sender.removingIdPrefixIfNeeded()) - val isValid = curve.verifySignature(publicKey, data, signature) - if (!isValid) { - Log.d("Loki", "Ignoring message with invalid signature.") - return@mapNotNull null - } - message - } catch (e: Exception) { - null - } - } - return messages - } - - fun getReactors(room: String, server: String, messageId: Long, emoji: String): Promise, Exception> { - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.Reactors(room, messageId, emoji) - ) - return getResponseBody(request).map { response -> - JsonUtil.fromJson(response, Map::class.java) - } - } - fun addReaction(room: String, server: String, messageId: Long, emoji: String): Promise { val request = Request( verb = PUT, diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt index 4db1d407ab..603c076b55 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -1,8 +1,8 @@ package org.session.libsession.messaging.open_groups +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 @@ -55,20 +55,24 @@ data class OpenGroupMessage( val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server) val signature = when { serverCapabilities.contains(Capability.BLIND.name.lowercase()) -> { - val blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.publicKey, userEdKeyPair) ?: return null - SodiumUtilities.sogsSignature( - decode(base64EncodedData), - userEdKeyPair.secretKey.asBytes, - blindedKeyPair.secretKey.asBytes, - blindedKeyPair.publicKey.asBytes - ) ?: return null + runCatching { + BlindKeyAPI.blind15Sign( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = openGroup.publicKey, + message = decode(base64EncodedData) + ) + }.onFailure { + Log.e("OpenGroupMessage", "Failed to sign message with blind key", it) + }.getOrNull() ?: return null } + fallbackSigningType == IdPrefix.UN_BLINDED -> { - curve.calculateSignature(userEdKeyPair.secretKey.asBytes, decode(base64EncodedData)) + curve.calculateSignature(userEdKeyPair.secretKey.data, decode(base64EncodedData)) } + else -> { val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() } - if (sender != publicKey.toHexString() && !userEdKeyPair.publicKey.asHexString.equals(sender?.removingIdPrefixIfNeeded(), true)) return null + if (sender != publicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null try { curve.calculateSignature(privateKey, decode(base64EncodedData)) } catch (e: Exception) { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 61fa165ef2..4ee0cd8bc1 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -1,9 +1,9 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.SessionEncrypt +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log @@ -38,25 +38,30 @@ object MessageDecrypter { otherBlindedPublicKey: String, serverPublicKey: String ): Pair { - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair - val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.DecryptionFailed - val otherKeyBytes = Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) + val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() + ?: throw Error.NoUserED25519KeyPair + val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + ) ?: throw Error.DecryptionFailed + val otherKeyBytes = + Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) val senderKeyBytes: ByteArray val recipientKeyBytes: ByteArray if (isOutgoing) { - senderKeyBytes = blindedKeyPair.publicKey.asBytes + senderKeyBytes = blindedKeyPair.pubKey.data recipientKeyBytes = otherKeyBytes } else { senderKeyBytes = otherKeyBytes - recipientKeyBytes = blindedKeyPair.publicKey.asBytes + recipientKeyBytes = blindedKeyPair.pubKey.data } try { val (sessionId, plainText) = SessionEncrypt.decryptForBlindedRecipient( ciphertext = message, - myEd25519Privkey = userEdKeyPair.secretKey.asBytes, + myEd25519Privkey = userEdKeyPair.secretKey.data, openGroupPubkey = Hex.fromStringCondensed(serverPublicKey), senderBlindedId = byteArrayOf(0x15) + senderKeyBytes, recipientBlindId = byteArrayOf(0x15) + recipientKeyBytes, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 057e6a5eff..17f16ddbfe 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -24,7 +24,7 @@ object MessageEncrypter { try { return SessionEncrypt.encryptForRecipient( - userED25519KeyPair.secretKey.asBytes, + userED25519KeyPair.secretKey.data, recipientX25519PublicKey, plaintext ).data @@ -46,7 +46,7 @@ object MessageEncrypter { return SessionEncrypt.encryptForBlindedRecipient( message = plaintext, - myEd25519Privkey = userEdKeyPair.secretKey.asBytes, + myEd25519Privkey = userEdKeyPair.secretKey.data, serverPubKey = Hex.fromStringCondensed(serverPublicKey), recipientBlindId = byteArrayOf(0x15) + recipientBlindedPublicKey ).data diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 38cc13097e..1cd37eb32b 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage @@ -14,12 +15,12 @@ import org.session.libsession.messaging.messages.control.SharedConfigurationMess import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.SnodeAPI import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import java.util.concurrent.TimeUnit @@ -174,7 +175,12 @@ object MessageReceiver { if (isBlocked(sender!!) && message.shouldDiscardIfBlocked()) { throw Error.SenderBlocked } - val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) }?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } + val isUserBlindedSender = sender == openGroupPublicKey?.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + ) + }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } val isUserSender = sender == userPublicKey if (isUserSender || isUserBlindedSender) { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 5ac03c9d72..ed6b425069 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred @@ -32,7 +33,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage @@ -46,6 +46,7 @@ import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.defaultRequiresAuth @@ -339,17 +340,26 @@ object MessageSender { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server) storage.getOpenGroup(destination.roomToken, destination.server)?.let { - blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + )?.pubKey?.data } } is Destination.OpenGroupInbox -> { serverCapabilities = storage.getServerCapabilities(destination.server) - blindedPublicKey = SodiumUtilities.blindedKeyPair(destination.serverPublicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), + )?.pubKey?.data } is Destination.LegacyOpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server) storage.getOpenGroup(destination.roomToken, destination.server)?.let { - blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + )?.pubKey?.data } } else -> {} @@ -357,7 +367,7 @@ object MessageSender { val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey!!).hexString } else { - AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.publicKey.asBytes).hexString + AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.pubKey.data).hexString } message.sender = messageSender // Set the failure handler (need it here already for precondition failure handling) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index f9487fae5c..bbe34d7c9e 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.Sodium import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration @@ -41,7 +41,6 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -62,6 +61,7 @@ import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional @@ -371,9 +371,12 @@ fun MessageReceiver.handleVisibleMessage( val threadRecipient = storage.getRecipientForThread(threadID) val userBlindedKey = openGroupID?.let { val openGroup = storage.getOpenGroup(threadID) ?: return@let null - val blindedKey = SodiumUtilities.blindedKeyPair(openGroup.publicKey, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) ?: return@let null + val blindedKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(openGroup.publicKey), + ) ?: return@let null AccountId( - IdPrefix.BLINDED, blindedKey.publicKey.asBytes + IdPrefix.BLINDED, blindedKey.pubKey.data ).hexString } // Update profile if needed @@ -545,8 +548,11 @@ fun MessageReceiver.handleOpenGroupReactions( val userPublicKey = storage.getUserPublicKey()!! val openGroup = storage.getOpenGroup(threadId) val blindedPublicKey = openGroup?.publicKey?.let { serverPublicKey -> - SodiumUtilities.blindedKeyPair(serverPublicKey, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) - ?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + ) + ?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } } for ((emoji, reaction) in reactions) { val pendingUserReaction = OpenGroupApi.pendingReactions @@ -765,7 +771,7 @@ private fun handlePromotionMessage(message: GroupUpdated) { try { MessagingModuleConfiguration.shared.groupManagerV2 .handlePromotion( - groupId = AccountId(IdPrefix.GROUP, Sodium.ed25519KeyPair(seed).pubKey.data), + groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), groupName = promotion.name, adminKeySeed = seed, promoter = adminId, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt index 67ad8d0553..0f7134877f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt @@ -1,6 +1,5 @@ package org.session.libsession.messaging.sending_receiving.notifications -import com.goterl.lazysodium.utils.Key import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -111,13 +110,3 @@ data class PushNotificationMetadata( val data_too_long : Boolean = false ) -@Serializable -data class PushNotificationServerObject( - val enc_payload: String, - val spns: Int, -) { - fun decryptPayload(key: Key): Any { - - TODO() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt deleted file mode 100644 index dc20b82bc8..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ /dev/null @@ -1,222 +0,0 @@ -package org.session.libsession.messaging.utilities - -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.interfaces.AEAD -import com.goterl.lazysodium.interfaces.GenericHash -import com.goterl.lazysodium.interfaces.Hash -import com.goterl.lazysodium.utils.Key -import com.goterl.lazysodium.utils.KeyPair -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix -import org.whispersystems.curve25519.Curve25519 -import kotlin.experimental.xor - -object SodiumUtilities { - val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - private val curve by lazy { Curve25519.getInstance(Curve25519.BEST) } - - private const val SCALAR_LENGTH: Int = 32 // crypto_core_ed25519_scalarbytes - private const val NO_CLAMP_LENGTH: Int = 32 // crypto_scalarmult_ed25519_bytes - private const val SCALAR_MULT_LENGTH: Int = 32 // crypto_scalarmult_bytes - private const val PUBLIC_KEY_LENGTH: Int = 32 // crypto_scalarmult_bytes - private const val SECRET_KEY_LENGTH: Int = 64 //crypto_sign_secretkeybytes - - /* 64-byte blake2b hash then reduce to get the blinding factor */ - private fun generateBlindingFactor(serverPublicKey: String): ByteArray? { - // k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) - val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey) - if (serverPubKeyData.size != PUBLIC_KEY_LENGTH) return null - val serverPubKeyHash = ByteArray(GenericHash.BLAKE2B_BYTES_MAX) - if (!sodium.cryptoGenericHash( - serverPubKeyHash, - serverPubKeyHash.size, - serverPubKeyData, - serverPubKeyData.size.toLong() - ) - ) { - return null - } - // Reduce the server public key into an ed25519 scalar (`k`) - val x25519PublicKey = ByteArray(SCALAR_LENGTH) - sodium.cryptoCoreEd25519ScalarReduce(x25519PublicKey, serverPubKeyHash) - return if (x25519PublicKey.any { it.toInt() != 0 }) { - x25519PublicKey - } else null - } - - /* - Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to - convert to an *x* secret key, which seems wrong--but isn't because converted keys use the - same secret scalar secret (and so this is just the most convenient way to get 'a' out of - a sodium Ed25519 secret key) - */ - private fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? { - // a = s.to_curve25519_private_key().encode() - val aBytes = ByteArray(SCALAR_MULT_LENGTH) - return if (sodium.convertSecretKeyEd25519ToCurve25519(aBytes, secretKey)) { - aBytes - } else null - } - - /* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */ - @JvmStatic - fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? { - if (edKeyPair.publicKey.asBytes.size != PUBLIC_KEY_LENGTH || edKeyPair.secretKey.asBytes.size != SECRET_KEY_LENGTH) return null - val kBytes = generateBlindingFactor(serverPublicKey) ?: return null - val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes) ?: return null - // Generate the blinded key pair `ka`, `kA` - val kaBytes = ByteArray(SECRET_KEY_LENGTH) - sodium.cryptoCoreEd25519ScalarMul(kaBytes, kBytes, aBytes) - if (kaBytes.all { it.toInt() == 0 }) return null - - val kABytes = ByteArray(PUBLIC_KEY_LENGTH) - return if (sodium.cryptoScalarMultEd25519BaseNoClamp(kABytes, kaBytes)) { - KeyPair(Key.fromBytes(kABytes), Key.fromBytes(kaBytes)) - } else { - null - } - } - - /* - Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the - construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded - pubkeys (this doesn't affect verification at all) - */ - fun sogsSignature( - message: ByteArray, - secretKey: ByteArray, - blindedSecretKey: ByteArray, /*ka*/ - blindedPublicKey: ByteArray /*kA*/ - ): ByteArray? { - // H_rh = sha512(s.encode()).digest()[32:] - val digest = ByteArray(Hash.SHA512_BYTES) - val h_rh = if (sodium.cryptoHashSha512(digest, secretKey, secretKey.size.toLong())) { - digest.takeLast(32).toByteArray() - } else return null - - // r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) - val rHash = sha512Multipart(listOf(h_rh, blindedPublicKey, message)) ?: return null - val r = ByteArray(SCALAR_LENGTH) - sodium.cryptoCoreEd25519ScalarReduce(r, rHash) - if (r.all { it.toInt() == 0 }) return null - - // sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) - val sig_R = ByteArray(NO_CLAMP_LENGTH) - if (!sodium.cryptoScalarMultEd25519BaseNoClamp(sig_R, r)) return null - - // HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) - val hRamHash = sha512Multipart(listOf(sig_R, blindedPublicKey, message)) ?: return null - val hRam = ByteArray(SCALAR_LENGTH) - sodium.cryptoCoreEd25519ScalarReduce(hRam, hRamHash) - if (hRam.all { it.toInt() == 0 }) return null - - // sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) - val sig_sMul = ByteArray(SCALAR_LENGTH) - val sig_s = ByteArray(SCALAR_LENGTH) - sodium.cryptoCoreEd25519ScalarMul(sig_sMul, hRam, blindedSecretKey) - if (sig_sMul.any { it.toInt() != 0 }) { - sodium.cryptoCoreEd25519ScalarAdd(sig_s, r, sig_sMul) - if (sig_s.all { it.toInt() == 0 }) return null - } else return null - - return sig_R + sig_s - } - - private fun sha512Multipart(parts: List): ByteArray? { - val state = Hash.State512() - sodium.cryptoHashSha512Init(state) - parts.forEach { - sodium.cryptoHashSha512Update(state, it, it.size.toLong()) - } - val finalHash = ByteArray(Hash.SHA512_BYTES) - return if (sodium.cryptoHashSha512Final(state, finalHash)) { - finalHash - } else null - } - - /* Combines two keys (`kA`) */ - fun combineKeys(lhsKey: ByteArray, rhsKey: ByteArray): ByteArray? { - val kA = ByteArray(NO_CLAMP_LENGTH) - return if (sodium.cryptoScalarMultEd25519NoClamp(kA, lhsKey, rhsKey)) { - kA - } else null - } - - /* This method should be used to check if a users standard accountId matches a blinded one */ - fun accountId( - standardAccountId: String, - blindedAccountId: String, - serverPublicKey: String - ): Boolean { - if (standardAccountId.isBlank() || blindedAccountId.isBlank() || serverPublicKey.isBlank()) { - return false - } - - // Only support generating blinded keys for standard account ids - val accountId = AccountId.fromString(standardAccountId) - if (accountId?.prefix != IdPrefix.STANDARD) return false - val blindedId = AccountId.fromString(blindedAccountId) - if (blindedId?.prefix != IdPrefix.BLINDED) return false - val k = generateBlindingFactor(serverPublicKey) ?: return false - - // From the account id (ignoring 05 prefix) we have two possible ed25519 pubkeys; - // the first is the positive (which is what Signal's XEd25519 conversion always uses) - val xEd25519Key = - curve.convertToEd25519PublicKey(accountId.pubKeyBytes) - - // Blind the positive public key - val pk1 = combineKeys(k, xEd25519Key) ?: return false - - // For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 - // pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) - val pk2 = pk1.take(31).toByteArray() + listOf(pk1.last().xor(128.toByte())).toByteArray() - return AccountId(IdPrefix.BLINDED, pk1).hexString == blindedId.hexString || - AccountId(IdPrefix.BLINDED, pk2).hexString == blindedId.hexString - } - - fun encrypt( - message: ByteArray, - secretKey: ByteArray, - nonce: ByteArray, - additionalData: ByteArray? = null - ): ByteArray? { - val authenticatedCipherText = ByteArray(message.size + AEAD.CHACHA20POLY1305_ABYTES) - return if (sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( - authenticatedCipherText, - longArrayOf(0), - message, - message.size.toLong(), - additionalData, - (additionalData?.size ?: 0).toLong(), - null, - nonce, - secretKey - ) - ) { - authenticatedCipherText - } else null - } - - fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? { - val plaintextSize = ciphertext.size - AEAD.XCHACHA20POLY1305_IETF_ABYTES - val plaintext = ByteArray(plaintextSize) - return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( - plaintext, - longArrayOf(plaintextSize.toLong()), - null, - ciphertext, - ciphertext.size.toLong(), - null, - 0L, - nonce, - decryptionKey - ) - ) { - plaintext - } else null - } - -} - diff --git a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt index 814f36d331..6188ea17b6 100644 --- a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt +++ b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt @@ -1,7 +1,6 @@ package org.session.libsession.snode -import com.goterl.lazysodium.interfaces.Sign -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import network.loki.messenger.libsession_util.ED25519 import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 @@ -15,18 +14,8 @@ class OwnedSwarmAuth( override val ed25519PublicKeyHex: String?, val ed25519PrivateKey: ByteArray, ) : SwarmAuth { - init { - check(ed25519PrivateKey.size == Sign.SECRETKEYBYTES) { - "Invalid secret key size, expecting ${Sign.SECRETKEYBYTES} but got ${ed25519PrivateKey.size}" - } - } - override fun sign(data: ByteArray): Map { - val signature = Base64.encodeBytes(ByteArray(Sign.BYTES).also { - check(sodium.cryptoSignDetached(it, data, data.size.toLong(), ed25519PrivateKey)) { - "Failed to sign data" - } - }) + val signature = Base64.encodeBytes(ED25519.sign(ed25519PrivateKey = ed25519PrivateKey, message = data)) return buildMap { put("signature", signature) diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index eb91326591..29f8949a9c 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -4,11 +4,6 @@ package org.session.libsession.snode import android.os.SystemClock import com.fasterxml.jackson.databind.JsonNode -import com.goterl.lazysodium.exceptions.SodiumException -import com.goterl.lazysodium.interfaces.GenericHash -import com.goterl.lazysodium.interfaces.PwHash -import com.goterl.lazysodium.interfaces.SecretBox -import com.goterl.lazysodium.utils.Key import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,6 +14,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.SessionEncrypt import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind @@ -26,7 +23,6 @@ import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.snode.utilities.asyncPromise @@ -249,18 +245,12 @@ object SnodeAPI { val accountIDByteCount = 33 // Hash the ONS name using BLAKE2b val onsName = onsName.lowercase(Locale.US) - val nameAsData = onsName.toByteArray() - val nameHash = ByteArray(GenericHash.BYTES) - if (!sodium.cryptoGenericHash(nameHash, nameHash.size, nameAsData, nameAsData.size.toLong())) { - throw Error.HashingFailed - } - val base64EncodedNameHash = Base64.encodeBytes(nameHash) // Ask 3 different snodes for the Account ID associated with the given name hash val parameters = buildMap { this["endpoint"] = "ons_resolve" this["params"] = buildMap { this["type"] = 0 - this["name_hash"] = base64EncodedNameHash + this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsName.toByteArray())) } } val promises = List(validationCount) { @@ -275,34 +265,12 @@ object SnodeAPI { val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic val hexEncodedCiphertext = intermediate["encrypted_value"] as? String ?: throw Error.Generic val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val isArgon2Based = (intermediate["nonce"] == null) - if (isArgon2Based) { - // Handle old Argon2-based encryption used before HF16 - val salt = ByteArray(PwHash.SALTBYTES) - val nonce = ByteArray(SecretBox.NONCEBYTES) - val accountIDAsData = ByteArray(accountIDByteCount) - val key = try { - Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes - } catch (e: SodiumException) { - throw Error.HashingFailed - } - if (!sodium.cryptoSecretBoxOpenEasy(accountIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { - throw Error.DecryptionFailed - } - Hex.toStringCondensed(accountIDAsData) - } else { - val hexEncodedNonce = intermediate["nonce"] as? String ?: throw Error.Generic - val nonce = Hex.fromStringCondensed(hexEncodedNonce) - val key = ByteArray(GenericHash.BYTES) - if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { - throw Error.HashingFailed - } - val accountIDAsData = ByteArray(accountIDByteCount) - if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(accountIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { - throw Error.DecryptionFailed - } - Hex.toStringCondensed(accountIDAsData) - } + val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) + SessionEncrypt.decryptOnsResponse( + lowercaseName = onsName, + ciphertext = ciphertext, + nonce = nonce + ) }.takeIf { it.size == validationCount && it.toSet().size == 1 }?.first() ?: throw Error.ValidationFailed } @@ -919,7 +887,6 @@ object SnodeAPI { // Hashes of deleted messages val hashes = json["deleted"] as List val signature = json["signature"] as String - val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) val message = sequenceOf(swarmAuth.accountId.hexString) .plus(serverHashes) @@ -927,7 +894,7 @@ object SnodeAPI { .toByteArray() ED25519.verify( - ed25519PublicKey = snodePublicKey.asBytes, + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), signature = Base64.decode(signature), message = message, ) @@ -1083,11 +1050,10 @@ object SnodeAPI { } else { val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages val signature = json["signature"] as String - val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() ED25519.verify( - ed25519PublicKey = snodePublicKey.asBytes, + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), signature = Base64.decode(signature), message = message, ) diff --git a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt index 4a6a588dc2..e4438c577b 100644 --- a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt +++ b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -38,7 +38,7 @@ internal object AESGCM { /** * Sync. Don't call from the main thread. */ - internal fun generateSymmetricKey(x25519PublicKey: ByteArray, x25519PrivateKey: ByteArray): ByteArray { + private fun generateSymmetricKey(x25519PublicKey: ByteArray, x25519PrivateKey: ByteArray): ByteArray { val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, x25519PrivateKey) val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256")) diff --git a/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt b/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt index 07b6ccced7..b998e8e6a7 100644 --- a/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt @@ -4,7 +4,7 @@ import org.session.libsignal.crypto.IdentityKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair fun ByteArray.toHexString(): String { - return joinToString("") { String.format("%02x", it) } + return Hex.toStringCondensed(this) } val IdentityKeyPair.hexEncodedPublicKey: String diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index a260d67ddf..182d4fb200 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.bumptech.glide.Glide -import com.goterl.lazysodium.utils.KeyPair import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -31,7 +30,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -40,7 +41,6 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ExpirationUtil @@ -53,10 +53,10 @@ import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.getType import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.audio.AudioSlidePlayer -import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase @@ -227,7 +227,10 @@ class ConversationViewModel( val blindedPublicKey: String? get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { - SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(openGroup!!.publicKey), + )?.pubKey?.data ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index 9fa73151e4..45dc18ba3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 401482ce2b..0dfaeb6d07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.conversation.v2.menus import android.content.Context import android.view.ActionMode import android.view.ContextThemeWrapper -import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.IdPrefix @@ -22,6 +20,8 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager import androidx.core.view.size import androidx.core.view.get +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsignal.utilities.Hex class ConversationActionModeCallback( private val adapter: ConversationAdapter, @@ -59,7 +59,11 @@ class ConversationActionModeCallback( val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! - val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } + val blindedPublicKey = openGroup?.publicKey?.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + )?.pubKey?.data } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient && diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index f07cfd6af1..fc2e49eb97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -10,7 +10,6 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity.CLIPBOARD_SERVICE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import app.cash.copper.flow.observeQuery import com.bumptech.glide.Glide import com.squareup.phrase.Phrase @@ -38,13 +37,13 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification @@ -57,6 +56,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 @@ -491,7 +491,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( else{ val userPublicKey = textSecurePreferences.getLocalNumber() ?: return false val keyPair = storage.getUserED25519KeyPair() ?: return false - val blindedPublicKey = community!!.publicKey.let { SodiumUtilities.blindedKeyPair(it, keyPair)?.publicKey?.asBytes } + val blindedPublicKey = community!!.publicKey.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = keyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + )?.pubKey?.data } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString return OpenGroupManager.isUserModerator(context, community!!.id, userPublicKey, blindedPublicKey) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 39301cd69f..fe3cd91c1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -8,10 +8,10 @@ import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr @@ -158,7 +158,13 @@ object MentionUtilities { } private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + val isUserBlindedPublicKey = openGroup?.let { + BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = userPublicKey, + blindedId = mentionedPublicKey, + serverPubKey = it.publicKey + ) + } ?: false return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt index f4887e1adb..bd90022bad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt @@ -1,52 +1,46 @@ package org.thoughtcrime.securesms.crypto import android.content.Context -import com.goterl.lazysodium.utils.Key -import com.goterl.lazysodium.utils.KeyPair -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex +import java.security.SecureRandom object KeyPairUtilities { fun generate(): KeyPairGenerationResult { - val seed = sodium.randomBytesBuf(16) - try { - return generate(seed) - } catch (exception: Exception) { - return generate() + val seed = ByteArray(16).also { + SecureRandom().nextBytes(it) } + + return generate(seed) } fun generate(seed: ByteArray): KeyPairGenerationResult { - val padding = ByteArray(16) { 0 } - val ed25519KeyPair = sodium.cryptoSignSeedKeypair(seed + padding) - val sodiumX25519KeyPair = sodium.convertKeyPairEd25519ToCurve25519(ed25519KeyPair) - val x25519KeyPair = ECKeyPair(DjbECPublicKey(sodiumX25519KeyPair.publicKey.asBytes), DjbECPrivateKey(sodiumX25519KeyPair.secretKey.asBytes)) - return KeyPairGenerationResult(seed, ed25519KeyPair, x25519KeyPair) + val paddedSeed = seed + ByteArray(16) + val ed25519KeyPair = ED25519.generate(paddedSeed) + val x25519KeyPair = Curve25519.fromED25519(ed25519KeyPair) + return KeyPairGenerationResult(seed, ed25519KeyPair, + ECKeyPair(DjbECPublicKey(x25519KeyPair.pubKey.data), DjbECPrivateKey(x25519KeyPair.secretKey.data))) } fun store(context: Context, seed: ByteArray, ed25519KeyPair: KeyPair, x25519KeyPair: ECKeyPair) { IdentityKeyUtil.save(context, IdentityKeyUtil.LOKI_SEED, Hex.toStringCondensed(seed)) IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(x25519KeyPair.publicKey.serialize())) IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(x25519KeyPair.privateKey.serialize())) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair.publicKey.asBytes)) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair.secretKey.asBytes)) - } - - fun hasV2KeyPair(context: Context): Boolean { - return (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_SECRET_KEY) != null) + IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair.pubKey.data)) + IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair.secretKey.data)) } fun getUserED25519KeyPair(context: Context): KeyPair? { val base64EncodedED25519PublicKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_PUBLIC_KEY) ?: return null val base64EncodedED25519SecretKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_SECRET_KEY) ?: return null - val ed25519PublicKey = Key.fromBytes(Base64.decode(base64EncodedED25519PublicKey)) - val ed25519SecretKey = Key.fromBytes(Base64.decode(base64EncodedED25519SecretKey)) - return KeyPair(ed25519PublicKey, ed25519SecretKey) + return KeyPair(pubKey = Base64.decode(base64EncodedED25519PublicKey), secretKey = Base64.decode(base64EncodedED25519SecretKey)) } data class KeyPairGenerationResult( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index e53c6d307d..8d3c0d05e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,15 +2,16 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri -import com.goterl.lazysodium.utils.KeyPair import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.KeyPair import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.MessageDataProvider @@ -50,7 +51,6 @@ import org.session.libsession.messaging.sending_receiving.data_extraction.DataEx import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeClock @@ -206,7 +206,10 @@ open class Storage @Inject constructor( val userKeyPair = getUserED25519KeyPair() ?: return null return AccountId( IdPrefix.BLINDED, - SodiumUtilities.blindedKeyPair(serverPublicKey, userKeyPair)!!.publicKey.asBytes + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + )!!.pubKey.data ) } @@ -368,7 +371,13 @@ open class Storage @Inject constructor( val senderAddress = fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey - ?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false + ?.let { + BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = getUserPublicKey()!!, + blindedId = message.sender!!, + serverPubKey = it + ) + } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) groupPublicKey != null && groupPublicKey.startsWith(IdPrefix.GROUP.value) -> { @@ -1566,7 +1575,12 @@ open class Storage @Inject constructor( } } for (mapping in mappings) { - if (!SodiumUtilities.accountId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { + if (!BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = senderPublicKey, + blindedId = mapping.value.blindedId, + serverPubKey = mapping.value.serverId + ) + ) { continue } mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey)) @@ -1727,14 +1741,24 @@ open class Storage @Inject constructor( } getAllContacts().forEach { contact -> val accountId = AccountId(contact.accountID) - if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) { + if (accountId.prefix == IdPrefix.STANDARD && BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = accountId.hexString, + blindedId = blindedId, + serverPubKey = serverPublicKey + ) + ) { val contactMapping = mapping.copy(accountId = accountId.hexString) db.addBlindedIdMapping(contactMapping) return contactMapping } } db.getBlindedIdMappingsExceptFor(server).forEach { - if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) { + if (BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = it.accountId!!, + blindedId = blindedId, + serverPubKey = serverPublicKey + ) + ) { val otherMapping = mapping.copy(accountId = it.accountId) db.addBlindedIdMapping(otherMapping) return otherMapping diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 7f5aabd4f1..285bb07f8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -97,7 +97,7 @@ class DebugMenuViewModel @Inject constructor( } is Commands.Copy07PrefixedBlindedPublicKey -> { - val secretKey = storage.getUserED25519KeyPair()?.secretKey?.asBytes + val secretKey = storage.getUserED25519KeyPair()?.secretKey?.data ?: throw (FileServerApi.Error.NoEd25519KeyPair) val userBlindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index ff030a8423..c4631e6d65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.Curve25519 import network.loki.messenger.libsession_util.GroupInfoConfig import network.loki.messenger.libsession_util.GroupKeysConfig import network.loki.messenger.libsession_util.GroupMembersConfig @@ -25,7 +26,7 @@ import network.loki.messenger.libsession_util.util.ConfigPush import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo -import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.control.ConfigurationMessage @@ -104,7 +105,7 @@ class ConfigFactory @Inject constructor( }) private fun requiresCurrentUserED25519SecKey(): ByteArray = - requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.data) { "No logged in user" } @@ -344,13 +345,13 @@ class ConfigFactory @Inject constructor( domain: String, closedGroupSessionId: AccountId ): ByteArray? { - return Sodium.decryptForMultipleSimple( + return MultiEncrypt.decryptForMultipleSimple( encoded = encoded, - ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.data) { "No logged in user" }, domain = domain, - senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes) + senderPubKey = Curve25519.pubKeyFromED25519(closedGroupSessionId.pubKeyBytes) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt index eee2117809..5153ceb531 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.groups -import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.ConfigFactoryProtocol @@ -22,7 +22,7 @@ class GroupRevokedMessageHandler @Inject constructor( rawMessages.forEach { data -> val decoded = configFactoryProtocol.decryptForUser( data, - Sodium.KICKED_DOMAIN, + MultiEncrypt.KICKED_DOMAIN, groupId, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index eeac756073..10e0f24b47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -16,14 +16,13 @@ import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.ReadableGroupKeysConfig import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.GroupMember -import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock @@ -257,7 +256,7 @@ class RemoveGroupMemberHandler @Inject constructor( ) = SnodeMessage( recipient = groupAccountId, data = Base64.encodeBytes( - Sodium.encryptForMultipleSimple( + MultiEncrypt.encryptForMultipleSimple( messages = Array(pendingRemovals.size) { AccountId(pendingRemovals[it].accountId()).pubKeyBytes .plus(keys.currentGeneration().toString().toByteArray()) @@ -266,7 +265,7 @@ class RemoveGroupMemberHandler @Inject constructor( AccountId(pendingRemovals[it].accountId()).pubKeyBytes }, ed25519SecretKey = adminKey, - domain = Sodium.KICKED_DOMAIN + domain = MultiEncrypt.KICKED_DOMAIN ) ), ttl = SnodeMessage.DEFAULT_TTL, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index 21293ec019..8c0761b57c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -39,8 +39,8 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.Volatile import me.leolin.shortcutbadger.ShortcutBadger import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY @@ -52,6 +52,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotifi import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util @@ -570,9 +571,12 @@ class DefaultMessageNotifier( val openGroup = lokiThreadDatabase.getOpenGroupChat(threadId) val edKeyPair = getUserED25519KeyPair(context) if (openGroup != null && edKeyPair != null) { - val blindedKeyPair = blindedKeyPair(openGroup.publicKey, edKeyPair) + val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(openGroup.publicKey), + ) if (blindedKeyPair != null) { - return AccountId(IdPrefix.BLINDED, blindedKeyPair.publicKey.asBytes).hexString + return AccountId(IdPrefix.BLINDED, blindedKeyPair.pubKey.data).hexString } } return null diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index d7a650fc65..4c262a4dbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -9,22 +9,20 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString -import com.goterl.lazysodium.interfaces.AEAD -import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.SessionEncrypt +import okio.ByteString.Companion.decodeHex import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList @@ -34,10 +32,12 @@ import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler +import org.thoughtcrime.securesms.home.HomeActivity +import java.security.SecureRandom import javax.inject.Inject private const val TAG = "PushHandler" @@ -227,13 +227,11 @@ class PushReceiver @Inject constructor( Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() - val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES) - val payload = - encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size) - val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) - ?: error("Failed to decrypt push notification") - val contentEndedAt = padded.indexOfLast { it.toInt() != 0 } - val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded + val decrypted = SessionEncrypt.decryptPushNotification( + message = encPayload, + secretKey = encKey + ).data + val bencoded = Bencode.Decoder(decrypted) val expectedList = (bencoded.decode() as? BencodeList)?.values ?: error("Failed to decode bencoded list from payload") @@ -251,15 +249,15 @@ class PushReceiver @Inject constructor( } } - fun getOrCreateNotificationKey(): Key { + fun getOrCreateNotificationKey(): ByteArray { val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) if (keyHex != null) { - return Key.fromHexString(keyHex) + return keyHex.decodeHex().toByteArray() } // generate the key and store it - val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + val key = ByteArray(32).also { SecureRandom().nextBytes(it) } + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.toHexString()) return key } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index aeb128093d..b28f1211c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -26,6 +26,7 @@ import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device import org.session.libsignal.utilities.retryWithUniformInterval +import org.session.libsignal.utilities.toHexString import javax.inject.Inject import javax.inject.Singleton @@ -58,7 +59,7 @@ class PushRegistryV2 @Inject constructor( service = device.service, sig_ts = timestamp, service_info = mapOf("token" to token), - enc_key = pnKey.asHexString, + enc_key = pnKey.toHexString(), ).let(Json::encodeToJsonElement).jsonObject + signed val response = retryResponseBody( diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index 1ab9b16547..2d306f2826 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -35,7 +35,7 @@ class TokenRepositoryImpl @Inject constructor( private val SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" private val secretKey by lazy { - storage.getUserED25519KeyPair()?.secretKey?.asBytes + storage.getUserED25519KeyPair()?.secretKey?.data ?: throw (FileServerApi.Error.NoEd25519KeyPair) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index d982b59283..aa9376b102 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -2,13 +2,12 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.ContentResolver -import android.content.Context import app.cash.copper.Query -import com.goterl.lazysodium.utils.KeyPair import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first +import network.loki.messenger.libsession_util.util.KeyPair import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.notNullValue import org.hamcrest.CoreMatchers.nullValue diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 695331135c..44ac78e10d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,14 +32,13 @@ fragmentKtxVersion = "1.8.6" gradlePluginVersion = "8.10.0" dependenciesAnalysisVersion = "2.17.0" googleServicesVersion = "4.4.2" -jnaVersion = "5.17.0" junit = "1.1.5" kotlinVersion = "2.1.10" kotlinxDatetimeVersion = "0.6.0" kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.4-3-ge192c9a" +libsessionUtilAndroidVersion = "1.0.4-4-g81b2127" media3ExoplayerVersion = "1.4.0" mockitoCoreVersion = "5.17.0" navVersion = "2.9.0" @@ -129,7 +128,6 @@ libsession-util-android = { module = "org.sessionfoundation:libsession-util-andr zxing-core = { module = "com.google.zxing:core", version.ref = "zxingVersion" } curve25519-java = { module = "com.github.session-foundation.session-android-curve-25519:curve25519-java", version.ref = "curve25519JavaVersion" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabindVersion" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jnaVersion" } junit = { module = "junit:junit", version.ref = "junitVersion" } kinkerapps-android-smsmms = { module = "com.klinkerapps:android-smsmms", version.ref = "androidSmsmmsVersion" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityKtxVersion" } diff --git a/liblazysodium/build.gradle.kts b/liblazysodium/build.gradle.kts deleted file mode 100644 index e0bf9f1310..0000000000 --- a/liblazysodium/build.gradle.kts +++ /dev/null @@ -1,2 +0,0 @@ -configurations.maybeCreate("default") -artifacts.add("default", file("session-lazysodium-android.aar")) \ No newline at end of file diff --git a/liblazysodium/session-lazysodium-android.aar b/liblazysodium/session-lazysodium-android.aar deleted file mode 100644 index aadcac7aadf7f568363a4ddaf7c13de77e3d9c1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 790229 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%0AWd7`eR>-FU;R?Drv+gJVeL!u z^al?H)J6AXu7Bb_>Jrm9! z3exQRDk{2dS50(A$P&a|QqP~8z^)7=*j9~Bq+TL_MPY`)(J>)lfo^3RMkQG_Z9sic zBnxu?jP)@SeF@nwN6coDttHRvqHBVzqPJ0@laP2c6@TbTy4|w$^|dD1mJp3AOrzf# z1Vg+xj;9YJLpeDHKZ}l)pV9Ib4BnqdJV}izhynj?4%A()hhIP_RD2#{UfmRxJLvii zSjwO*&l&AN0%)L4^TPM0sKi;8j{`YD0?oKN0!MilXIl$q zzr){x2{I%94nM%3PAL8X|KGUF{Wa!m^`DRj1_4=zMWKm^h=Q$*E(w6_Lja;=n1PoN zPotv_`8zt+CJ}}Yf<{*|`_ma4U+z6UzsDPk;@)&;l&C4spD^Haqh$Bb??% z_Ch$qkLO2B5a%0np49@Ge7Az7$=zRa)^Ng;5fR8{sPfD*XCG^|`)eSgLG5ol_VI7K z!H$G1At=8Ai#znaTICT3Z0U3!KyD%|8}m<}^d1_eAh5qp3A4snVk(nL7mYM)`&8ZabQM&G zTomz2W=n}~+S;rhR%Yq~onghg-vuyp4P|x-UGFXXvVYoZzD8qzmoGT_TRVF7c3R{q zxh#ap<>|mDiD`j+Toh^@&T<6E$w+I@E1=`l>3aV(beIf89RCf;b9Jq+8v-F?awyuuP(h zBNqB6N>B0cFwrX?7<|QmpSS`KC&ut_^+xn*T2e&tYlPxo8Kb)ZpUA27LrxjyNxdtK z6-%pDeL|(`mSsQxHVQy@4_y0s7ghafoc*u-mJ#>&IGyhlL{6^Jbv}?J84W{jp`kk< zn7#?m+K@RBUi{}U!0+#2fQj$|6IHg>4G(*g&6wHv^Ru@XP(yD@BvN<6DQsyuZb(P1 z<|{kIOC%$PYNSXetaaFg`-a~6bBf5A>Vc}!z>kMyh4T4Y?X{<_$N?j;IK7?W5#=bA z5@yi6nezJMs3e<9)7z%5=rB|!?k{Yt1z&rY%!IfQ6rD*Q0@+TS7INqWtjtzwwms7q zY`M-D6cIlV)1tlsiuDy#vv0?;E;b;rx@bZYVcg-8xg93*Kw3`PY90;6qZ>6cLZ=k& zhit6v>XGqd|D4QWcVv&ETtw%ztng%OrKfO6CsL{mn~z9F`09aN(H{_$^7*&GJu1Q^ zi%+Ynq;>{PNm-JQ>T4W+bPkE;mu|f#i^hRV$_e%wC29^P)W?p??0N?Jm7n=sHA2>Z zv(5b%KYtm#49qP5@7vtnKV9A$F`nF6^W*+9Sw3z@z8QZMSd9boWXD-p-aCi=Q_1DBqEySf6V{F zAiy|UAF{_teUHZ?21hFFoj||SJee$6TT#;@zDH{SMLq}#5Wzwlb@B)LzUoW9Yj@_`AKKc7MZzv*>*rjAVd5s zHHM+Y0&h2U>u>#9tC*k{y0GpX$**31rz|*0ks;>{bkm@h+}ViikVQbZq&TpH53q6X4FYe;6Li5;!W8l^cYa9zevD zhhS1#FYlsPt?=U&JpL4A0Ds&d(!8V$y8sU{yygOn~4k;y3h_V5RVG ze3E3ZbA};CgfsX!rJw*WjW9|%ZVVHtbwEFR?E!kO7z1qA>mcLk8pr_GA&cQ&ieo=S z)|a`3c*<%ecYuQ`Khb5tydjJv2lFEy zyxdK0uRn+!8BQru#j>MCeLm)bRKQ~e7sOvXhjs`c$XrEKEh%nKd;TeZy}tprKt}kb zCCo3104vBDZuH&?Ndoaoc4gd(Nq#V#QffpMcJg3t+1D;_=7tWAn3%e?ccO3aUq{_0zJ&P0T;VF`D^t`P&KxC3<% zK7Fk=Qm`#vxcD|~HJnVm(ZS6t&mz4*BQ7yXv_x4r_$`OTREPVV(bGny7^0d7=RRXU*nc2K+x3vWBgHE*HnK zRmC+BF~?s}jMoqWY@u`pDJCq5BP*gL(1ju)Ly(LSWeiji^F()r%J>O^@CU+fJ+Zp|;VkdyJ)|JiYWM*?hp!s}0Ax;$T7qK|)7q`ClQQ`X2IQ(KdDN#AsxPWNAem zh6hC{cFDm5^0pgZsb1zK1*{y1fS{=5G?6kkdhq_CBVKNj=Y~SBJ%7{_ zH;z~WE^@7*4#IG#L)|yL%*HCwx`K-@&6Bc%!0XRnl4IYa{_PfF_%m}U`)6%QmyDnx z>tx}~g+y^(u}fz~T>l3uaKW10Yc`as$R|hgH{!HTHk9&iV^{mSZWb03+8$9sf(udE zUBw`cn?3fWey(J7^3Y`UkdbME=y5qgDDfYlM%IZyVW%>>r>S^^HWWUTX<{Z9hh zA8Ml(7o@6Dn5wy}DnYx1Y{j zPe@gEy?Y9)jWLeRotI}@D-c^(B5z0~Dv818WYvT|Zz>%f$tyG?1D+`^x7Zv8Z=F;V zfG-nRgCRSF+&2}t!W14 zGT=`Lc!BlBJx9t(Z1}S90yxrFO&;Ciy{V7&}V?C>+jFE9kSHy06vW z%3($r&pJ8*&r#Ltdm6eLh8oV?7j#z~TYnqAtOK2iOuwHv|MbNRQ6o!RBYTH`bS@F& zRn}c}aG)D+WJJWghdqdnJ^)lX)|PxFy!`sQhW6d?=;&aJITTzN0=U_*O0zWJK{T$F zyUpkMPwk(jbv|fMM#|yeeAlPE$~F8JnAq=6schbkJaj*#ZhX=ca2r|Xk@8?2PO<4m z$dlhsf_i-iV?vzcgd>rFE`1+2aY!@0cCv?3phSYJl_Me$1@v6uJ{%uS71}y_FJ(r; zM9T^7ce`FpZ`rTT(N7~BHqpuol7nF{2;2qKReUeVEC@cR&lLoD^x4HP=rbbla*u-I z?{6Vx7@OWyyq|uKR+;||M!#4U(h?v2c%i+v!GP&6;b?5PEn9p;Vj^g`I)f9S(LuXE zg#2gx-baAbL9^7BbhmWp&Xf6JF8Kise4vKPP!rCxc4K zihhOpYHUxa2J0NPjw`!x-0J z&I7a;E=(8O5Q5hbc=1`aw0XY1)k$-oGd9o_Jj^Cf#>HV_f`jcmuFme+#vNvLZ%1>g-O2nrQ+!Z8|~_ zS!r5r0qr&bT*-QR?DA^-fK;!>PEJNx+b5r&Z(qQCz)V%?(B}MHH?VuuiE>b{Tm+EM z2OT#Cg9)Cv;D(kFx=jdT`Zb5ys`YYBb9Oqqpu3N{{6qKWBnJ66ld}C&tXk zph4dDS;KGJ9M0IuWWZq3(5NRn&_!W%KelgD%RD9&EFnEcz`@^^*Q=NHS%6MQ-2n^K zQWI8Xd~70c>utst+FP>g=+?>{nB;~{Z6ZbP?*8#c&qc5Zpb{@QF16b)MgS;PeH-4J z(o!oDUfN5yJjr#?z9YiUt5mRhT{|jgMT|7*dv8RH zh+hKC`$JGQlW3(AFhzo}{y-PT)bfCmzKXOw{HHP?+zt`^6KFosgb%bT9G{|&x1+{a zIej$3J=p@%6p{4`DdyagNo}Nx6LV?!@nNdPSno=ES&9QR1eM4i4D&*(QYq=pl3>U$!e!WL1+)UPRYxqY?pcP&)Xvj!jEelWPnKvYp5 zuzdWst;68|Wj>zuhgdVlz_?Pg3lyR%SeL zS~9-gReKXeIrR!5+(+FggA$$quqTjDk%z`TY#Af!j3b(Mh9-G0Buu<-Eh zR^Ly&f9ibwitjH2Y8gIpAU?R#NaK0STzJ^F4u5S1)ED0P4vY!^4dSnMQ)(n9V*?|` zdS0nuxEpyrnc#jRPZJ=sN?J32?a^6CIzWT7_OX^+QGZdNeJqRewsMK6mQIdf!J3VI z;`~@QU3Po6#h1N}^(A1s?5rkPvj9h@&QiT=Cg8R9cKB9U6PdH?!k)R^oh1ED`^UH| zyeA<>mn!h?i#bhotSr}q7F|mvGve5*;J7@No;*kYX5AdU{fM1p@=FCE{u6l&JUEyr zV>E|=p=hl=7BOhv3k@9#Rdfwvx?!Hpuc!&NO(*cYBW4TuhjsifsQr(<^`E)rf;=#Y z=N6&eW-3YB97-v~W&${UCBeLVufADaUG&D+#tWclvTaRz$R>S8ld)~*!t2+|N6-x{ z5e|faSj6xVKDgltM(#_72tP0vE(Wcv1d+{wfzrXlTIkh~;MI=#7^0mZeFC;q*payx zSq^#$m~X39Hc!mW^R#yy&S|_#{B;QTa#YXv(yj)LT@FzR3+B!z)+Q*5`k~RZ^JS@C z>X{)g!nMWWE1$cMD7Hg&pNT$ue3KBZ_qRJ3z?_c@=<51GT+4>9aO;Jx%mpvRFjXbh zpm_#Knm~HUH5{G&*@fc=xQT>!!5BKfyl*5>S8wcj8Z_donAM!Ar$f=UDVcr(Wko-g zFOq{nid$tc5{HV@Fy;puovvi@58Dy&~xBlQZFHX`_2w# z)Hd6{vBKU75pp#!)w4D+`bUx|kpF82`Ob;BP{({%Ye+QOw-8Qe1Sx2uRL4W}QWY%u zLc>Dh4Uv1kzoyBq%Xsj_fOae<_GgXb>331{tRMClT;$-`ptjrABM=#4a0mCRB5)O# z?h=gmdrS;+#p!W>!iGU0 zDGjUcIDA%2EqTT>DKrB-?8>;(mIzTs_j2F&8HpYl$!NusmJ(H=Ao$V6`X$}44USC5 zLaGp1%pwGJ9hptL*95S4#4OUM1EYGsa(`~~1A7y@b#{+kRGv`ZX!_z~r=qtR#di!O z+1<|o%$AJq1MX+K27M46A$mFsDF{O|A&lUEw7)>BwE0}h@}+>AGh0Gc1RVN z7a%xE5HM`(pqZBUacs4OvxI;b)5o8ms9Hgx$B;SGBYoIUGh{f&O=JB~&jv9`up;_h zltH0#G)}Wc07XE$zjCIoED&#O}{=i>rDoBiiyQ6D*I){mUmpT3_Pl3YLB zz5;xhmz~EK+2{%@OvEi<_*f>}LLGZRK2WHfU3o>tm7nC);bM5c2TO)8rfoWfgi zRkr3z&>GKM@V4Nbyqc5uW1t}YgC6jZbAGI1kt11`U0hX1W$jUt;N-$@WUzZYd<@!Mcc?z1{M0aPbPAy+6%vlA1oj{cIJWxNS? zFSk{WM~_0<>ondCKu}^y{BYo>p6a8O7mreD_c}u-`>2+n^#|4+*rFQ z6jnAd!#6O750A2n3ciSqR%@MyoRYaQAjX%Ht-XefeU_S$G$FYI4_9k2zFDYrAw55ovF|(`_M9h)UMiNrqCi=MsVenn-QYc-E@8qGN+= zCYHG%dwcA?>0F^CUVM$}dnoF@=cKRol9V938;9AFqp=**ak5W{YWCo&II?5Ifejdl zB?5Teu8toaVQdHp=pjlUQip{nP)*veca*)bi!iM!L39e;uXg!cp`LB3Ch@O2-M^vx zpHBDZj$L|Ls;W+~$wfErXcQ|PFrVTDa8(3xLE(fVU)0{0_a=X9&fVv%f9zxoy7_$k z8bmiFMvymI=s}3s%`8;X=+W-Z&9Va32-{|igFzeH5lbUUv<6~Zah>V^q3j!@>s;5a z8#K1n*tTukwr!_LR_rvk&Bl$L#!edBw)3_7?DL+p`;PIAzCZ6V)}M7<&wXEi zOAT2KAl{%^1OO&pnP5pP-uy!|R2&Lo%6`f`b}0$YAk>HqS(VoH$NpP3$FN`Y#Y~-PG?T>2TgvmZ|p=&MsS{siTF9%*jd9vJWg7`> zCKpA~o}v#=;eUqIhDv4{22K_<6S2z454hZ3gabwolvj>4h@Z(OY?EitSLDpLe1KUH zhZ9?h?@}jU(e_V$s1L^MgLJ7Dq^@Ssf7ESjzo0M19(9n-sa7y4Vb*4RMn_8^BgBqF^zF)XSnf!C1jAvZ+wL zS{A*`>adxT%o7mYEZX?^QYxt0#OOI?ERC+-c-z+PWwv2a{W4VWZFtrdlk<{TI(XMn z6Q3eyx+kEvT#dR+n|hWyli_S{Pn&pkc#45Nb#SJATYG87gy;FwTu)}PoZ5w7X&hTt z!wU4qK&chs8goh-t#Y`=cF}k5<+AjA7|I+Hn`LrNwgd&m8XEgYo+%0Po^0&msEB;5Xam7-S;*YmaC`hR2zD*<}-abTW%&GMq=^nM2I=v zL-~mxumdKnFh12oPf6P0eYEL0*1w&`C0cX1+9n&9cVudnikoiUzlxX|isOkxhj{Z0 z*VOvN5(B;8>tn3FFQ5kFfj4Y{$@vMBReQujEx5`@`-HEDj1JL40EA(DPUNHqq?n#^ zU8D+wPYDY*=@xZ>6`sN28A45IYGI-9$HsdUkNd4A``7iDb2a99FfboNkDvzydM2X-U4&m(e23SnEBPe9kXuA3FcDY4$lwP(R{)M)slaTJ+ zMS{2Q1c=xyDVAqknxh9DTt248{d2^GmA!AYcmk&|bZ038v8R>H1xts+0FC!C`KU|| z5f}QyZ=3&2@=oK%KMSHYus@Av|9Y1G3D8QkahefD7<@bIt>9K zN0Y$nAY?=oLO*Vd?>wx%Q|^N=g)1SOWA}jT4Yt;W&21CXu`d*NmRZPbj70hnWZTg6 zyu7;1^Y|Llxdzl6@XQg3A16B9MXm_5Z&Pj6*e?GNeR#o_4X!J7*J{d_Age!q&9BVW z<4frsJWF#pe8DP|r>rYOR99W?EO8CqB%)7iM|+jS6uxOk2oADs;cc*iK#DO6@|3+g zPGIKML3c8VMB8g#s!LaG@eom2N%JwA*&byO<11qd`eu>5DFq`@EA?~=qdg88M&y8C zZyAdvRSb3mNic;J3DS+ub=b%l3GOhgfrssL(beN{*FvlJ9&U;HdWpJ7aBJb~3ZI&u zuF}d3*!Tu@uX|T!4VrQYDig7kvIy2b2JM-T?N%y{XYS2Pm$l`XV8u{QpS?jLQFyq< z$>UnEb(pFGn8*Tj1c8k(xRx_u8IFsILpncCbVn;VpX3;FeFJ6KCVgx&nQ~4g@yHF# zRJrUC7d}8{!p^2Ry<22hNhGof4sEW>1f(wQ@D^Cg)ucJlBhgfpVY^a}k*#?K9m`|2 z3)Wk1sOrEs(wM!)?ta(`b0rdZAQ~J(JgUr-rrUlfzDoVbZ6T<$XW`Q{P9t|ZSNTfv z5sxBiZ!`=Btwy#7CV4~sQBem}h3vw#f?~jUFo6+OKF6Q|dCBE^r)Y3kv(WW~5TbcT zZr6LK>DDzV121ISgz3;}G3;6d)OVX0Y?4?jPoH%(w}K^1Y*XI+qR!w5 z83i78kxZ0=4jua;ND^O=&N7N8eh%6%RZVx|B%=LXa9;lxd=3rhXrJM|6%72jM@+M zfdqK6P4siQ_(6N^Ft+z27#TSEi72xh{s4IhGFI(pF5^OkcLc&pxN`Qc(qxQx>=}6Y zY`=)B&LAM#J}4!MXcF*d6ss!ovCG*H3x;Fxl3$cBkvZQu!a=wRpX`RV-J~}eWCQa9 zT%M{e6r2S#>Ro(MErTouN*eSuj=n)9}jQjM+TZ_Ix!<~X1sG1rhT_HuVTJ~hl7{_4hk!0Gu0f(hW6 za89u!M5HWw&{|I;!*olT1=P2YQMY=u?A&>b6y||w03^G-GMbmn7GnG_2m$fvcdc=l zjEJMr&%G8RxwF3Qm-e2!q-H-e4Kcj8VU7v#q>@8HOWs~1Qc5TW*~vkpY3&5!wU zH}Q28BGoHc_(hiyhC99R4FLHGtZyd-x0>ljX-#=Xo_Uy5CWvK5{alE{u6PDqyNeP9 zu5vm}(m0-5f|UO9I8di#(rMyCR)|>?wv{<#8l*oc@GuFuaBeAk&iKFr>4jKB_<@M2 zr2I_K922-CM17-Ufl&ryr$HIvp%YAUxNA_=i93Z6<8{98=1TgWx^MqRipu9_6-oMQ z*!QPBzrWq`kA{ZA$iG^o4s3K$qC^J*2%toQds{T%#iv zt~Y*`f`${c0iX%&FiKLLkZ&W|V#k=B-srki_@|mPOe-48 zaK_zfWr{?}33a+W@Rz*RACNE@#ON&Us6m&PO_ZLg;J}yy1dAFHGC3sGN;_JVdY$7} z2k@BGT}shq4CQn{a|sjbNM*1QLZ;_6`}cF1MKRHj0f8SwNZHpt?2w}05K#@n^g_Bm zo_xRVN!sk99Ty?!CzPZ}QZNvOqdII2r!7Id2FjNQ-67xxWe5chP=>^U1tmA)cYtr6XYlS}941eq)nJS4 zn5s7RHpmOZ>w+iAS{`>eyfc((tkau#&+u|RJ3Y+qUPJ9HpG7nl)AhODW`1ouTV z+<<+G{rGEkVq8ZX$`ufFLdws>IiCcfh>+QvU#J3!cM>dRgS$0_J>}4`MqJCW zLFUy9_AmyH&qJ`K&@)S?z##j4r%(+r%GGqvdDj&!_iiP3k6CW#D*2z+O1O_yUm3Uj zP~|iM*^>jZEa;odG_Ltm3=tQ-rNfVLDvYBg(HLg%mx=r6W{d{G4kg0m_aP`pXzKuXHp2#c4NTLkSBuNbvMBKpb0vW zuK4=$vFMQKAV(Lxs4tMWih=ovbkW>{+*uOrXcFvrP^yEk>;PB&08h5CZD2b@JLoo{ z3dlC87wrvD2gMB(2hIdLx;{F=C+VT;kS8m^3(wmD-sU;tdbH}P@SH4xyC>HLlE&9( zVY4@Ah{cDX#y}upKmMoavky<+2k`nsA)6ltyfNT68sWdJzpQXP;R0s^tPYAkVH8%$ zDHSJ|w9>F&Sos7k7?WA-vY}ks`hbQQkGVpM8OMCrG=`4-9Z5C=`x@{IDn|(PhnG&# zz}DFAZvi`*Xf9h|1{9HVkl;;ZUWtA<46K!!0K{K#xc`gHmk**{XRHT1Ss|Z z6L}1)vL$lCj1xEua*=tBLdg7fE6KujnepC-$LA%gJ4kCFKf?Lw$&iH@1_+Km@*NC1 z0y5t{bfpfil-J)v^lI!75!25%X&w9zr)G*4PR@=d&dzp>EG)lGwr92Eu*FcoFWyhE zyu*M(26hzVVG@e?f!IJD{Y%0Fkx;{?A&3en<+gA8bk)!EQhyZej3^(m9VitjOX3mT zf+$nz=>w^e+-5om^&Qa&&UkwO-Ho$z=nj^crHJ_0}UZOWk&f z?*vGr|PPT*^-}cj`WseWUo) zJLxrZt8>i}$irJ3CmL;VbDCo^@x^5`QH94^a((4-AZ~d8_66n&hN(Pv|MC>Shy`8J zk&j;6UH3wKzIk#5LM0hRLGt7P=j%btJVTc}j-E$^a-`$y(^j7-7#XH*C3qX6J9G9q z4Y5N}v&9fGo?YxD?JMKm5)S&^$vJiIO=W6us~ z4e76Tj!mjD^!#{Iv~F4vWUNH86K1St`+*vqfo#&5JUTv*Nq2CW+t0eSCyY~8FN(gs zX1P&K!qtvFp%Ng^aaD!wKz3i{OX}H#c{S2OHy2M@ieMFLrQzyQ(PS$B(64(K^!m%NP z`{JI}hlnI5(Kf44Z%^Z!SG^6^z=6dgy8h94{kNX2Q}%vm`Olo}5c&^^?*4HX|C2QL zCuuoN%OeclD}!oTo%UOnqSVYo$w(q}qkHpDMvrco=oi61Ij7-RaJJC5I9JH|g5td} zM{k2)2yx){grmem>;d7bKbYM&rLP))$?oiU{qUKQTcQitKi>LCaltvIPy*}K*&sdq z=qTROeitM$m8L;uxi)1xrBKqOx?cwgfvCn)bb=y={@Zl=7ba3JP*;=Vy!{l|8N+-A zndcPCva9(WY2fsh`Sqb_qf*hPG(R!?UA&c|V8bI&%NB2|M#&JrwmubLi+32Yq|(lU zI|~0sJqv|8+V%(Xx~%-P;LOk1py6-atT*wTS&T45@)PaVVpSl|%Wv*jwX`hFrl_fXi(dZc_{ zMpCoF3}ec|Y`GlC7l(()o?&5w5DL5IKkQwgfClHEI3k$w!JlgKJ>e00UL{oMU4#l~ zfx8t#>gB=3mwDjyYjH&+1)G73phmj<{9poXGfQsd{!+?N17y=+0vGZy8gctl(#2?4Gka; zU`Nm$h#|ySB78Ca=+8Jn9E6U-JE22}aYTe-!qK5Ph#ZF5Akg*)KIr^gu9@#!_rwWX z5f>@TP{e+}1*4J9t`)bRaXRm3K;ryAUx0s>XaBX;BP@loi3-did>qIX=e=q#Zd>H0=cKeEa z&D`f~*OK6VD>QJfnWF?SAgQGDE=8Mbw11-%gXPH{UeCy8L{3S72tA6~?j_ckcUWJ9h~|kJ-3j(p!-yuDSHXW!*EljrDhuDnnWMvru)<6T*$}9Q)w8 z`OKD}iKZwFKQ@#pig2tEao(ufLHnsYMSuew3MQ=McYwpikx%uf%^^+ll}a!&8*d{k z%__$=d~u;g(P^5Dk4?&{)LFinHl$Gw)Yz_Jmyl?RdKSsdMil;5?c{D7EmW-oi>X4iG)AJldv9QwVOsu}A%pc0_~8Wch@R#zwh%G0Aj! z?=CCLe;A-Sdreei8ymgXXIykFi&^RH>)*{^TOw25zdG;SP=I+iCg!O5V_t?RfYm=@ z09sQ_qf)uvTP#(9^F}hn7RPj!Ts1e7hEoqe3qzY9r;cVd% zzE*eQB4ak1iWXgwn^qF1lEEW*8KoAv(h0jK^;eZ{R%`T7lv&B#b$>TOt}fOy`N_a? ziy78?3o&uTWzqE8dNKRTfa3?}@)4&M=X>{ZwQkuJeEZsTO(Twm63s%(sVdm`F0HI> z$ub>rvztSp@V$D|TiWgN#E-2_BZZStw=Wa2QTo*ZK4uJ#6=^05=W6!QV21;^bJR-stpO3&c%Uzz0}LW3 zb;u7z6RSq^VL4j`BpD(Nr3ab=mgx!Xclm&kLpgR?l`_+&51>MjbQ!)0bqlOeOfrh} zQ!yD?8Jqi>1eIPMtQT(Bu52d*^l!vei0l-ZmJ-e7)u7J;DT&JQ!N0Ls9qDKsCG>)m zBQMN%PD;B;b~CGej;>RwiPW5WsHxgOxCbjs$6S@K#JqEjlj75G9)sf6t_ETZF3lN5 zjBsecb)!iTcwx(&5qlO7X!!wE#x4!p!vfHYL7mOASpmuDsL93_0u@6U-6-h_e!ATB z9sCNMD%|_X&vDq8H5d3T_ftvI*EE(Y(}+N9e?ocXd-7!wd>^^7FRCvuUM zbTXJDy0<>%xXZMz`6n>Tx6=A_`VI_`W0GL##0c%?G?~^eNL*HP`nvCK1|zK+xzr|9 zh#Mp4^0n_ehOAhirCr1MN-QACPV+8dTOW?KhHi&hdM=LZrY+}YK9CRz= zX-4Dwz+r^yo+7v%ku;AD=a}#NEE2$cmONhaRk$XSZg%dOiv%_Zo?Ky&UV+l%i}Ant ziBiw;KtHmp`oyS!FRNwuc$menlGT!?a)#aN%?Lok&%ED4%s4^|Dq`78t5zge^%PFS zStod2K%BZY=eVQ90F8%BK$!J2r(LjDPKeFunQ!~^(Gn4EL+11ws9i0>66}2FkEhsc z*hHayUSbI6xUl;uzCcK+qn6LKcO3c1Y2=P1H)Y{p9Cwj3bF62i`E(RQ zeJW61pUmBa=Wv*Wb(hKeEXy6SFRqhe&MkMY9TPN3EOv0p+P0zaDP+2o|nN3cq?d^oz8Z)BlQc30PaIA2KEC-6~(Ml>c88&Rt=ylwElLZTsqm``n*ldz$rpS50Ug3hK zA@?hazS3k`>u$w`{niO_vy zHcPRB*Y{+jaIjaKCo5t^jpdl!tJ!mWZ&DMjBw|{@cnMX6vfOL{CyH^@l z6U+(k65lqcLc7*@95N*Tl`E0+b(EU2iLA7duQ?|t5k82RvVx!jF34)wXhjWWFa++< z^au}+1J*>q1ew>hhPS&Ij)XQZ{{iOs?Kd&D=mcNvyPs!1$Uj_0`R~y9cZ#F(v)oog zc#F2P8WQZ6LS;3ex75}%`AM2j zCA)kx-qJ2)Ta!T~Sj2X$0=8-pds8eBAc~2O&s3iYX56gL)nS-a*kODD`dwa-Nhlj+ zK_wqU_K=gV%yX1dRcfW?3*A+lwN(*8Zcy$uLUJw582LbXn(l@w5iKG9Hc>spgDHzp z-9E-jcQ+~@Cx&R!qGufDwsY8W*5t*u;M>p ze9dKn((HB2arMplqL#v&zvS@4FuDgGUv{I?S>h$LVv&q+YHK$ zGd83{aFP(GR7)|p9TRBuk3qG%?=T=zQ(6)!qW!9{ULwRZG90QU+A+E4hN(emVeF)g zI)l+nVd|WXFXQ{SMrILG5$Pk}pJR}}Ka<%fPYm~SLS7J$0-H6b%)@l3)9f}UsyK0; zf=-k2grLq%_kHX54)e57f=jJivd2M^R;e&>5JwypP=s|$yMdII{t&@P0zgMHoy%#f zKT5MXe!-}rFe-mEgP;4lttXh&m_a#_hPlrGb*G$I44o4{HuvIgtxKtNPM$tnx;0lx zGbORJ@v@4Nf+GcsaEa0(Nyrky&8j5;2~#2|Rp3Ch*&wM#`BSK$tUtR6Gl|qd>Z@;R&zpd>e z7OcRP0ztt!%TG#~_nSE9VJncQ6q@qH_!?Vi6D8pDi1nRKKN3j>95iFQJQ?0$CP{oo zmy0F9Mt_vtX4qWWIiyrIp9q|Q^nhFgVU}`7G6J97d=D0OxM?4}RphdbG#C<7eb9301mkkl37GCeYVPX{A40iaK59bp^ zj|7QwqtnANAC^=S$m=b@w1<$e=_ywhC~m{aJrUbO0Ny=};4>C8)07R_K8Kg)8L^)6JK2Gd~r@dsjIr94E>rp@zGX zeQpOWYFSt;|1z~2h%+jNq2c!ISGh)or~~*@iELRfz+^cEj-m(|(AW>RPmN zZ}0>$X}92rZ^H?a7Uz0@(zMUzb*J@u`*hC-RH2_AN)Y^!?ugSjA)MGA_|02S#OB8_ zCnNC=*%{<+v+h^CVv?O$x;%LS#QAsH<3dLHaV)jGABbznar7uv-{p%IFoVk@;IHT> zCg?NSZW8E&kwqfa==~+Y_IY+k@vdUT8%U>%1px`=-x?Yk9s&u>ZjQ#Aad-6E4*5j9 z`)ZuMkuFD(9Vqi-<1u#jKGa+=2-y@9oup9RGD|MGW(a$IrCDlJcEu=kmHeWcK-?o` zLy{e#30apiW9u_auVlls=ams3)UjO@2J`xqi;2->A58EA4)oEqTo(uYnYD>a+eMSG z{#DpMUJ}#YymjmuzR7Jxbi{_5PRErEYot6G*M?2|zV%kjjrDw~wS5*2pm>hB`rwP- zH6KzVFMt{WQ)rD&V`JV?_BuPWPx*kfk6FZR^U|)~V60a3+N(dLLNOZ9YcJA@(mxD$ z^WtZ{bc#2J*m~zaiCk7xr(L4|Xp_X1_biJBO@-;B=FFFoCCr-NsLwtXpWD2Ad=_pT zat;QNZX}EWhj%BuWe0Jx5Tgu+u7s}`;tWJJxudV4!E&whp^r@UVF}RJ&nTo)^%|w; zEvamHRVFv`6S>6q;i5Jr>83v_%6QpyAZ>J3FC%W*5pC~7ZYs#4xM!Mkens3g#RxCJ zlqVI4>z8KYB|3L1LUVL>I0&9`OB97`*dxsJ3ENyRVFF0q&|ui(>P@-E4%|DENd!2rP3DHvSnF%iIw8*xmt~&kzK7JL?_2p_cv6MsUt-=FGXn!fD#yDZpdYbOnov-?*+iz~F}`30COminT5=}O0X*rh84V(~KN`P+)?W;$c1BG`tuT!Gz_S|#~vJaCHftoME@Cy%D zW(~Ej6Z$e>JfJewmCWgV)26#O(%x9+aC`8Rl7axJG&w7d_D4i)U2P=I)rDgRh3z%y zO=Dyv3L#8@5B71VlRY2w-w%T$f>ZXKW_87?Did5r&EO+5?`oG-KT*t zY|`3bg&xD&S@_2h|KsoK=(^5NEK~A?%F!#x$tIh*9j}+-o{*c-^jcmrVi+t2$_tHs zT>0N>Ti!hO6X^YiBKPA>kq1po5lW6zYx}^9vq80{gW+DaWMrI@GP%aLP?mqT7vj z5owDm+k8e|2@=>_g zDS)@4?oq>2&scc!ZQ`M+6;Queo)SEeEJbdn#yZZOdqI575=1qsWHw(-wsf%WJ|b1<2;S@RWT=g*kUy2xwP z+JEAic2Srp6hLWP`>Zz;A7jg)W z!wgSQy;L_!u{VhCC3s_o_WR5?d>LRg+x5`R;&pv@y4>XUeD!n#vwPu!g9;u8|8v-6=8yx(|4uQkCb0) zMA)?|dnlw9p^0gTn3+gJlvK|}>r$oWqucfo4eHMz_!xM1yey{{q}`f(me`Qr=v>C6#i-#R=2-e0{%fDJ&tQ>3s^=Y$%6qOrzm zGoEY3$sza>^XCbXfN^5pBPkZTqPC)LPGPuzz=bj> zF3AZ6g1`UbJoe1hATva|fr4A-ccjf96*YAlp&5u~kx&4hD zK^^9~iQ=b4iGcr*`0jtK`a6IWZAanqqr-MjA~ZBF?*}aDP<3g`NS#!x_W1Z|QXhod z8SMuk-voznL)94&z! zAr6~j^o2phTWuCQ28Em>y85M5_?x&EH%cqMSDuS3XFxg!ux97`C;+zF5J9-4iPW+@ zI*64`%W+J#5G332W8)4Z*pq6*Wdbhmolfpk$#&`r3_~WLLKcw1*f!}XvV(e(1sf9s zz*ISqltnqrBcY+2hum#|k};B#ZxTs5p2o68e6SW2wzan{10QJ$KiiDE6khzc+Crrs zuc7(V>~g?=xV!N$vnx3}ni$ypw!0B4CDr>Y{4-k^P0#XrtGET^pjv8^)1oLq3+8?c zbKbN~i*~ifjStcHi{fIl)rO?~g|53Kg`Ez<{*>a?Y8J`O+^}(R zb|XZ_gy;ggZ6h#~^0@_l`jdAG_wA4Z*MMC8lDh&rT4X!?9x7h@?-n<}A+0{R!}Sv~ zEV$o8H*@Z*>xF`^#4mCom;&v!R_9p)8CYyMav|nTN){R12GC!4V^~h`AwN0L&J^)R zthH>zXLO%D@ha%xiUh0`@A|8U$m(TO0jocKS00(wD2S|Yeg)fQ;bJXKVyugTQgPz^ z?4c~-GAlnpmu*U)re4mHol`m?pNM{xh&gcf^P>ZSE{MRa*3X~^rZ&kPQ72$Amo8of z{7wSk?wUAk{7c~FPqpm+;j;gtwO0BI2I-T}|4{*mkN>6!oZURilYTRJ3Lu_W8wNfl zR7r#;CAHKa`Y+mZ6C8X2K>;EMc&=8v^>MG(y64NsJGc#mbSzVd=cI+%<1EAVsZZAp z$nN*9t)!cPbQOdW2mWEw{b=*k=c{6ghmX7j1aU)ehH(XV7kd{hwM(0ZR=Vh>a)HWAdPrx#d zBHs-ZZvsiNc@F7g8_-yH*u50dCzc-Fe}uMey;W?%81QXo9!%9QYqfvYE3(|yG3o6| ze(C$vGvz$oPgNSf+29i)XA=T>XfSQN08F6apmP`Vi3vg-DwX8qMIr*>kne(fQy(fB zU`{sx0nl*Pl75p!VQ_wr4Cd(G4iF_K1(h%~0M&qTNQ%W0m>wFk1{-Pj{aB8~#2D{M zgdSm}vjw*G8!{^2C#_BHr{}K!bT<7D&;3L5^e=iyjG&1aL7F^ZU4G|7VDzORYH4Q6 zu9O_nC>iY`%Zg5Q>Pidn_@{qK^{~^h5D?IkfBByAUAu#WiR1bg1#XVN4J@W$@70s& z!j}`SU?Se0F0V##pI+Cwp@k1d56|M3XZ{iyT8q}kHf+#p!1mLvv#T@(F`i3W_^bG0ig8SHDFqB)f!PH{!5pD6p-p5kG@3JzccIEAS>^kKq~ z11bykP!%!WseWPTT3V9p5p{`r3@bf<@_%;IU;JeMn=!8NZ^pQpn7+oTrC*G3lYwZ=;O!LQXrQ_7^wZC*t4B%3 zAn8%^?}=pHf5{BUTEe5Xl~9kirFyh!?6v!~bpWvs$fABk(MjM=*hGl#A-+3Drjpqa z+pnW;@YR?j!rV`hrBG)g@MQRvIKr;JBVWB1jacq|h`WW@q5US3$pKv|bAjpR8; zv8R6{d!36wIgpqsw@tCdcAM+fo)Y(Cck8@nnlJIRauF>nBzWhNBstxBn%^eW=DsSIX>vI)>EMWCZ@FQ*uE55K8~Q9rN#e_&=<1oMV9r7nJp}NyL*_km`k$ zmQ*T&UrUtpB^A)kx1@VP2StsrDZEjs5g>qmQK6hQ2Z?qC({kT$I!$m~KfG;UvweuU z=n{@L=#42FXO{#(Wqj_;?4J@ZQW6WE<=E^9{UrAwNvxX(nw?~jqn~Hq3+S&Y2-#+K zCM9r#h z%)6GZVdSapp=f|iNzK4u zRQ<=$ri@iqiaLBG1GvnUm}`Pv(k6kqln?zXx74U%ucDIJ=&{t=1ackbo&i?pzDnpU zk%{{Unr6;!Owu78wn?}4banCh?Tjm8kk|hkB@2~9N zt3#)XKfkZwb)v%DLjGejAVd^-1%kq^kAReEOAAazoF}TffsYXuj9&^y4+2!bUX*`9UlcG!L$miTzUg&ye#(^!W;ElZ4=?AYUW1m_53NxVe!AJ+$tOG7ACz zrEro@>N(;Da=3Z${EJ&3ID3o;xp$Jc$rO`7oCjJVU_WJFsjW*})*-t4X)!M;3lWqfc z@KZG9pbm=fZ+$b8YuSwKpQm3q`9Dp@TpUegOl<5NJ^mY{BqIgYLywZ7W@S|l-5#g~ z8~O|J?J$Mh_drc!0U|*)o8NNr0asGmWqV#@w97kX|M=}g%>cV7zdD6d*b1|C!{pUG zhZF6RBuaxDtzs=QX8HV;Kb2AIZw0AN8;JxzC{jUz`=9vo|0)DYejWZHA$)_5`k z_+C}@!?xZzS|<`v^KhJraQBYrPv3gZ?o9SB>hMeC;Fw?2lzq#cOgl8*UqPIW}t=MMkNo& zF(={xX;C9 z&-{}^u+sIA0fR(y`+K`nXIVc~Co=V~Yhj{t_wEMr;S9IC(#wRM7$5|dX8ra(E$WyB z9u%Q`kiU{6#YK>I_je8$Ax(hoARwG{SLI>O0pTphwQ#^Yp=}mY=Vp8j$WKt2Rh)Oi z)yn()<;E+VNeKQf8cb96@;6*oeP|PP!!LG;pmT<}ww2C1&>J zK!>oP0vBeRkcK#g)2y-e|5o^!jnXn>IA^%#F~yx&{=^@UB^*W1%Bm)&^>swKs2bhw z-XgKQ9^y+N>&aY_%&vQXiwk;K=p^4^9h|*`a{6kRjD)$3%`VKuOeAFBdfyH#GG|1d zKHEtB{RxR-&rNl2z6n3Xlol~3V5jpzGx?C?g|tHy0m8+*Jb0iOdMKxNBh6qLiD5~g zmC*OMs#cIr81(JuY5q@{E0oOb9G!o|4MxdJ{bH{0K^7DgvVcY+`z$g7T&KT4LW!pk zCL*gJDWOy7YQhz2&y0riS%|w15ZF!<<4qp9YT)`jc3P{j{4iVe%Q}`IgBP0PY2p;^Anm4gUBroc#fu=PMh&@) zQBa$512N1uIyob(T)+H=dUO^y+2Byb&7Jo`q-k|4QG9CxStO`6(O`Aw=*-Xez-hrc z_yT;4H6a3vAM-3jUn}l^W0aDR3)Wfqd1CzIp4a~v^dEekQ;aCUl7`2&ZQHhO+qP}b zoUv`&wtdF7ZDY?(_Gxdjom8bCDxFTco~r-)ink5}P}2Yq1hH@HZ^4`{@Y)Q-S?$5r zHYJAdpv7=ACe@ThPeaGltj%zb^2H7Qo{ZcHAbv$P3X(n#a^4R8#%|!XG05Ow7%DMV zWUvb=QH!m@^t%%oOA3Bc5X!tKPi+;GX!e%(QCAwyFz)BIBo@X49E#Z53RMT}BMp80 zSi(firpU9KX@r9oVzr4``Dzjsfn2|c1<-x7Av*tZTIrl4W|uCJ=^lCmO}O`w$94He z&sRQxcivC5{y9380w_jOtN_6c`a+&}kEr*VewHPypO(Fkw)nmI@2jwvML7ijt%;Ae znxDmqi{tPCNwYs0XvnV`hYtPVy`Xai$snL~{phL8;mJ~E$&~eKAxD2cqO<9R6~DMi zXzgOwC5OH*UubFg^{C582@v>GjI-XNM`G{(iCiRO0PB?Hp;B-RukJ`QM6{ywV;*Ua zx*yKOYp=D+v_C`u{|1+}fYsHk46L4JR5VC7YA`y#dAK4EI%+3`AfoMBevJ%j+q=z} zo&D6HRcu$rSkgk}sf|-0(IhPhGr?CEaD906zln%T_+t`4`o`ZU)f0$TJi-pYb#7~w z`IYkE{t{fC0P1-&5kC28f9V#!Lj%9R>BvH)YxH}7Y8XgM%YU2!sb)={R21*}HQMcB z7Mv9N@rvh+epENJ4rRRKt@GQ)SSf>I|16HC`%@(qm>9n7euXRkrm91PKv1lEe2eje z3Tg+E4*4vYN${vLAgH*S?20N#FcGhjpEN^05bz5W7`$WdO%6BjFkP_wJ-#|9phOmw zP2y?0T&W$3*UNTcyCWB%RU?>ZGReB94mfD#O!^?+qxE|&D_L8XqAdQI1^ts2Ak*i2 z_?C?Xey&Ai-q_+eXQ; zA8Ev2f-_Dh>Q%mv$OJ~I?MrAq@aKH(HBJAQGP{7BeJXB&KfzeK2^0tL9w3fFR=z*V zl^$ValjePS>kXRl3)I=SQ5wluw~{6cOn*Z^G8C8`7Dia=sv>?jX4M01HEk%Z?b7;b z&MF$=>64-5*d1B3rHbO+ z;~@;g(y;wbF*$<^WhKkld*ZkYcwkK&6S;SeT(=q>?Q)>RIo+Dc(heSMBw>TyaVkh1 z^?lbJy!Xl<`jKG#byqRoI?(26#o{4j#|gjZuBTD8!y?!}X@^Weo>=2Zt*|N%D$5Ot zyI+z;`+*Bm(5+kqsRnD-`Z`Hn{RGaNv)^vi|p1k=7L6t`JOC+H! z-tHZx=R>cn!GmP)^ zCQH$kbs;mHh1}oO6P)HITPe8+ySzl&UEDFG#T!!Cf*bX`KnGTAc_DU0$8`{DmdXe{ zzsrI2m;m*aZyy0xz8_2dkJa!y^+@_BYYnT{%dc1T0vl3x;<$Zoxl5hg^_HgRgj#A3 zO}waV1UW$9NYEAT(18BWGi`d#k#iTc71H%!)W=dnX0%&pzavgj`$|B=?g`4Vkg*Lv z@6m5dnN~gU2=8)8Dq(yoM&^5!-{KtcD)Pa$GK(tWl$m%Eh`f7Aw5}B~*KdWVJ7Wq_ z47^%iT2l6NGN8CoQ%Arg1#AZ?Gh>wN_la^EQe7u&#vbf{ZNolbWFN?WAGl{OinNQ9 zqk34NS~S=ekW`g~qkjwNs;mFN6(o7n`?r+p<*2HU~<_jb_pG!vr~q1Y(aSvj{M0UKx?mt6AP4kM^47WY>{wp`>B(YVt97m-L8#&uWQ{#O_6T}uWPvVt-j~soAxyE z)TYtUVWNzcM9ul;ujE1x)I%1G&n;{>D# z7=RFcKJRcBe_vX!GmpaOTv z-CxE`-UNsqq~|U!$zgA@fsi)@*iR&#?9sq2@4802}!LnOdrj{gzI8)yQn;Y4w!B`40 zzrJv>F+3=%^Ut=}N)2{faxO6On05kL$)^$}-pOT=#0q1DQjN+!fp}D~ln0+Q%xU~u ziTYQ;y0@=z2VCfzMJ2m9DmR(lb`DSL6))_dxjr0lV0Btb9%|%5@IK(!X!4ZhBNYCJl|U(w-M) z{W^c`z(uc@LS-6lVj8S4V}<$0+%DKHv0AScXrbKUh}TCu(drKUs%P@W$C#>_hrTvE zoLQdLFy89j#hks8!@&C)SPoYK4d-&-c7mHv!Du~i!jBGglo30ZEVD0gpFYqh3k~3V z4?)Y>8yX%Ls->drB_$q{{{M`4z zB~y->Sz`P__~e<;%@g9id65XN7Cnez*UwC7UC<_#WMSFBQQ95ZP(xp5TBEzQ#}Q;7 zT%MQkyo?rJp1&}}Th*9=mj+o*fW2e9BUEjU_kluS>vSfEj+YKatMh*#?DX`3Y$e$H!$F#r z@LL)Y0`gg#v3((!vQGH~IkBy>az!609U)`}BePOJq{g>#S|FU2OZfl_UoxSjjp4DI z&x49;%tGvEyzB96*GDZM;1#A9Sf2fR0dGQ3(6d|-Q zKz>wAVHvEA91_+I9-4FZ5m#19Z1ZW8~WVtmE;JZ#mcz7~4s76QNi zbG5>xnp=O{QD8DZr$Rs$akPBi-*-HYL z)$P&O*Xr&MO!%+II+nzjbdCWsWq2Swxx3X5Rn5DCnRLmR(8}es$~W<*RI~&MTR>be zzCQT7;l8Rp-W0Z9v*l8e7=5!U6(|M@T!c%F$XQ0tCBSwbC)5&F!_Sufl?q+kNX^qz z-0^4?$99vT;mHw&=nP6$3mn{}{0=<<8s~cL@ksetFRWK~QChdBwy==toI)xp2qy_&mW{b;>Dj4!h;)8zSAFh4DluQG$ z@fnU<388Wk8kJg$75vT@~-KaSPAs$%({8uN>9 zN(#76$k3#+4qpws)USms^Pb3EMNp9br5-=bc94+a;-}%HC{vwOahOfgpyUUb8(4Fv zanzPW14*|D_pGQ9G25uJ8_Qk&Sp7{KYs6hGB+Svc@|Hcklwi&nHnG(DZIbM=2EgPb zx2x?5-c?rd+J{l*hL{q=j8JAoDS!MWrR}Fvs2wEk-1ap&OmahotwFi~nN{}uZW&ID zua?p=Q;W-YW>xW7K>vDga+Nq{S7ejGk|xJsy_f)O->XoS3k;=Qx zzfESe1xhb1V?GpsEI(XCs$bSbzSR})b3RGO+*94|l&ZL*#lUYs`qsxeqOuj)DXz^n z&X-udh~w}7h(U+ahe?7Df-TTnE6nX6xV#dAlnp%rCzOhRI3%rcTFAix^53X7*|j1L2k&ApW8H-)yhu zi$hDn9`6;)F1RD3XYr3F-ij_FvQgbN_$9)}1fqpSaIK;iP6s1Q91pFk16d${?yOdN z51cmlRulvIeI3#hvycQ{dfsAu#Ep~ydUZHW1;(4ZAdT^G*kZU+t{B}aTjWD)T^M@J zCxg zsb8wl)=+D;B6Fcdsy9dFk=2+_ffmsYY~<#=GJ3ale0DrJy!&nfF4~$CWNQ=+@5y=K z9~s%Gb(fdTk^FF!$lC&RjRjouoKr@pT7afDB73}N3<(@bLeOS69tn;3Fikdve{3~U2B`I$u4x2i!AnzGZI(mc0 zFSyQspLP-yzB0j0mgSWXCPn3e6^6l*_7wL>>H8C`0AokT%Dx)9dzuu#Fpyn$D% z*MldpsjdxEQZTs%PcTW5(JPnpRx9)>5X znI#Ap?{eGesh|7WWv#}VkpsP^0#9`8V9Z2Y_1Qkv?FN~j$y>Fglu6ly zUxk&NG*7z(>s9!ys|@7*5s5DYO0ZK2vrqsTc2PhOQ+jkW4A)zdo>pSTDdj0Q_XIN$ zvT48B71lI^E9ZGO8MVF`&7D)DCnulF#>b<*_eg)Bf^VfzSrUc6fwX;iyXeXz;%2dBmn(flBGeXfWPsx^N!keu* zlC<9)5!9?SYa4VrFwmlNZ9rbH;N?0SI~n0kdUOo4gBi0J_0y2WFuO7g``!VHxqD*X z!C2`z&T*oZPoAf2jmwT*#wdpy&xQxA%JFJ2iQM~}hXXXVa(ILkRZUb_R<%0L4Unt5 ztY9C_k4f9HgGj=K)Rt%0dS_d+nf?Xym#Ds?tymGC>>(XRo<=B$ht=UCos zWly)IN~>`&kg0n=oDwPbuncuuo{9yzR9{2GNt}=CamwBG%GB`IT=GFT=iw+<-TSMy z#&$4#0M6O@i>BVFk z6FvrTm)V`c4Rqr!v`sOXTb)=1ctW?k`vt@jDD;$=K;}r)8nbt^0>~rsl=#z?w=ElZ zL$>}6y4&od3Ma^m0=RyEiUJK^)Sg#}oY&NuQQ?}-PAm}8GN@8tj0<&TO)X9+f0fCv**$fjD6uUB4K;sd&d0h;SH)w^Gr-EC4Tf#gd z91}mH5KF9dri#*zzVi4*?h|?1KIpQ+fEv5lWyX>}EJMUn9IL!X!hrPqf*m8G&^=D) zK0z8yOXm!HA@PUPpF2+QrTFa&nM3cF1z6Pq{Fv2JcG2L;*|wWGQJ#DG^*@PjbwAO5im zjSQcuFp=UMgR)bsQnJ(&OjVJ?I=7-20YB(14UF>2*OtN$eKN6IrYL&x4}jzDH)4t= z(G!GSx?MqnO0I4|Q)rmA$_aMELn{3WvK_AB#&`L1jNQE7P8a~ zrPs^?;fSke$@$jzR(m>?S@5ZFXoY@UYt_=?3=j5|%+Il>Tm_%ZyOb@3JGjqZ!Wo0p z1;rdhH{kIuDWL;nKl}X97y^4u)z3+x-&M-{Isi?j3O1Vey^bynw@xoZ)s@nta0ZPl zhRWzWE0-lSA@^@udCZ0_UI=Ey#d&bwwH>wy~GOSr%nf@)~&|Y zB?k7XiDfTdzj>xrqnOBmZ4Ks-R6CgjNxLtJ#|kb#){UX%?97Tg7IQGWf;9X1JT!NT zV^k~u0|pG=aS~2HyBKN`rcnd=JchqDll4u>y~cI2oB>6jJ*^<}ZA8pZpe0Atr!DW{ z(~RiEv9HjMv!{rL3ZnAq$DeB@RVl&dGk-uF+m9pZs{oHf( zefafVCcuY?(dmyT~}gi!Xi-(*D`zu7kD8!XcuG%@CNH zgbW=8F$?@YBfI+~x_@yiJ!y>*>TC_eu`%lW=mZN}Wk-VO?M>)c@Q^Re)pon=hN9Lo zy)?2ejsk`$#h9M@T2V?!&g9~5i;nq95LIDJk4oqqS(HF!#5gfPmeLfmpX<15&iyCu zPY#es(eS`r)I*kHOdsr6ENB)@nZ)}-!4d?X@dey+w862-*1!RTZ%5To#ehp+{W#>% z!6Ok25Fx_G0E;&iYoK>@gE$)=5Fjt@T1M4>zdcq%%Oq&+N+5d`v&;~Z)QS?%Bz+?; zR&m-}E)ZUI+J4q(XrF zn4U{KZQ)lGFEqxO8c7D|K3|>l8Nf+6?pRGw;o8R9EV+#MKtP0P)1S|{lp-E6x)aa)R!qWu4I| zP*BwA=SbFG)cv*BIJ5^XOy1fV0(c?@`S!wTvsctrY^}0vldC&dVB%`r%_(FULO8yJ znUtUiNnUwiV(66P!c3+8o~LM?w=y@r-VSF)&VVRXy@@;{44mh}wG2xu!NkzY;<#8AqT~hRZa|1&sl@fwwec zBafU*5o3>8t0cm2e!53bh0W@QYu^SN2QN`CyXoFL)(VQ}yE&Q8pQ8R8HN)~Zt_F{GzAFV{kxZ>TtjcycG&hT8TfKfq&4%$h;>U8UP z-qicqR6&k}*JOD2bKl#`U~+e30P8T!43nHdV#pQfh>YwwyUQjA|H|&cGh8*j!26V6 z)T7Wv@KWVTOcSYfAg9x@+-!-mwfemkC#p0Gc~8C+I!RcXiO^19g2`G*!f5S=YIRe8 z^%@sHfGr&3Wh2!;+|W+jFD`6fTgw|YeN1FkQo2^&OLR})2z8a1M@|Ea<8L6MJl^9i}os?ZX2c=dGb&Dy*Jh>I#`rP zbela9!iyI^NJ=dARM+!KmoyzjU)q|ohQlsB;l?AYs&k`ms2elyaJu<(kOnn#%=qe` z50B!jf!Wr?vK>M;XRP^!mVdJXdEhZ;h_N-wPfxz@nwRaoDaydiml#YYO!zJz6^KPU z%ltuKil~NA-nuu;h@wp+P1Q38r25FAIPx0R6`jm;*=zf!WS)80ZeDDOC}b~eTlzCj z>i<5eN|zU^|A}P%gMeeAg#IC;DM+7v!s@!aD{D8;rg&rU6O+@0l+VjE_BfcUT{8bO zwb8kllc>oYn=k#Q7@3H@#bOcylOS1dB2^G<=`tMdC@7Qr2eTs84Mdm|yMgB=B$a$^~OZ z+qHZp-{4VpN%l->?UuLc*NGAtq@DyB>9@g?P(t!bPK> zc~h_8Oyvs>=mHu!bw@{le%7 zPQPZA^2?%6vq=jMc{I>kAqWni78Ad(Fw^SjTk}ILZ`G7O0IXNe`F56HBOOFxZe3qw z;ox6V|KD-jb2aG^6cmNEFp*_&Vd?(XK~W~p<1}yZ0MazeTH*| z${T4q<#$vN?%?&*XMddG?)WW8BRJG9%omj)x)R>*e3R?Us6FmzzcM1h2$lt-zbIzV zeJvcV49&UZvrh~aL-H&0b)cu<2p-FE$&^!f?^2eW=4lp*uEVCz3|oqv{_H@&XPl-> zu7&>IAq8l0oYl`5d_9^iiA-!dn0?(a&CqD+`M4}7jk*EIe@2@y9yhA4(3obMD=x=I zLHgDl%MHpE?>BM#6#URU9TCxYgDFEN15Cb(=+6$74X*Xot$M)aw%rTNXF&91l*+p7 zD5FXvAlo%)#6;~fe5d#t^Vel!?1SyDL|_XH)2J9Ot_5}o`c4(bWVUO#cmIvB0IP8I zPGs$t_~wYa_bDHGOaGY}GLDDyaA!_rUkfDj0d-xhnwm31Vf+%GbyjIlS&j$t`pmik zn{t9w#O(%|N~vb*cjQjhi0^v`xpoPnsjS~oK;hx$?E-b)!lrCKCWx9UkG(#xFq0{F zX|(j4JSOfvK04~7O0XOF04snmXzWjOW2&=W!Umq@Ld0*YeRIF|D^GvKUNcAD7;8hEhA~irbhxpZ@^tRh&gzOtk6B54YIKL7j~^hn zLq}g5Q;=V_1@mY;EovE^J9QH-Issa_@G&tuFTkxa2Dz1m1FQ9FSVNxNd}wC}hufe;m>C zy}i6;3amh3Lf>eh`y2@`vdU|&h;<9iAX>d?y?T2b`kUk8CsueflAa!pF=1I0tIj_+ z8rY4&B4^$g9nG85`@5I)QY*Xi8DlTcqaJ)K)y|-A@V52mD4g4v0)f>*NaxzCCLOAF z-AR5)Fz#nZr;-&Fhd4Bw*S*wTrWtuqYWJJ|x4`VkpR(xINwmcI0ct=%) z*d_0mtOn;jbTn+noWj;Mn1xNTrxD4Gljd6_I^lrTn8pbX45^O< zvPE49QpW=ZX5NL6N^gd=SgP{At#Yz%5Zd~@M{{ak-tzks42j9QWrrmV1V=K9@YW7^ zAoP^~TmAv~-X$}aegcq>CxD55?5r`7$T~spUe~%R8B@T^9uMhtM|;5=ZYeA4qUX~T zyTwo^QLCEGbY{w*i`36pIe%B?Dhft)q#LC4Cm6tH5Cc^k3`I_Qxn+KTMzHWSHdVzc z7YT<4Ew5=CY_T;7SSgDnPtwXy8y1AVX5{cly!qlXKcuw^4)O~QG`&&lM@zhsse%nE zpS=|LI*eInO|W8zl+1;#yHE|XP$M#btK9-luwgRT07`bbhtyJfR`1g51(_5mxJ@yi z`0@T`I%HKUGpGYxUHTPKpyV3Esf}Kj%}H6)U`vkP1~BV|CFRC7ix0tV$IC~g3#W6Z z8ih38>bf3TD9(W4UY|Vq9B5pjW0$ghWh#Y)Dz5h1i3MTWV@{D}?lm$8w+}HlXZ+1P z!=qInaGDTaF%NSBK7Jt7&=Th2_ASY--4PtAAQ>q>)cX($U{Y&Mr@sdr^opA^&fWl3 zbuE-~JsMxcnfY}TxbH@R?nZ(=vxGr@LyFk@74KQ(fS$Sp*ASpigV8yGrPtx@lt=I3 zmZUH_wcgh4HsLUY5wojC|LBzYjiPlwO4W@an4fkET{{#Z@eZ80lzR!nx1~D*f8&#X zOyvjN@Fe_`4-PIAR!mRY@w3zfZzm-5czs(wqMFa2FNllJ<}OlkRnb0GL1;{z_?l;` zd&%s}NS_eg9fvv?l{Nm(L|0#3=;aZ7knaZ56oxxmPf$!CJ-TZB-5^7w!s93tLJi~OSpr9%%2I_6>ch=^TH8|Af)U;%i2^0VTw zd^>$>$Q}VCWwQ!UI;mPF2CT-`FNmyP&N8K;Tx&XJ-YFbz$J;MlcN(e|h+zC$9)kXD zMhm^F@H0O~U##=W+t+F2UekR{f_6E=ePki9+oAr=7LlxlAnJ7sobPQf=xA%NAUc!G z8t!WQDN}<1omQ-@m>M+^{#uYFL3}gTR(+yE1o*&t5REMBVLIi&R{7IfJagf)-gsi~ zO#>4%dVWNPP9H^{2_;=ou_YM1N-O@AeHb|A;6Bhl{yUPm5cUgwtir0Ag(U1>2d^0z zSELiDg{7`mTBAY@9W79+dKD@znJ|zog;&84NGT>Fwm9?pz;m#*x;(5#S@PPu68t^< zn0@q7@21s546mn_X7793=R3Im^HR7EqyyfY`sM&fDXJ55kAYoEl*rInjo|GfKc|xE z2eo%HPWKh7?_Xuyi@p5F3UU=rRo^~`g}j(()tGtgrj*p=>4*XPj2NnXzp?N|1+G#G z`DN$nI6g+LGbnOFpOP!t9&dRuN_0qaivWlF81*OFdZcXU$-5hc? zwY028-X+JnHZ?g~$y$_%|I6~ZeZtN~qn!+TH@45lVYP-r$d`_0%c)XXh)G-*7S3E2 z2kFyBY!jloFs-p*6sdz3OLltiJABrnXF5ASrM5>06Sq##|6a;2LQ6P$L4vXH_vGsE z_(_aQC85&72t4zO)nJw#Vq(I#XL^BwDL0E=szX|eg<0CR_$h(NbF`HXV?UhAUshn+ z+d(G;!V<{l*X%Ud;lgp*9aK} z#a8NZB~`3x`A;qkTxa<8%o{v!LDBbbmmUsSCmGK?)UVtctTwaK_wb~!4dfOY`1~Xu zeIgh6O`pA`@^zRd=iN0mG(FK9U$WmI4SdA0v@^aL?!)ep*6yoM{I_a&7%vHKuaIxM;b|OQ4(g3Qv$jy)Q2Ip5JV zLWgS2sFuwuU-L^M+lkL|?ZDhkFa=(?Q=R&$%>FnPLV3>DnPFG!n`7=@ra(7m-Ut5D<|HP19)ls5##Du3 ziURB+0&x=Cs~$rEA$@fbK>O6W&}^DKF|ApeMrytsJrK z=znKN-AVAjBV7u=9s*nG6pW~l0#ygYoN#JyxAGjw{AI{=BGev!9bbgMiw1SBAPL%q zu0gcw^Tl3nrsDBMAxuOaT*7rT3)Cc?T`am)WEL8f-FJBVyNl3REjhFTEnWxCex{tZ zX{Nl~POp|k+dw_I8he^3lc-iVCESc}kaExyw?!lX?w))TGkPjiNCg4+4AgI3Yr zW6=fm0s+~vLd^nDK*=r8DvaC)MAh&OMC|P5?TGfWrTtmxeGf@9#G-Btv&ATHEFGhE z$3giDx%s{aa-NBJiYXs467@>{t|p$e!(Dz%oaXuBhkVsl7fu~#hJnO?e9a(BARbniMN!>VWwSrDL z<{jHr-@!=igXe|jmNYLJc^P&tNic>)19+L_$OsK$RJ730$U6u#$=~-_*kyDsgXaoB z@UhYElRd}&4W`RhrEbqw^5a50QFCfUVn*7PaFTO)w+tF?@^{${a1Gs%sDy}?uPElE zZX;St@HH!VB@yQkXCeuaxdJ@Lq)-vOG)bd^K3x^UXX0H-w&T;+jEwvp>X#OOwFssp zrSkz~O@e%vf+A86l-5QI&hrk~g*64fBk$GYHg463tj0orj?|cn9p-KGSthm4v~4rd zoF~|tYnhrKbP8OzbpxFBSD+yfEySGM!fk6X-U#||q=1wg`VImJ?|91{RDDA=MFfME zqur0yQ|`mAC`&oEY{yI9vMy}iEOK1^d!FQ(<)|Ls;Ae*MWk8y!60$y{A&1g zv~iS&DThw+_v8y*^ExCrQNrV#w>r}G@VX8gLH6@JUO5E)dA;ab9NXb>K{h{n52td@?1lTKPb- z4zHi!A@do!E;m*ODe`RT7}QKhb82fpCUM(Gj%h9x1eeefM*(zVl1&6BlsI&Vv<-Tt z#uKM)p$#a}YyVq)yPL1h1~1Ng&+kTBBJ<$S5yCB;XD+(Vj*1}P;-Cb{1!p0?$(oZ3 zUGTISKhj;?O>b`RdD_3F1D>3e9Tl4A5t5xUf$6^8O6EEu!BY>3Zn})_e-7OuY=*an z+RAm(+KfTJmtiPEz+G8JQk)B~@LCDv4!G7f&l#>kvsLKBOkAiXN|1^7mrQard{u_G zTlj7x!6RDl5FOB%rT94uRF6jgVoVJgR!L46zi^rD?~FzM>tH{;vcrai^|hx$P!C+y zE^FF(X+a^3j7E5v!I5{}30x;ps`yz@Lc= z+ibE-Qo*{eM#szc!OSpu zNRL%JcRre5FLh4GdqxX5<@1D3_((dLnY)Q-r9O*2BKI(j(eJ|;z3&zCAg8>hx!NQ$ zX^Ph_5qr*Gzr3f!OvPG4yeR8FL<3pJ(GQg8-~homN&zNC!+ZDu z#I-;=7(2S}<42_*@Vk`b=Szgp!s2OS`u5g_L9xDPOA&yJ$N~N-`hwg9|Ix1Qsptr- zPbo>LxPMk?<)~I;+Vk`S_=&wBFQ+@Y%}=|Rp7j5g81>x= z9YBu`wi=A>Tcz|6Ft043K%U~^j9s6y_zzvtQB&hg8vhH#kAc0dY6S)KL?@L94O#Jb`%$vp#Jt8v$3_c}{`r!h<0PgE)Bo&Q8o!9%920M$#J$-p7mNLv`U@Nz<;g+ zuuwthukyED$zrZAG^o9xWS?2tH=cWC4k^FS}=uR zVI=gG{_Ee1SC+}I+NBrxMF4Z(xWx+wa+c4b?k*jAInWfJbuiRVoU5-^G5FjDh@Gj8 z&DYd3;G7gA^AZ4$v349k{*x5oID`+eb=3Vz9|av1C?2xMe~?&5B7<#D@5r0=E1R&X zPOysi#=Vob7AD(fAdSrx4KM}^zCE=%leLO<^+zqsL7=2ZzqD*Cm0#_nhxaZr!j=y( z4#Y1bcW?7Y008bDbk~o}iB~UAxDU0%I|$qFl=1kI zGxU$E;J-?OTU^11Q+ef{|&i(7XeJb%0&B@E~?f z!ch~%=a=C~$3L6WI&*Mx%J-=NwQwx^0;``7z-@-yKdkZ)nZICjl?I#U06e*YfIZy$ zVs>$TjuB2+C>f~WI~le5*({b{FGzjt48PD#{|c3nXK94Nwhu2~Sy$3tKc)d-9$(*Z zXK+?OOEsvq;_$@To_f7a@d3ewk>>V`R|=~O{TJ+66?uB@iH$&U=C=W{>p;%_8%A$p zz`<_DeMlFYZ?`?`f~o@AdioVle(%4Q0kD<88`1Alr%o8}zqivcmEaqD4^njxoBmer zkW>I0AJ}3+w(@E{{_ba0}JPQbG3z{B3rCsDRhHPNWCGU_WCyG6LuMv|P1kWqWO< z`m6PaY3C5d4Z|r4h(~!)-#bM4vIw;*G6laX$7DqTYWIB$g1wJrTzwVi@Klgmc|TGQ zv{(L#-Fjfn0`Dm>3)}q3?=zc>5{j49fO;XDkUHuISSp~+QBNsQ%m;auTSpN2qzQE{ zUhzK)r(S^bRzvDQ!sBg-?WHdaV1%*rFz$8dV~bU%6ufCI6YOR)xv(SK^r6m`&n3l)4-F z`}VG{Y8YS=tj5V<#Bb68`{i~Hi$(kg|7XGl`@yy~M_{q3j-^`TNB3|Pl+2>>2DLNx z+Kffwf{p@%Mezv=XUviL?X+1@fzIOO098Hw(rqj60mB~?Ss-zt_AYv>ZRj_-5G)E@ zHl}ls{cb1bS2h!+9KfP?HYMOUI1BWL-uo0C9WXMnPY4V0m->Oq>?Q45D<0$Ae+%kw zRs*}3Oz>pD=)2y;S>!Iwqx4c;5DUxnv40EDPbk2LOC?D>^vf@Rf;2D)6aWAO1ONbl zAb_G4&z8);oxwlj{AaSFDuT3LBY%3IM$%1%naPEw1^kMS-5&jA1DivMl}0Q4XC zZ-M+XD?3Yi;QuEE?tfzpoov}yXx%sr|0}vM-)?%80|+4f76bt6zoKm{jhyXGEM0Bs zob9i^J#}QYzxZSOW|trdAv5G59Tkz7@JR?m+8zJ8$OVhJ7RG=(3Xk(FlA5%e%;FrF zwTHB49G*;OTTeQ?*KYga*7R_lCcoZ3dUXH3^|+_@ygK)+Y*>5Lta|n6Szr5>{+k4c z8ZDB~P2zwcbpw#>(dtE1!eCx5@lyw^W*~G^f`aSqh5$WR8WGKqQy}c6Pr?!GDanP2 z`zz&f17kFh@sD(=>Zw1NcAIsVO$R=ZA*=obFR!kmOvTYP;n7Ky!H|Qo_z${@)C)N$ zsX@D~xk?v|Z>h52l*}AVh41cR&?!lUXE=A`D&Vlv@=cI93*z?`Zxk_XW;RDH;IYCR zS7pwV+U4C&MMb0m$~x|vY^?qJE6CW3kGs)&Ghb_)^{d}F@9l8hJRh8e-OnEJbf^ux zX9;%K%KCJe*!gkK`}}k`!S-=43wDPKcGm-T=K|~z2KeP^{M}wz0@$78=TiXa(*)Qp zhyLB(1K8aIxU~&%V-ApK3$VMrDd3IOPYtsWr_LCl&K|JJ7QoID!2Zv(2jF8@eB51Kd~u+?oQs#yfhLuB~RIwaG=`*Nz=_baV$W3vbn6#d)(LO+Jr{ z@!+XsjJZDW_A$qKdp)$H-B+^la(Cs_sNeO*SxAN55()!#) z;PKd<{5vY5t#;;+RxSec9sg?L@Enk*2lycP^X@It!tiI}U#2iCN3>N%zvG-fuAV(q z%pIPvht8?09^8}A!~xxVrfSTV4u1Gm4IIu+g}klhdGIuY>F1&h3u`}k*vR9x3GpApuR}3A#!|3&Z#`U!z^1ZSjB`JvG#ffxRQm2ORx48y;@O-kwnG@d_{(0v? zcrg}4#*?og^SRmU`v!N)~4|f{f)>@m*iT^!JgE&`CkT+U|I8OOjq2pPkKi$m7v8KIpcRxzf z0IQ2WR$)U(Ez_e;W_BC=!Z+q0W%^zNn>ru4yEaJ<7meYW?ssep!*uuGeDomXW5;u3 zzuJ{-3Atu~+N4deY(wX`na*&01-s0edzzBH+D(x8r5n8W2(MTbneH1Gir5|bx@CCR zXE?pb{o~1ldOtdzrzGabb{X&xn4s>7>-iN;y+If;H|R+?kT>S~#SeGvM#4HijNtmE+LO0ex&@=8e~OO;fBt5BllO5yws3i}vXVyBIfy*k*hZ zda?#*))Vsn8+iPR6YvnvKM+wn-c}UqlVTL~5Pv@Qcex=a3*sJ)!++WMgx)$iPcY^y zTLdHsR(+Rn&}nDXO@lFLj2R*BcI?nnIrOQFn!tYriEp)sX>#I&CpW?a96H;76=F&` zzubMKOp5;$Nj;j;E=P~|~d7Xa9!)Xk6>n{x6 z>P(y_aQ#=L5U(M^L*%KCA!l1b?p-U~E}T|zU96KOBypPF{#PULyE41J&+vWjSd6$g&d7mSEPobL{w63V^M?xM>Nw;dOJ;sqi{rf>BcK1odFQQ*5Fdp4ZJIZary;%{ z@g+XO=dJ{l1L)7wsP?J!AKU(|mr zvA=9kUKI=z+Qr>)KJxl12Bm5~GSsIMm065^it+{mBNZ-C{ggK}UW z!*7SNJkPN_E{JEN9LQzH(LPh`#~M7If%!K?`I(FUiXfz4g#D#p`s;7h=Mx$I;Wds& z_s+uh?~V3?C1Wo%!v1nr3+;EM8;|=i^>vh@9Kih1Jr4fJ|EbKl$Ur`P%kbeKoOj%& z@wm=k@hgst_&JE{{PxHvsZ9TVL_PT}Gp?0bo@AyxS4GH&sF#jJdbEo>M3%oq7t#MIp+uOmnoCF`sV8=Ppb; z96|Zu$;byA)CVpy^Y0^+V+lmy8+V2-@;<#JEKery@TTri9!pQS{%-yN7&hj1$YMPy-tkYYo_4wqv%(0LcZFBdXa|Fi&~&QP{7Esu}!gGnEIw+ zI~UCr+7YRP`FY&q0qU_j{R-`u=ZyXG9Lwp&v~$%)IPWw3?1=JYhgRr!ib4G7G)7Or zevia{*Ui5RP@Z_8Jkj-w8IDs$7e>C}{9?=KMV+zyh4+Q+ydB5ab7p*1#d#-mn6Q5b zDsjB{2=%&V*zca$?>awo7u09682+!0_PCU>$GhNjt#H2cLi{rNCDJaSeTTRg=HuWh z)N@>sZ-d-~H0E7tTa4_n1i6KIIZrpSDNJd|^M*hiU_~#F_bAuRt zG6wrg!Sq)$&X2at{I~}D-L|LDPT7rmDR>I$$DrJ>L|ln@10|2IKs##<^21ZK`(hUf z^O0gcTJ+;NBmJK^4=QU5`ExGvRX)R4BII*7M!t=~c`Cn-u-%&BxK3xrbw})%c&1+} zp?rQjSXkf5XqQFW^7-rh28pP*L^A!g0qx3U#{MO0JJ07#zb7|9{=6;hMFXMo+to`NZQog&X3T zSg(Cdz0Tk~t~3|6j~vH)Iy2s9p?)6JQ>dSe#OJD*@^?eMRDu3j-TGQI;_-{nQG}-g z#B*!%fX<(@0_BGGgs>f2p}!*bju3aj`C7x2e-ir1J2U#pVw_K%&=01Y_Ya}`aT+3& zV{g&^ie>Dt1k~p<8GU0f`nS{Ug#0`b_2jpVT_Zy|wuJE)y})_LX{OLV(&G9~B(CrH zARagv=gsRpUWE8Kw7c^ZLcR1M>OZE8zBeEB*c4`b%|QJmkr~&+u)Z#g+&hKiF_jsQ zgRuN}_VD-;WGn^B84p&@pk9&8=uyXTUU2Xh+P@R9oy`J-^Fj{#2`4dr!ow&hvzh#> zq1~jZ$K$&GdW7RTk7)-H>chFMh5b7U`6r0spXJD(E)0LZz!BWHi}uf@zA(e_8;W=aV0$uh@*vuSrWQi|R)YFK7L&if&X0{bfxpflKR@l3n*L4D~WgI`9yH=nVi zzhZfEnev#Sd`n^Y`LF`ZiQ`c>Z^oiu!jkb54n_Y!KI0##i|v+(%V~-j^`r-4*4eijk9Q)KAPAy>}Uo7ayiP^-vya7`t^o zj#Eo(A>W3ep0k3{bE@JvEx0XQA4yc;{K(jM1JSXlimU(V}l*49>ANw-)OSY@fUOSKcX3E$FX2`ec%zU~H{oxlG zy>T)=w;uCcQ`Ga_7VcCOJ@7b=mn4QiO>sW;8i(=$aS_g6 zp1O4?#5>@8ZOhEpA5ng~G5oLv$MrsjAC{xvxN}2c{!?&$X9=@D?u7bJ5~H6KpnPy* z$Uh9<&PKZ?%~B{&&fq+l#q>)W%2i87t~z0R`Y`SJH_FLa8==0@ z1@(EiWx{#9AIgm^MsD=S^@R+^FZT}lErID@cWh@5hTrT_&e$=2t(92LAX~;ShwZ$E znfFiNykOgy$5VCdCTQ2>=J2>q4)4Kv){dEHPh)$&wH4Ys?QlG$8}nB(_S8!B|9dk2|DD)h>3{KfEaFMXS9ewj={4x@s>j%U=de7nOnEe@SA;Tp zMPHP+cNlv?iS~jiv#;+W>H}`5w<{5^gz{g-$bS#i8_gKKu@Tx$Zj9aZ8TD$ri;R3j zJ0+E|Q%<7Zm{C*MzjqPOVb*KcAFn@E*U$^eo67BKM zOur~mo_L_&V+Z2Dp#I>7<49*OkHvn^VfsBN9SL??JsKh2ir!)F(5T`MMDG!$hWkn`3)sGy8Dbpq`*%#_8|aFZoQr+{N-| zGxJL$oZr_l^L~PEzdGuBI)8ft`YS>ie?=plFGgAj_2H$cr)4wpDhKBUXGYI_quU?& zi?E&FqkM>GP|LKB6YAT!jD5EoP2jcHgerltI?c;@VAe)f`OHmK3$IQzfsOKj! z?Vpe1E{)L#5>XC6XXNl}o&Lt?CzG)s9fk|X-ATlKaNncOE_jOT4q3SFpyRh(w5Rql z`tv80^9lEa^Y|x}lbH-Z$Z$SN?kTh{taN&58=<{l3GI<3Dxv&5iSuB-jc^`6fqGFa zv%Xmu^+OHA|M##xy>K3>hw@O0_JTR%cYTQbreNlsZ8)y;kZ(Jqypm$QMl$;0LL4uV zO#58M_G!V)3m&L1NfknW%RSV`JkUMSLsHQ~MbG;S=_k6VqRRqyBKmQ7E?$_P}zgg!JiHuOy~k z6LH=@%J>cLqaEtN?8hF0{wpQpze-kMy%>F94%%_~Rj^*VeOcIF8SMHp%I(ffIs0Jy zxYQ8xc{|hx+#3n;T_|VlY771L?kG>RcZB}u`KUK0GUcg@ayy%m+b;m;saL{ve}&HO zVA^vp%C{Yid`rRhDXc1#|C>;rEMdmQNz~`-G5d}>Vn4bt?{|E_@e8=Vud{ROrF z{o4uK*|dhxj-G~gRu1#r=_s!>CxqjA4eISysJH9-IC-;P|S?=oOwQZ&i%EO+!4C z!7t#rTfy-2GUU(X^Qa#pAD+f_WHV+Rc{Jve!0a=8h5K_lGxX6oPTiPsItt}yG9y2; zalX)27RsL{=--wy{_U9}eq8Kh{3;n}Pen5JR9*CkSTXW86#a)m%(%FT^Kt>M-z6X} zL%qd?u@^p~JSk$vZ&lRu3pxwukucPY9MJESs%r<-do_&STLb&$E$&lw*F6`<^--q$ zO;MhNGUf3_zH(yt${zjXg$)0!!u63%X5Y~-xK8TB*!9-PC!q|#_-*TkM=0X5^+6O$5&5rT%_L?%B#jWzqm2;%O#xO3l<9XOh7(K zL4z_L$F&OOWDsLVFnm&g^34wCab3G{8z=tX|B3$Z@BjY(@9+Qq{_pSm9~A%Z zHH3OMp|Y%4{`w60E{tIV2}ETsUcci$)uT{~&Eu6#xJ0ID-2Vz&P{1Ha25Z~q<_f7> z70@O@HSnouYOXXDiL^?wsfjta8&0WkgJd_Ek7ydn##(I3J;?)-iWRG?3b6>p3W-=N z?rGXgnqZ{>l{TKxxQV!g+6>cVskstlq6DR~qN!=JHJB=kXA&)FtGQG(1#5*AT$E;_ zB<)F}a}y;^0rByU@fK1$b5hB%N}0HZHK~Gmcddhi$Q-m%q7G`!NySAZp*i;jCoMNM ziA4&4idrdXFPbZ*3K6*YPjfX(u4Br@k2ZBvfHDcBKGn>@DhYaO6S95EvO)^))j3kBAbTh3o-cn_iRl^KKE5zpJwx&c+>ruSeSX>QrNGtvb zk&-B$%f({oP*LolP|YOmV_^sJS~4rA*orGCO)Xq1b%)VPH>J6gNSsWH>ycNyB}fzx zwYhwhKu|FEfyOYyOl#H8)ZDy+nDl{b4`P-`hrukX+E4+sN(E>|Qf(j71EyeEO}5aD zB#jh_N&1SOk|eEC2IOeJCG{Cw4#zT$F#{>Ylv59IldvyV@KMOL?~O62UaFMGK=UR(?D?&lw|sE zK3z&UMZ&ee6!i~>ijsuxL_sh$7b}W$5ZRcw-Z0q00o*K12_la5vNP9KGOs2!ha_dv zgHyIHptThdo+EZ&S|0~H5Q!B^NYYlc^#rY%IdHu!<#bSpPFkeZq)Y?N9rtsBYFytsEQ$w)9I5n`}Yf`#c!(*9z*WDgsNf(l&HByj~9 zNVN*CtO|}dOtYmkrYWi|GPhNtb|&WjX*sSDC6QVvEfY_+0g^xoo=T!q!E`A|r}ZGW zN-lk|+10%@NoSr&E~_a_6F^jKBg>LSri9ag8wA`3w@j=Rq6G78Hg4PqFc+JFQrg3m z#3!0tXicXTFDr=cz>$*$ErHh`iOLE@T?S%mKqufBn%}2JR8#0r!XOfc0@uO-BS`QA z-hUKKLI?>FBt(-ij)ci5W2TcZn}qo!EFxh!@b6%*Ls_&HcpvOO5{>}B|11;u_kI5a z{yQKyfZykTn}mBLJR+e0c%Mcg39qqh-;nTugs)uPNb8u8P=)h9kWiC^x+F9pf$%Og zBB3b>ElF??@$V_LC!r$=T}kLcLN5|Hfz^)$4-y8GFp>m65`swxCn1`IaU@J2VUmb{ zZ|+wT=8`a1rY z<|MQr!JdRRB(x==0|{M7a3P^L3GO5eAYmv8Ka2Ty=SP#^M?x?OAtZ!~`ERqukT5~a zzh5++gkMRRLqZY>oa<&43CSd+kg%DA-$~d_g5~)5ch?f`G?t#&)ZsuU`P8|UQ*KZA zBYOF(Yp=%nwQAFaEdPn@5&;rab_ z3mQ2lx^_O5UbLk~yx9Z4)?H29ya>{JmLGg1PQa@vHF8_DtSIep z^<#+7Gym}pXEe@xo;7UQaFDsnLbqxvpPhf7{gS_D%$=qqZfvgKY}&o1EfdZC+JD%# zf8~|EvrLozT@smm?Ebr__ipU*HcJ@ZW!SeFE1M5$xS{RD2a+n!>NIP7=3t|LUbx2Z z>+!W-g8hyD{@Ybo3m;0CpY4}DhSTJ?5Jsw`fdYr4t7!ZhVdoqGKqOkd?*`?gb^=U&pp znK!DqRc^ddv314rF^v~ZZaZU0SiIajbozv$D~cLaIU2C<%*NVg8>|PbFIt*AcaiTq z^0L>r?x%*oTz6sTExYVrin^UVkaaxf-EUPNNN+vvw6jt(XYI&A{k(Jks^88gQ{?_t z9H##KVMnstl$M(YObDBoP|G@h@#TLn9d8li({AMGrWuukYq?9R|MbwKW>AK>^X8vg zbl&y8n%CdU_TSn0bC=%=Tl^e%X;|>^_J^xges}!Up@>^eJE=0~q;$2I@3QVpwJNu* zx~jSq+HTu+{_UwZ6(hD>tCF%Z!_IBNs%AlF>g;@*xGH~n_^0Nl@AjX5Y3XlEqm!DP zvUE+zY&Sj%LMw#!N{e;^$@6ikd)73%qg?W9kMxd5nvXp)V1xPAp2K9rpW5fQbeVf+ z-jbYFt%hdVwS99Yd1a3cJ3mf8P<7kl`J$x_##MUS`cjqa@l)lWLTqk*{9~@owB}#t z)w!SYWm4U->9FhRv#Y&B3+}|Vo&BtS#kAE)`F);cB-fLAd!{v>;Cig$&dn>2bnCgm z@>-L+Evrh``(*E`Qs7td-GQY!<7cljpF5$~sdt~}w#<69q{{3_Z&l;NXF8l%(xO}1 zmc_|m>b)K{c1agAv2BOUIV)}zjhm$G^J3Hjx6lSBBdtwr&tI7Jd5-s-Cu1T#tHE@) zDVM)wZF-p9x?B^le0T|jrkaxe>Ar7s)Xf73mcjL`_!pj!SN;WeLK%gU$JbPG(~CdvF3W^ z&DM@86Va40s~%Tf(YR?|mm%xkKHWNY!r3u@G2sE)4eGtg)K&dl^WHdEfmW=6bU6`tK?My-nu zKh2rtR%o|3^J&nAJMHfL(I46`8QEv=@)Ygqw#T=gULIa&qv}D`^L1S{ejS{a`W=2% zFXC`J*FQaz0?n3>**EI-)ax%+{2O*-&i^NZexHb~dReYv7q@kjFD-CBw1vAlb4#+xnJ@cEwyZ=TPvA3kK(j=uGr z`t0$1?eFW+WJ^=^u)V_v_-qXP)Wdc`#{qUL4rbY13<%v5d4Gv=Z`(>a8$C{K@8y1WrdYprY~(eBwT&!%D&F>siU5EtZ=K|m*~hSiG|%&>o3V)6uw&5 z^KMo09g(D%&lL?0-@V!I@vY7_lm2wvSwC-O&7B*c-#;=g;oXw;>(-2}J~?H{ z=vJFt67y5y|BQRo!~0rrX!F_CfA6%mT9vGOSLA2n=PGNu|8r2$=}L-YX8fl{e{PL0 z0-uM9L!Q#!dEe^(F=lHO|Mnkx?}~O>v8#WR&%s6Kr+Zc2d9d;Q-vTaNA0vLSzvbt7 zZ9-aX`{~@A)%*Mp%nymG{H|f0qSTM)TF2h)Jnmt3tDb*UAKq$4cGCc#xe>|>hvRZn zKR2tmC?tIG_vDEm0xx{TRUp= zoGmxsYEako%8J=;&ByIIry9BDV8c;g?py72-j(1`ci6j*W438(^;jahvd^=3k(0~G zeGkpsSHBy)e@GSC@go(domn4QFF7e~fuGyV>L;$-b?dvc=)l&|U;f+U?pM=&#+c8q zDi(F?{PySLtA_39-f8#ZJ9cp$j|R`mKK!y*wneMTqa+{FlNbrx0Gdl9OGpj3&?inVzg5fgTemaw zx#`HRM{3(l0Wv2%JYc%|PMeeBqDQvM-8;G@Z6FxJ(c!W0bgWUR4M~~RzSw+>~bW5YjP5N5Ru~?XTZf6%* z;J)_uGq0zu;NP?7M^Cs?FJgW7eA9!gM$i4XZR1%{gI!j3%{e;arrJh!E=y{JKn_nExL2SQ5{@ex!T% z#SMf9r$?(u_=m(VCTCBAwG)@Dt^GpIfk^A3r;OYoLt5QF7E6Y%35lyX;H3Q!6&>eU6lyfqcYoM67`TCI1Q_S0J4@g*#@tjSe z`&JuguPqSqHVS7ib>eI%a^07lFOm4asAt-V`FGzpk>G;3In@>usj{@EaE^2^-hU)F z%kg?W3CB3Q4tRUcol3Khx;~80$|1prTu;O}ZhLGSwCQdljqcl7V@cq?B+b1;+ydh` z`)DbZZz~e~$^Ap%Z6*5PY&*_IZcT7C#@Q0A73LL;bO~s0amO9tZ86SnRZ#hHw*N+Q zUyX!w)H66+ZYK#_Fi$y^W;9IJs^tDp~K>9k^|v*uK3Eysge1V>nMH z2~KFIu1CBz30=j!A7nLW*CKuac;A2x@IHfeV%|pA9eu_8tHRuNw0Gz)V2>_%{)5Ch zQ}y|cgaEwOt?U2B^!+e$|AM3&NWunj$#>B>UxdAgx7U^Ayq1JG;QbBURwxT9p8~2( z-SPT5p7(L|)boPG{2n$}ydO`(W4zY+HZF;HzluG%9!0_|a{XRUz3C%@#Ix}&Uf)A=Vu}3u80LvoseG%NTg`5YGdw-1Md>WkZiu1upMEpAN1S;)V zF~9d>Jm7;`}e%PaaIAaL(6a$MqZDZ$dvvCE#7g zQ!t;+cMdwIx6Z79tP%-7~ zg7=({$_Fu55x?z*+nT{`L*l+wG8F4x4e95R`|gNc27Z4I=ey#*W7Ldlr>A=QKb*uL zBKT?&_F()-Or!IIg_FQ-(d$Qo50&3I>iQ#;?1Ktr`sNkzzAN2PlZ2nSvB1p_;*$4c zxZ^bDv5|!RczuN`H@9bGHVKOm+a~6Hc5N_!ZchW}J>E>^$@%Ph>6xorFc|0G<6hnS zM9!QiSNDCTD5TpXF7azFVDj1_=J%-&BInBFTmjR@l5;b>o{O~Joa`cJ4LLs|aWlmH zx5V@vQxKQp`7P#Up{FmoB_(ce*IuL@1H3OZ4r#+g{5O1FQfcv1n#F!r?syOUeky+^ ze{Rd-aS`u#?SuJuBOwIorsH`432Gf56S{>Y1c*!CA)*gQs?NGS_2)^L!HqEz(!{*4 zcea@KlXAy!a*h}Czpb!N_e?4u6LQUcmhrQ0E+OaB@_xYL{Zm2QP<1#(4K^ny_LG(m)QgSekX2E)zUJa70ui) z$MX!}*TKx8d`Hi6(Cj7QWjXWH9lCv9x_xQAfL~Li4@DVydA{cZZP-Uc7PMqJ@=iP z0hsS-D*q_F=GI!R@YZT<+*b z&bxqLE7u*rQT6RlT{}?Mx5>2{uV+zdxi#~4BrZTyy!8|AVw#`0d%UhH=HFlB*0NWT zFh|6%K|jEF?$cLt5x=f)=-}3yxi$A4dU$k~i2sc>`ry{Gi%4io(Vr6Wdj;r&+g`-2 zg?C1}u1x;i_YjVgfUh|e|6UgNZCx7@@;RLp@rmSImz41ja^0PTW5BPMb6Y5CAWd&_ zo+sk>IkX~pOA=cBr%^{6hz^fd`x}jqjPWxT z?`teFOdTEV8>lWO@ecEiHC)6%bvQ)%hN~jNcw)bp00YG8@M0XIBUHgLVcucE(J|3# zt5f({-1zNF)8jQ5ozWD$-CE98I8qn4t#LMH_0t@9_qjP#3Sa4Rw)(1vF}7 z1ZA``RR^}2{ewuQeH|SlBSPa^w{d7|utlRaQEK0?@ANu2B3#Y!uy2ee$oqR9!QsIr z!%H1DK^+xgls+a>MJj0EN#3y$QQpD+Ml&cDXXUDwaiU%m@{sE7Lj$z>d(sfG-u^T|tfyBEP#W!9 z!*!&4n3J!<=W$4>hA0-pQNdxsn&5G28Wm6kh57nN2l=*b?MP-KK@6|97$(+7pr@pm zhD%Vod{m^~@>B78X4=v@7N^;YA6jhbIGLb z9~Nn#R}8l>7ip}8xp?C(j5!!+VJ=o*OB%!Vw=frDu$f9HV`GgaHIzok$f&Njb27sE z##*T?W=4`STiZBGBLUsEdZLZCRVjw_i-`4(R;yKlQgI-Wo1ww}Y^8W||0uOaUyPqR zfJ`glVWlF%Bf|aFrBF^2EuBY1tX`Z{p)_Vduojn}6@$8(at$2m8yrRB{xMPG)Q)Z2 zw(bxc?Hd|ROJhSxek>O~)VjthXAb(veFHS=sB-6^hdLrs9sYy47)VmS)aPrd6d-t-U9xH}OFTJxDTK->_Il}7HN-`R zDSm{x^h!S?^o9ka(T5n4ndKPrKaxhjq&1R2udp>%C@6+(SO)Q)~eq72S&x_HC$ zoQ^^;-!b*1RBnAqjdzZa$UrC2M8*ip>a@~y#vf>`6LmC%MU5~f5=E+`q9ei?Wk7Fk z#(JMF7uV?C-oDY%>L`t4S>^lw{IZxtKXvJmLi3`2mZm)bdQ~9(MjaKa&(bxdOzFuf zL=|9|1@%OhZ~`4kxit1*}}%OP}}iZt;!wRb>NMDc zW})NtHoP7JVJFZ8`q@xN!7*xCtx3}uZcUoXU~5uDhN2C(CZE_)YwGilvGGP*lOmv6 zQ@@vivVUl^D5R7lfgt{&!Rl}##)uvq91+eDM3>G+@I0bRN9m;xG33YzG33Vy(c=#) z#)>o7LtSe4(`$mId5MlS7{NY?0zhLpkO!Qp6GxgM|!Jhn1B}35mg6zCS3K%*ve9 zr8w45Ze^2}6hDGY;ndJESU}@+-}8%BherpOr;KF~8tys6F^t^MLl8~`X|#8gn%6Y> z@uQborH5QU9W`K7hDMC4tRbT+Ys{$195kvjM~$kiVWTQz+^EVLII41t9921nj;b7E zM^%o&qxyl-qxym2qbl$CQI&T9smeWqROKE*DkHsUjO+D}B4hJ{(IG%0Rq6m=N<+dJ zLu10wP%t1q@*t0CIERz|_;=$4_TN>e5 zLl3H#{mPKWrF1z;yEhgm-~tNgFAc@gD=KA^=*#Q-Iti;Hmc*1^Hz-$Oy5K9xc~$kP&jj98vZ%P`Mf;kmUqjvzD=kt_7&$?JIm1a)pF)lTuj}XSvVmLQ-^EHownRXE^SGck-+yyh=bI= zWUBa)6#7SlL2~2lWiXk>H&B=K2U7e1=WyggZYY~x;B<%%iipyb_I8)Rr5fscOlg^` ziz-bU9URG>9i8+(fF4<;lNc&l2|=kc1_y@g<2Vl1L&!R?ic$TJFifD&6;>Q&kWY%U zEY)DT$CvW|aS>`2&e4Vlj3v?&r;kp62_{B5vr-YIw%60tdLwbqoY92FXlUOWohUM> zbQJSSf{30I0I%vA#&C>&-qpn#;o*`D4Dzy$#3)aH z&nLvYSo#>~T0S~5M1L`hBTH}e(UXH7B4+w4RTU;)k8Z zpyxBO28tM4W*KA4S;$!ZrTqRzp-@zOf3Hw4+)g*M(yv}dtBL6z78zQs2Ks7(BZO~e z3ZuNkeZ$nkTqbRsoDQQTV@VKEwYEGcqm zgntMF7DJeZYg_(}Rqr5Dpip&`zPCxk)e+(P@aSl@qXE2)e%xS$+NrhP_gKfp8i4ga znBZ+&I|k^Biu4Z+iwV`6NF5y>tS=%;#dXs6rs7_lE#AL}fPX|-q;Hg3ck8PvA&v0^ z!gl@<;oplAR@~}gzN&G-r1Qh*J_y5J(w%iME>}fHMQGIi-(P?%Mt!5Ae80b4QM|?# z<;&%(ONwd8;P&HED}G2zq=8L`ESwS+r^!vl3_ zXs}eT<>Xzy{pq1-}ZL~yu9F9|m% z=)<`QLLXkdFHEOr7$l(m8G=~UV)Q<-*sER+ir{b}p7qxTDZg+|_#1tUp;p9bZo1bOZMa7XoAXaq=~;vjP+}h$CgA$WaIB&1qk=i5TBC^| zWXAYQK*zSDAd+n(L8N|e5|HR&7-9Yf`AQ#|9_G?Vria7ykr`k!eWb?QNIyI2qhWbU zZ(EdRTme}b-Ci~m3dr>{nx1m;MBJ;moH3z^DmE%Gjc(Pj_*S>wA9quh_|uJ18Csk+ zMqGORSs$^%Wo)C=<;&aPg125`1FPT$X!S0Q>m@d_US0-$`D$roWxWjg^5tKSHF)FC z`N4E$uC^P0+K;3vXJ40-%)nZ`A!>aK_|%#?>n|@ALqv&xsBg)O_j)J{yx~YkmnRnk ztNKQ!JBo^yRb@mQDz9PrYKX|_E(7DXP?_dTd5MiIf0si8H%4t_mAd@vi0rCXY?#`O{N(c-)sLQ)N@}1CN2Krs- zMpicj<>#tb%w1$m5oM{^y&J%-pFx0sxF!@hpIWg$&^J{0Rk4y!sj)sLCYt>S66?ay zPhQT<${;iTDE3Q9HOpTKnhz;i4!M?$vzJ8(HcPZ6jF`Ce5??APNGX|nKrzpAR ze7z9?D&IKnCNfwhTv_1M5^kZCSvb(S(@B!(NVR`(0O$5s^X^(*9LKcZZ=xwe9Y*+u zR3KEXQ41hk&kYUs^KTU$0q%Wz^yF?-q1+w0;Ju~XP4S`pCMiBkq{4eQ2A^=7KYK8E zJ^WD@9|QD32Iym196!jaWpF9tRwAhYhg$rFg2A)v@)vdtZiVy?44#Md&J1pcxRSv= z5O-s6AH+QvJOS~dn)=JHJL_;4{IIVngD=4!TQIMszg|`h?g0G9Ruv5XRxB3OPlua6 zAUiH zEQ2Q_p1|OiBY8ZD!L3H|_zDKsF5>ZI22Vshl`SXY=?w0RQ^R29!__FxCEzv zAIX%$Gbr4g!s*{^u%hrx3Rh702MV{N@Imx9%_*Gz4!<*n=TZ46DSRk}yHR)+m5&F7 zFQ(|dD7+Je`%pOjjsGADPoUaAlEUfVDvG6WXR16}3a5X^D1pKsP|r=G@U0ZSf^Iho zPp0tRRQ{keKAXb3QTgXm_&u8cDf~Ij{}jHH zdTt?w)4$VJMB%$AdYu5{@LLpYO5tNE+?>Mc-&?Sv@Rk(4g2Ja$?O;dYsWksn_!FA{ zDSRN6kCMWxQ1osTKDPu04+`%~(R)$&Rtoo_@Q?KGE>JlATa}R%o=4HgQuuC)UQ6Mt zX#S^g`nM30D11MKub^-f3Qwl+2UK}dDO^I~X%s%6=6?$Ro60|f!jDk&Srop7=6?#O ze+wX&!e3K(9)-&(ynyas>bZp!PXAs=5rvyl`GB+p-$=ox6yAU;zd41Ur}>}4$I!n+ zLgDo9J=sw>{rgG|6kdtKohjUm!j%+G|6Y+Bg;$|)4+_^(xEF;_rt0fM;VBe-5QPWQ z{7>Q0wEU-V`ZqkZ6ke6eKY_w?X#I!66DWKIh10*ql1$-kX!%d!^lu-fQFu2BPp9yK z)N?Z^{58%06n>4?e<-{=Ri0c5Uryn96i)wsXaR-yrS%^Qr+-hdh{Ai&il&U>9|||6 z@Cg*TIfZ-C{7>PRD0&5jkEQ7CD106DTn7r@MB&a9-nRq>C57ixxEqD9pz`sc@V&JB zr*Qgrm3=7Og2IC+ygG$PQusbv{!{ogTK-eGHASC5;g(eXNfb{1{?G~vuSwy_6yAiE z{}f(}qEDmnI~1Ny;Z_u$LE-f8!evo-EY1HE9zx-{bo?j*Qf9x3a5WNEt10N-}j59a0P{HDcqXE6DZtEB_@rf~W<8*?eVDTU`z zxE+NTPJe3pReLSwxnp|*phPrD?*s~Q@6WV4GlYG z|5{P4b&u%%c6UJ}5=&&Jm223vAD;9i&QDJf1V`yF!3M>Ihj>Q^Pf#nmD(twkx2BoG zVJH}aI(Jbxwrw{Qgx|mq4%aBq4Q}wmfp$g+ir@B8G;g7Z3Fj8k)G7sukK%yhZ$2up z6hOV~GA<%mrO@jx$L;ohF#&ePM9quyV6(yx@Y=Sw1-rn|2tVJ@R)K1bI$RSK5gAwF zx>rn6l*D4r-y@qTx^z)E6xUu@)zbAMG|t`{>arQwe*f@;?jtIEalHjQut28YMQdVjC3un~3Tuve+a z*~gTAZ^2&I4faO*hCG~Wg*J}f8e?5p{N!dZq-9sqm!ax#uHHS|yZ7tm*u%Sr`@o+4 zdJOmMH3VXdtD*Y>W-EJ#){bqQ+O})&+@X7qo-Vyyd%N}N+t0oKfPo%^1`qKZI_zh! z;Uj$g{8j3Jz@XqUA)#U65s_n~qBSw&#>d7@fbV0&c3c;*4eJ6WZ)&z~<W*!NdD z%4&DKZb`05m36f%n{~N&Kd|yTCb5Nx7!9_;&^`k^os0Yadn_p`&o%bk{wwmZXuhKq z(Y^ud;$8K~N90j06nc9U)5mCnL!(Is-?@{^+pnFIcVt8~_i*l#d+VdjN^tLkU?n&X z)4jD26|C`AbEFt&2*z(`3nN6PR$f`8W>`2@KXsGF&c8V0@hNkHlyb_!f3%ZJ1IFGdm@| zJG)p49~z&YHugTiLHDkeD!l`6bWfSN*ii=kx@^4maS}k+h04EV9RM~&->79r6xT`r zylraNRSZsbPUT%~BZ6rQnti-FPzpiYCj2V15JBAa#a>I}q>voA+99B_3|>AuUb$tC z7|s^XK2Ynm2{7JStyC8wH8=}&K{Kc^Pr`KbqZ;(NoBb!WJj*6j&XY$==u|y~8-qvFGH!-xF^={tF z7zrrCEiaEGitynZy;Dw47s0=;+C5S=6T=YjY&d<92u9A2UHAH<820b(`gn_{2xd-i zKJE2w2`pdU?QpG6L`QU9vv19N6PP!1Z{-oBKacEP_Rsc?QaG`3z>@5dV({-@Cp~(Y z9C9+QG(O}Z0?Y2rZaQ}p!MPcWHXrwu!ftEFZL3A6>ov;)Vz|>`fPCg-O6+^x~W?c>t3uU%Yhio(NjpRSeiV1mI)x+1;PF%E8`0 zb@QA+6HxEXO=$mA25~)B-nda$41ed>9{zZT9RB*<`P#aE5>RQ^N~TzwK-iQvrYZMK zz+}3g#mNaWh?f4XzR=qQ&i&qSlWdI?>h9{5bfQ2CH}ZOQ{M%9nRlm;trSlLuoU@!V zap(*gn5J1PYIT;wz2@3E@gRo}k1}Phc_R26xV&z5h79I9_gJ6smmG3k8}A&^R0?iA zXY~>PB8Q)vM4CTnC4)aleD>Vgp0w|_j@egENMPhz@rwO(MDS#`&wpoHNIxK<@npH4-S*SC5x{O3K~dZas~OZ ztqKuDI6a^8pOY9CpP1^{NhF8LV_v)nC;YqX&5;f2{bCqed-&{2L&P8+cV&7kQFLQB zwu-L(8$f9D`G;S3GJ#e1lP)e9h)IDr4U|mKtI=WCLobo z#UJ*TgKVC4&d5$usJTCTwf#6T?7eXJ^W(E3IQDSI=?cS1J9lllTx2bU{yWVrZd7A` zoz?K6=jUAoN0Wl33r_86n%3*|+u8rVT2XlY{tnr-vu!%;b`~du4f!Jbqt=+nF8y-{ z9`0OeTxIjXs?8NcKDFp@@$7&<{u{q&hh)O@Z$^))FUp92Sw{R*CVpE?xo8hcYn2kh1)CJ+kGghh*UKwE8c*2>`ytIGR7vn!pLGAX!BjIXez*c(J(&H2!q% zV)tocnDNipx4!+wFv8`Jw{7A8Pe8E0#8BbexXdS?CD5Z^s}&);03O929%S)5v8N}! ze6h2J9EP-ge#HdFDh(Vqd-70=m4=L2jual{oD}k~7o{9qh=l7naGhTGfK1Gx3h>*az%fc7_%t1o*1kf*5L zSG-jMbrxR|cvA zi?P9GB9LWy%}6Hvx#WSHsY`?uetMTW_F!)*)V~(&czC=7v>xvcf9wQs&-?h~rkN60 z){^@TFqT1gUi0hu5auE z@Xz}Ozj)LXLAaOGtiJ+ekZnKW?93iAP-u!S`i+)>OVV!NLm^V=pcpH2B7D;+&dK}m zKoK~Zo?Np&MGj|e=EPPf^VBaL{tfTWj2OYbg^pD2NXZ~JcasbK=nS9Z7DP)iK!(ps5ZJ_O+P;nCPN z|B2!5yAAPm1N)ErBGpt>uNK06{x8 zHz(Z>gWQhJcumHIi%G?L16@r(ULjgG{DcYI{QZjZzm;O5KS)>I`&S0T>s>tjGD8j( zn-%T;?V=PG>^?p0>1Pql`K9}V*RgVVd-9dW>w^hQ71ve<&k;laJ0V-!|0IL9n*%Rf zHwMTbZ9iTSC>M-18~)h0FeGl3hE)^~{^ljM@YYb#cIDTk)# z-JgA)CV{p7T{||6mxH&rPx`0v$|uIL&kb z=-y4DYDe^r?Oy{Hj;~JIy}{Q$o4sT(`{JveZC#~s_}^dMdVUjuOUo)Y(>{{^>1?&7 zPLdP~i|QnL9uz^%Y3+t2lJcxu+GnI6>daD&eNjaYThB)3jV0wg?%Gr1G8v$Ls%q1L>0$_J6jP*qAq5Y! zUiFWEHi6q;EE1!hNn!Tvqy1Ly7lDt7d+Nk~a!B9#=j&J^2hCpJNHcL1!>FKtA^N2R ztYR9(_a^hm)Ph?H(L_&tQg`{a-z}x^x{Cb_4=aG#pIeNXlp}|OHzu#kA4=wjb=Uo^ z6D82g3z|2aC4qB0FSs{&AO?%LX5(r&iy-et;e5+%F?{~@@x11PC2;klX0+_37^<7L ze($U_A@aHyA62nw5WPyJ5Nv_8pj6aQP0v)2BIyzs8E8 zX`xfx`$ba7u^T!q`<@&u0_qI%CF9bi#yyW#L@&R6{n>B!-y{&wVR2%^AThM9x!773 zPwci?4t-y1Od!c^Yr>jiWFCHEpWWYF3h84i+%_2?0^78IX0Ndp!R-yRa<-G_D3hvI+_Y2|0m!-h%N+AnQqsPY=O@-O zff&iyXs1FEtnlgU+oz)(HlMgNtFMm?4ybBP%DF=LyVaQ1rexmg<#Ddp?Y&|!bC@@6 zpuY$-57!sWt{{e1&!5hmOYD?O4=1XJlleLFSFhkJq`w_1HIeOkLhS6ws!I)5YulC*Xm;rJJn0(| zG+JG&(_bKgN|QG}n)nZ~17L?2y8s-~YRrMfAd^?SC1$_oN9_be3zk5PkNz z-}$qFZ^=A=a#Zt1nPlEgtDaGRs2u+6lvd3w7@*36>znT=$$Zc=R{C=y(Kk|}?L2Zt zuqNl;lm;p}jD6>R{l#+;q@5ZzxLbx823)#4SpE{=$>*BW?)sR(vu{Bg&rjyx|15(=M;c_U_)7*MH*e2%T}kYz z+FQr`(?te#R}ZgK)C*v7ck5%RABf)2#v-MEeK~B%{3r9lVliC)dh4w#Y5$EM?tNN7 z^gQX&H~;A%SOyO)1}*AW-2`5k?E7g6(Wf8Y&FGaG39#|6 zOq<+lQgAHnohdsY1NXwmg=fE#a`tW!eDsn8MsKY>X2p1bs&mKxw(Y42Y(L}v%KkOc zizj6&hHGWeV)@6hy@?*F%vgW_7}4XV?O6oyK?Kj{JiRlnuNahnWZd6lBLlYwTBq6t zayZnzXY$Q%5;&@u6X>5Mfu3^)?UDZ@1Ix?HHq|5gwaijG=~fMZ{pJfIR(A%drCe+i z>;&+0euvg?L}IYAx*xirz65Iav+NgIB!abF|4NTpA%VSHzTGVROy=E-2hLA0k-?y~ z64Q{WV%T;cwDwvlG(O{(yl5H8&r81W>0u&2ZD*xkST2Fban-XvpO%5xestrbL|%K$ zS~={)Mib~UDKBSgGZEBl&_cYA$gw@chZin(5P{X%R`zX=iD1^#gw?S`KRD#}v{p4@ zXZ#Z&cU;>>3|s6w4w`jS236}mSlO|g354yrak&bae;3Sq&^D-(492dsp8Z>EV!uSi ztnFwog7cX}o^T76bL=Ck-Itg^LSEjuQ(YvmO7*SwvK=Bgs~z~cSA+>{_+S9`Xj3=-v$mxPEdSKBFuW^3L7QdkIw#BF)uRfHj)zOZxsPw~5PN`MWgC8fMCN_* z$B^||b&34Vs;$zr1L!%}qv~UCDJ-;Hnx*I?hSBaze4mr@H_WJ&H@K-BCOp}2_n-~Y z!+*a0zBaKR5-XW4+f3TEPu=`84~SmdePh_5ZbbgAdz|{8WQ+(pRO#q<#g5ojrW-{r zL{F|TZRnw#aH6LrB)@-6^gQcE5hEk~rEsm%jYT!ccr7?HbK-i!*UvqM)oM9G1nW+o z2F0do;^dvuOx8b)33R|q=;bB$uPOw&nA#s zZ5wEbJ*%l;dhYpeQph;wpO8313=STyeJ`g;pyh;jJJW3desBC_yvgrkn9%F5!0r{K zFnx^AoD22IJl=Zz@lQbbzfHB%LoUi;T4mF(>dSKYt*$8G<{1gRKl}c+lIZza>(?d{ z^#&3{;yh>Gl0jow8hJcj0u?rIZaMq93AFB8+qBD3!Z#1rJ5+Wi_Rh=n7M=f+!hwv# zzfVs$f#dFX?`{v5KCwGpaM$Dg=9g+2^!a0+#zR5$ zssz{QmSaV5cX8`WMYD;1I;mE=ZH^fJR614u6ixK_bvb6@4y2s5x5Em1G3@v+eAV6c zM84VCZC!F!4judDzx=d{&i|e|38Y(JDkfkjN|vaWt5azs0c0D zC8I<}5v8<@kg`I`N`pj7W*G@dBw0~Hq$s0QBAF2ll|)8EL+3t!o$ER$KkxfI_x=5T zK3(ZR2-^5gRDnQ7O(DR3}qbJs&}J!|^t!u9PO82kfK z_>S{OoHgF)!a>`x@u$Bp0q!2kGM~@GKpXYkvO*aB=GxMLG*R4}H4Uzx(h0b|s^ZN# z84?=9OwagaQ?OyGO;;HGaZS?C0-41eq_|wzT(ucML}u2=dz|+>zv`wZ;CuL56rB4$ z6Mg*g`Rn)IQgEuw_k6%Q8Uld(IQ0{1KbP49-20J)9{^7)N4_-vkc_3~{Bn)W}BmH&u+uATGPTZe>+>(05Q zUl~~Ec&Bw?FW#qgd%A240TP0CGo9=R;7OdlFXj^sg;|Zw9pVIV^QNBtZs*{9OwD+$ z4gULpl6jlgQE-Npa;(O_oPTk;<9#<0BA@OTe>zD6q2MOxD#d}#>o*4rlCe*V#EUxw zXb|WXUH99N0iVE+>RS>Sa9$DU`v30}s5qJ~-x)%~ZasZFCH%YstBpiY2m$G*^m%%4 zU(Y?+y-(~U4fd**PFDIH5M>&U&X}*t`ri$=V2|>P8(-7jL4hbOw}0tc^z#Z);}{_l zzHKS%6-WO*+O@l{H3xk_vTgcZ%*pKJleWoX6s#@YAzhKj0>7iB(a3l7j%AA%86t1B zWkf0^9VEe0){ED4I(l_`;FZEI{QjqPU%4S?4s;E@`iDI5Aa2X~8gJagR$G0AND8(q zTo5;BF~{`3Rn2flPKtaP(sLbu-N(;|ci`MrDLrjlSC2j6|9Yz*^6-`8=Tx2j2-qrS zAhKx<1C`8Klh8>H93Ah?*o+?S+I8sOC-i5Rne%u;rfAUbmAC(KkbokWriMU>djSysIbXsR(!*k8u#&)ZZ+10{i5O*F!~j4np~>udjTBUUwki%O33W{kCG& zMOApOGj_*p`9gqEg79j200l ztw}zw$Ff(x3rk8QsWAYK<&QvVin+kkP8EoMP0_6~2e z8U@`=!6i?T6E&h&ZS8x@K=D5*`X(Rd=-Fb?HZAm*oku??eqf>3O!S|34goXj4mvu%p&+-|KYC6Z zzzvP+Pb2LBFM0Qzo|{WRqVz<;2|ES^zKD)*bD`mw{Gq~(SOR`eH#FB@!or^yzwXx| zpUyB1Jm(z3!md+-i+(=By=3~jKdc}@dA4@#2F%Zz&I+695ApM?3sQbJu+UZ7Z*7hH z@TA1zUEdq@j9bLMFFp*&LB{d^7c^L|JsbWfmV@du+=V-Mufj7gy8M#B+_ca#nyXEM zvE&=ceIF?Jc-d&{FF6W23+-ymrla3S*Z)@L(6?_}YFBt>!de1IUapT=VK0AboP9i1C!P})8fwe2GdC3;@_chNKmA5$A|dq{(q zcy!XA9vb?cuIP$>ry+b@!DQDuyeEfl#}(+?li|D;lgP=Bq>olN4RIh9a)MKMjPuuC zbX2tr^D%zq$o`oO7#+Lb_4gbBo~J&p(0)Y1uaH^=34ICzIwHDEus`NjA6|3q7W(to z&li4QWuZ>y-R%9L|94(uthE8p!2ad8{hXgN*ZM z9TSq~U}$FyeY1{%`itRFZax&8R$d?Li=Nqhm$_2(nubNv!OXv#6exCTD>!^%p?~v` zzOW4okyV1~V#p!K4=d>t2S~^rkGK`xLBZkZ>W!a~^XwM=vzdwCk8YJVII@y}*~{{6 zSMKG&b{ZRDE)L+Jb>h1}<~Ps57pKDr7T7Y;X9v<*_<1JrRPzJuC*Knd{e>Jj=Qjk8 zNup=VX5M8@&|gNc%gqs`pxNk5aYrcw)oN+8hifpu{vL0idC)JMp@$f>Q&=}J6PeM*T-V`s5TvGY2 zGRmg_xvr};e+SJ$MY^5RU32up%RWz%cxh1e;a$*wmV?CVe!;`@S=e*0IVdllgPTik z#hP>z;H`gQ*KXX`?a9^$-YT;Y?cFre^a(lonX!V%6azfPM{Jgb;{Lz4u9_jhVxPS= zd~=zBn3w;UVF?Dh{4{9OTO>#%obNZEPe9|R9L<7$RKQ8Ok_LSSBtPv7GQnOIedKA7 z>Q6&d=|(R16z2Qev$k3<2r!)U`I4S40ht3fR?^-iBv*_LZ{}xV=Kg{sHOQ-sM||vg zSLATXS�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

=4QfM{KnvxXhj`dQOo(R>xP_!<$a4 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 ORmxF_<`TpwN!0 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%0DVRJ|SLfn$};V)ddEJX*D8b8ZDot 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%0DjxY&&UT*MEsI(%fq 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 z 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 213a5009d4..19e2d266c9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,5 +17,4 @@ if (libSessionUtilProjectPath.isNotBlank()) { } include(":app") -include(":liblazysodium") include(":content-descriptions") // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing \ No newline at end of file From 949cc5cd34e8493cb5bf60254c07130f50afab1d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 23 May 2025 10:34:32 +1000 Subject: [PATCH 338/867] SES-192 - Share contact list clean up --- .../thoughtcrime/securesms/ShareActivity.kt | 4 -- .../contacts/ContactSelectionListAdapter.kt | 39 +++-------- .../contacts/ContactSelectionListFragment.kt | 3 +- .../contacts/ContactSelectionListLoader.kt | 69 ++++--------------- .../contacts/SelectContactsAdapter.kt | 4 +- .../contacts/SelectContactsLoader.kt | 2 +- .../securesms/contacts/UserView.kt | 10 +-- .../legacy/EditLegacyGroupMembersAdapter.kt | 1 - .../securesms/util/ContactUtilities.kt | 7 +- .../layout/contact_selection_list_divider.xml | 22 ------ 10 files changed, 36 insertions(+), 125 deletions(-) delete mode 100644 app/src/main/res/layout/contact_selection_list_divider.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt index 38bb0fb686..13f693b513 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -81,10 +81,6 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { override fun onCreate(icicle: Bundle?, ready: Boolean) { - if (!intent.hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { - intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionListLoader.DisplayMode.FLAG_ALL) - } - intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false) setContentView(R.layout.share_activity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt index e299277bf5..229e7b6cba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt @@ -1,12 +1,10 @@ package org.thoughtcrime.securesms.contacts import android.content.Context -import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import network.loki.messenger.databinding.ContactSelectionListDividerBinding -import org.session.libsession.utilities.recipients.Recipient import com.bumptech.glide.RequestManager +import org.session.libsession.utilities.recipients.Recipient class ContactSelectionListAdapter(private val context: Context, private val multiSelect: Boolean) : RecyclerView.Adapter() { lateinit var glide: RequestManager @@ -17,19 +15,9 @@ class ContactSelectionListAdapter(private val context: Context, private val mult private object ViewType { const val Contact = 0 - const val Divider = 1 } class UserViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) - class DividerViewHolder( - private val binding: ContactSelectionListDividerBinding - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: ContactSelectionListItem.Header) { - with(binding){ - label.text = item.name - } - } - } override fun getItemCount(): Int { return items.size @@ -43,20 +31,11 @@ class ContactSelectionListAdapter(private val context: Context, private val mult } override fun getItemViewType(position: Int): Int { - return when (items[position]) { - is ContactSelectionListItem.Header -> ViewType.Divider - else -> ViewType.Contact - } + return ViewType.Contact } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == ViewType.Contact) { - UserViewHolder(UserView(context)) - } else { - DividerViewHolder( - ContactSelectionListDividerBinding.inflate(LayoutInflater.from(context), parent, false) - ) - } + return UserViewHolder(UserView(context)) } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { @@ -66,12 +45,11 @@ class ContactSelectionListAdapter(private val context: Context, private val mult viewHolder.view.setOnClickListener { contactClickListener?.onContactClick(item.recipient) } val isSelected = selectedContacts.contains(item.recipient) viewHolder.view.bind( - item.recipient, - glide, - if (multiSelect) UserView.ActionIndicator.Tick else UserView.ActionIndicator.None, - isSelected) - } else if (viewHolder is DividerViewHolder) { - viewHolder.bind(item as ContactSelectionListItem.Header) + item.recipient, + if (multiSelect) UserView.ActionIndicator.Tick else UserView.ActionIndicator.None, + isSelected, + showCurrentUserAsNoteToSelf = true + ) } } @@ -85,7 +63,6 @@ class ContactSelectionListAdapter(private val context: Context, private val mult } val index = items.indexOfFirst { when (it) { - is ContactSelectionListItem.Header -> false is ContactSelectionListItem.Contact -> it.recipient == recipient } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt index 041f4a4569..728dfc752f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt @@ -42,7 +42,6 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks> { return ContactSelectionListLoader( context = requireActivity(), - mode = requireActivity().intent.getIntExtra(DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_ALL), + mode = ContactsCursorLoader.DisplayMode.FLAG_ALL, filter = cursorFilter, deprecationManager = deprecationManager ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index cb95b85794..1c79c24d52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -1,14 +1,12 @@ package org.thoughtcrime.securesms.contacts import android.content.Context -import network.loki.messenger.R import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.util.AsyncLoader import org.thoughtcrime.securesms.util.ContactUtilities sealed class ContactSelectionListItem { - class Header(val name: String) : ContactSelectionListItem() class Contact(val recipient: Recipient) : ContactSelectionListItem() } @@ -19,63 +17,26 @@ class ContactSelectionListLoader( private val deprecationManager: LegacyGroupDeprecationManager, ) : AsyncLoader>(context) { - object DisplayMode { - const val FLAG_CONTACTS = 1 - const val FLAG_CLOSED_GROUPS = 1 shl 1 - const val FLAG_OPEN_GROUPS = 1 shl 2 - const val FLAG_ALL = FLAG_CONTACTS or FLAG_CLOSED_GROUPS or FLAG_OPEN_GROUPS - } - - private fun isFlagSet(flag: Int): Boolean { - return mode and flag > 0 - } - override fun loadInBackground(): List { - val contacts = ContactUtilities.getAllContacts(context).filter { - if (filter.isNullOrEmpty()) return@filter true - it.name.contains(filter.trim(), true) || it.address.toString().contains(filter.trim(), true) - }.sortedBy { - it.name - } - val list = mutableListOf() - if (isFlagSet(DisplayMode.FLAG_CLOSED_GROUPS)) { - list.addAll(getGroups(contacts)) - } - if (isFlagSet(DisplayMode.FLAG_OPEN_GROUPS)) { - list.addAll(getCommunities(contacts)) - } - if (isFlagSet(DisplayMode.FLAG_CONTACTS)) { - list.addAll(getContacts(contacts)) - } - return list - } - - private fun getContacts(contacts: List): List { - return getItems(contacts, context.getString(R.string.contactContacts)) { - !it.isGroupOrCommunityRecipient - } - } - - private fun getGroups(contacts: List): List { - return getItems(contacts, context.getString(R.string.conversationsGroups)) { - val isDeprecatedLegacyGroup = it.isLegacyGroupRecipient && - deprecationManager.isDeprecated - it.address.isGroup && !isDeprecatedLegacyGroup - } - } - - private fun getCommunities(contacts: List): List { - return getItems(contacts, context.getString(R.string.conversationsCommunities)) { - it.address.isCommunity - } + val contacts = ContactUtilities.getAllContacts(context).asSequence() + .filter { + if(it.first.isLegacyGroupRecipient && deprecationManager.isDeprecated) return@filter false // ignore legacy group when deprecated + if (filter.isNullOrEmpty()) return@filter true + it.first.name.contains(filter.trim(), true) || it.first.address.toString().contains(filter.trim(), true) + }.sortedWith( + compareBy> { !it.first.isLocalNumber } // NTS come first + .thenByDescending { it.second } // then order by last message time + ) + .map { it.first }.toList() + + return getItems(contacts) } - private fun getItems(contacts: List, title: String, contactFilter: (Recipient) -> Boolean): List { - val items = contacts.filter(contactFilter).map { + private fun getItems(contacts: List): List { + val items = contacts.map { ContactSelectionListItem.Contact(it) } if (items.isEmpty()) return listOf() - val header = ContactSelectionListItem.Header(title) - return listOf(header) + items + return items } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt index fd92fab099..c46513dc45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt @@ -31,9 +31,9 @@ class SelectContactsAdapter(private val context: Context, private val glide: Req viewHolder.view.bind(Recipient.from( context, Address.fromSerialized(member), false), - glide, UserView.ActionIndicator.Tick, - isSelected) + isSelected + ) } override fun onBindViewHolder(viewHolder: ViewHolder, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt index 5b9c5ad9d1..baf1868f9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt @@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.util.AsyncLoader class SelectContactsLoader(context: Context, private val usersToExclude: Set) : AsyncLoader>(context) { override fun loadInBackground(): List { - val contacts = ContactUtilities.getAllContacts(context) + val contacts = ContactUtilities.getAllContacts(context).map { it.first } return contacts.filter { !it.isGroupOrCommunityRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe() }.map { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index bf38b20ff2..0b60606a09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -49,13 +49,15 @@ class UserView : LinearLayout { // endregion // region Updating - fun bind(user: Recipient, glide: RequestManager, actionIndicator: ActionIndicator, isSelected: Boolean = false) { + fun bind(user: Recipient, actionIndicator: ActionIndicator, isSelected: Boolean = false, showCurrentUserAsNoteToSelf: Boolean = false) { val isLocalUser = user.isLocalNumber fun getUserDisplayName(publicKey: String): String { - if (isLocalUser) return context.getString(R.string.you) - - return usernameUtils.getContactNameWithAccountID(publicKey) + return when { + isLocalUser && showCurrentUserAsNoteToSelf -> context.getString(R.string.noteToSelf) + isLocalUser && !showCurrentUserAsNoteToSelf -> context.getString(R.string.you) + else -> usernameUtils.getContactNameWithAccountID(publicKey) + } } val address = user.address.toString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt index f039ecdaa0..28464650cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt @@ -47,7 +47,6 @@ class EditLegacyGroupMembersAdapter( viewHolder.view.bind(Recipient.from( context, Address.fromSerialized(member), false), - glide, if (unlocked) UserView.ActionIndicator.Menu else UserView.ActionIndicator.None) if (zombieMembers.contains(member)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt index a5822b585f..e093d1d8cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt @@ -7,15 +7,14 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent object ContactUtilities { @JvmStatic - fun getAllContacts(context: Context): Set { + fun getAllContacts(context: Context): Set> { val threadDatabase = DatabaseComponent.get(context).threadDatabase() val cursor = threadDatabase.conversationList - val result = mutableSetOf() + val result = mutableSetOf>() threadDatabase.readerFor(cursor).use { reader -> while (reader.next != null) { val thread = reader.current - val recipient = thread.recipient - result.add(recipient) + result.add(Pair(thread.recipient, thread.lastMessage?.timestamp ?: 0)) } } return result diff --git a/app/src/main/res/layout/contact_selection_list_divider.xml b/app/src/main/res/layout/contact_selection_list_divider.xml deleted file mode 100644 index 71098d00cc..0000000000 --- a/app/src/main/res/layout/contact_selection_list_divider.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file From 56ebac51ac8f376cb0a3d6b55b11c610c7965153 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 23 May 2025 11:37:53 +1000 Subject: [PATCH 339/867] Making sure we hide refresh on error and add back monochrome icon --- .../securesms/tokenpage/TokenPageViewModel.kt | 17 ++++++++++------- .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index 07703b084c..6fb3eb5917 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -214,15 +214,18 @@ class TokenPageViewModel @Inject constructor( viewModelScope.launch { // if the data isn't stale then we don't need to refresh it, instead we fake a small wait - if (!tokenDataManager.fetchInfoDataIfNeeded()) { - // If there is no fresh server data then we'll update the UI elements to show their loading - // state for half a second then put them back as they were. - showLoading() - delay(timeMillis = 500) - handleInfoResponse(infoResponse) - } + try { + if (!tokenDataManager.fetchInfoDataIfNeeded(tempDebug = true)) { + // If there is no fresh server data then we'll update the UI elements to show their loading + // state for half a second then put them back as they were. + showLoading() + delay(timeMillis = 500) + handleInfoResponse(infoResponse) + } + } catch (e: Exception){ /* exception can be ignored here as the infoResponse can return a wrapped failure object */ } // Reset the refreshing state when done + delay(100) // it seems there's a bug in compose where the refresh does not go away if hidden too quickly _uiState.update { state -> state.copy( isRefreshing = false diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1fd..ef49c99170 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file From f6125b6c0c509b682628adebcbc4f2b616cddf6f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 23 May 2025 11:41:57 +1000 Subject: [PATCH 340/867] Using alias for readability --- .../securesms/contacts/ContactSelectionListLoader.kt | 3 ++- .../java/org/thoughtcrime/securesms/util/ContactUtilities.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index 1c79c24d52..5dec06f0e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -5,6 +5,7 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.util.AsyncLoader import org.thoughtcrime.securesms.util.ContactUtilities +import org.thoughtcrime.securesms.util.LastMessageSentTimestamp sealed class ContactSelectionListItem { class Contact(val recipient: Recipient) : ContactSelectionListItem() @@ -24,7 +25,7 @@ class ContactSelectionListLoader( if (filter.isNullOrEmpty()) return@filter true it.first.name.contains(filter.trim(), true) || it.first.address.toString().contains(filter.trim(), true) }.sortedWith( - compareBy> { !it.first.isLocalNumber } // NTS come first + compareBy> { !it.first.isLocalNumber } // NTS come first .thenByDescending { it.second } // then order by last message time ) .map { it.first }.toList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt index e093d1d8cf..c9b91d50a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt @@ -4,13 +4,14 @@ import android.content.Context import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent +typealias LastMessageSentTimestamp = Long object ContactUtilities { @JvmStatic - fun getAllContacts(context: Context): Set> { + fun getAllContacts(context: Context): Set> { val threadDatabase = DatabaseComponent.get(context).threadDatabase() val cursor = threadDatabase.conversationList - val result = mutableSetOf>() + val result = mutableSetOf>() threadDatabase.readerFor(cursor).use { reader -> while (reader.next != null) { val thread = reader.current From be0ba0f10da369af80c8bfcef87d71c174ec92b0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 23 May 2025 12:04:48 +1000 Subject: [PATCH 341/867] Removed temp data --- .../org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index 6fb3eb5917..ae6ff30644 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -215,7 +215,7 @@ class TokenPageViewModel @Inject constructor( viewModelScope.launch { // if the data isn't stale then we don't need to refresh it, instead we fake a small wait try { - if (!tokenDataManager.fetchInfoDataIfNeeded(tempDebug = true)) { + if (!tokenDataManager.fetchInfoDataIfNeeded()) { // If there is no fresh server data then we'll update the UI elements to show their loading // state for half a second then put them back as they were. showLoading() From c3625c23a0387625a25fbf4e1789d417de727ffa Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 26 May 2025 10:01:32 +1000 Subject: [PATCH 342/867] Tweaks to message long press UI --- app/build.gradle.kts | 4 ---- .../org/thoughtcrime/securesms/SessionDialogBuilder.kt | 7 ------- app/src/main/res/drawable/context_menu_background.xml | 2 +- .../drawable/conversation_reaction_overlay_background.xml | 2 +- app/src/main/res/values/colors.xml | 4 +--- gradle/libs.versions.toml | 2 -- 6 files changed, 3 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8bd1786e3a..48370b745e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -278,10 +278,6 @@ dependencies { implementation(libs.tooltips) { exclude(group = "com.android.support", module = "appcompat-v7") } - implementation(libs.kinkerapps.android.smsmms) { - exclude(group = "com.squareup.okhttp", module = "okhttp") - exclude(group = "com.squareup.okhttp", module = "okhttp-urlconnection") - } implementation(libs.stream) implementation(libs.androidx.sqlite.ktx) implementation(libs.sqlcipher.android) diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 171a2a7049..d83dbc0663 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -54,13 +54,6 @@ class SessionDialogBuilder(val context: Context) { private val buttonLayout = LinearLayout(context) - private val root = LinearLayout(context).apply { orientation = VERTICAL } - .also(dialogBuilder::setView) - .apply { - addView(contentView) - addView(buttonLayout) - } - // Main title entry point fun title(text: String?) { text( diff --git a/app/src/main/res/drawable/context_menu_background.xml b/app/src/main/res/drawable/context_menu_background.xml index 3691575836..7a447a9ec7 100644 --- a/app/src/main/res/drawable/context_menu_background.xml +++ b/app/src/main/res/drawable/context_menu_background.xml @@ -4,6 +4,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_reaction_overlay_background.xml b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml index 1a8b214ed9..7d34b290f4 100644 --- a/app/src/main/res/drawable/conversation_reaction_overlay_background.xml +++ b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 31a9d12a6d..2a06f4c823 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -56,7 +56,7 @@ #dfffffff #90000000 - #df5e5e5e + #99000000 #26ffffff #30ffffff @@ -85,8 +85,6 @@ @color/core_grey_60 - ?colorPrimary - @color/core_white @color/core_grey_25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69211cbc61..027447754f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ accompanistThemeadapterAppcompatVersion = "0.33.1-alpha" activityKtxVersion = "1.10.1" aesgcmproviderVersion = "0.0.3" androidImageCropperVersion = "4.5.0" -androidSmsmmsVersion = "4.0.1" androidVersion = "125.6422.07" annotationVersion = "1.5.0" assertjCoreVersion = "3.11.1" @@ -129,7 +128,6 @@ zxing-core = { module = "com.google.zxing:core", version.ref = "zxingVersion" } curve25519-java = { module = "com.github.session-foundation.session-android-curve-25519:curve25519-java", version.ref = "curve25519JavaVersion" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabindVersion" } junit = { module = "junit:junit", version.ref = "junitVersion" } -kinkerapps-android-smsmms = { module = "com.klinkerapps:android-smsmms", version.ref = "androidSmsmmsVersion" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityKtxVersion" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" } androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "cardviewVersion" } From 632a5f19ee8936c2552bb05f58fb05e014b9b8c7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 26 May 2025 11:06:35 +1000 Subject: [PATCH 343/867] Removing unused MESSAGE_TYPE for mms table Also making sure the reaction is set above the bottom spacer in conversations --- .../conversation/v2/MessageDetailActivity.kt | 2 +- .../securesms/database/MmsDatabase.kt | 39 +--------- .../model/NotificationMmsMessageRecord.kt | 71 ------------------- .../securesms/debugmenu/DebugMenu.kt | 6 +- .../groups/compose/InviteContactsScreen.kt | 2 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 2 +- .../res/layout/activity_conversation_v2.xml | 16 ++--- 7 files changed, 17 insertions(+), 121 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index ffeaa19e0d..c611268702 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -503,7 +503,7 @@ fun PreviewMessageDetails( ), sent = TitledText(R.string.sent, "6:12 AM Tue, 09/08/2022"), received = TitledText(R.string.received, "6:12 AM Tue, 09/08/2022"), - error = TitledText(R.string.error, "Message failed to send"), + error = TitledText(R.string.errorUnknown, "Message failed to send"), senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"), ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 5d1555b082..e67e6ba04f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -20,7 +20,6 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.PduHeaders import org.apache.commons.lang3.StringUtils import org.json.JSONArray import org.json.JSONException @@ -52,14 +51,12 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.MmsException @@ -606,7 +603,6 @@ class MmsDatabase(context: Context, databaseHelper: Provider. - */ -package org.thoughtcrime.securesms.database.model - -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.mms.SlideDeck - -/** - * Represents the message record model for MMS messages that are - * notifications (ie: they're pointers to undownloaded media). - * - * @author Moxie Marlinspike - */ -class NotificationMmsMessageRecord( - id: Long, conversationRecipient: Recipient?, - individualRecipient: Recipient?, - dateSent: Long, - dateReceived: Long, - deliveryReceiptCount: Int, - threadId: Long, - private val messageSize: Long, - private val expiry: Long, - val status: Int, - mailbox: Long, - slideDeck: SlideDeck?, - readReceiptCount: Int, - hasMention: Boolean -) : MmsMessageRecord( - id, "", conversationRecipient, individualRecipient, - dateSent, dateReceived, threadId, SmsDatabase.Status.STATUS_NONE, deliveryReceiptCount, mailbox, - emptyList(), emptyList(), - 0, 0, slideDeck!!, readReceiptCount, null, emptyList(), emptyList(), emptyList(), hasMention -) { - fun getMessageSize(): Long { - return (messageSize + 1023) / 1024 - } - - val expiration: Long - get() = expiry * 1000 - - override fun isOutgoing(): Boolean { - return false - } - - override fun isPending(): Boolean { - return false - } - - override fun isMmsNotification(): Boolean { - return true - } - - override fun isMediaPending(): Boolean { - return true - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 07b0264951..38997082da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -132,7 +132,7 @@ fun DebugMenu( onClick = { sendCommand(HideDeprecationChangeDialog) } ), DialogButtonModel( - text = GetString(R.string.ok), + text = GetString(android.R.string.ok), onClick = { sendCommand(OverrideDeprecationState) } ) ) @@ -151,7 +151,7 @@ fun DebugMenu( onClick = { sendCommand(HideEnvironmentWarningDialog) } ), DialogButtonModel( - text = GetString(R.string.ok), + text = GetString(android.R.string.ok), onClick = { sendCommand(ChangeEnvironment) } ) ) @@ -402,7 +402,7 @@ fun DebugMenu( } ), DialogButtonModel( - text = GetString(R.string.ok), + text = GetString(android.R.string.ok), onClick = { if (showingDeprecatedTimePicker) { sendCommand( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 85c14f6483..d2021d109b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -70,7 +70,7 @@ fun InviteContacts( onSearchQueryClear: () -> Unit, onDoneClicked: () -> Unit, onBack: () -> Unit, - @StringRes okButtonResId: Int = R.string.ok, + @StringRes okButtonResId: Int = android.R.string.ok, banner: @Composable ()->Unit = {} ) { Scaffold( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index b1c5c88534..d6ddd31af1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -331,7 +331,7 @@ fun PreviewSimpleDialog() { onClick = { } ), DialogButtonModel( - GetString(stringResource(R.string.ok)) + GetString(stringResource(android.R.string.ok)) ) ) ) diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 644e20fd80..cbbf273248 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -65,14 +65,6 @@ app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" android:visibility="gone"/> - - + + Date: Mon, 26 May 2025 13:48:43 +1000 Subject: [PATCH 344/867] Making sure communities do not handle disappearing messages --- .../sending_receiving/ReceivedMessageHandler.kt | 9 +++++++++ .../org/session/libsession/utilities/SSKEnvironment.kt | 6 +++++- .../org/thoughtcrime/securesms/SessionDialogBuilder.kt | 7 +++++++ app/src/main/res/layout/activity_conversation_v2.xml | 1 - 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index bbe34d7c9e..82430c0749 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -93,6 +93,9 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. if (groupv2Id != null) { Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") + } // also ignore it for communities since they do not support disappearing messages + else if (openGroupID != null) { + Log.d("MessageReceiver", "Ignoring expiration timer update for communities") } else { handleExpirationTimerUpdate(message) } @@ -512,6 +515,12 @@ fun MessageReceiver.handleVisibleMessage( // Persist the message message.threadID = threadID + + // clean up the message - For example we do not want any expiration data on messages for communities + if(message.openGroupServerMessageID != null){ + message.expiryMode = ExpiryMode.NONE + } + val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null // Parse & persist attachments // Start attachment downloads if needed diff --git a/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt index 69bca9d440..22fbbcc5a7 100644 --- a/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt +++ b/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -45,7 +45,11 @@ class SSKEnvironment( fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) fun maybeStartExpiration(message: Message, startDisappearAfterRead: Boolean = false) { - if (message is ExpirationTimerUpdate && message.isGroup || message is LegacyGroupControlMessage) return + if ( + message is ExpirationTimerUpdate && message.isGroup || + message is LegacyGroupControlMessage || + message.openGroupServerMessageID != null // ignore expiration on communities since they do not support disappearing mesasges + ) return maybeStartExpiration( message.sentTimestamp ?: return, diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index d83dbc0663..171a2a7049 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -54,6 +54,13 @@ class SessionDialogBuilder(val context: Context) { private val buttonLayout = LinearLayout(context) + private val root = LinearLayout(context).apply { orientation = VERTICAL } + .also(dialogBuilder::setView) + .apply { + addView(contentView) + addView(buttonLayout) + } + // Main title entry point fun title(text: String?) { text( diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index cbbf273248..d506668eaf 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -9,7 +9,6 @@ android:layout_height="match_parent" android:orientation="vertical"> - Date: Mon, 26 May 2025 14:53:51 +1000 Subject: [PATCH 345/867] 16k memory page-size support (#1197) --- app/build.gradle.kts | 2 - .../messaging/file_server/FileServerApi.kt | 19 ++-- .../messaging/jobs/AttachmentDownloadJob.kt | 15 ++-- .../messaging/jobs/AttachmentUploadJob.kt | 2 +- .../jobs/RetrieveProfileAvatarJob.kt | 27 +++--- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../messaging/open_groups/OpenGroupMessage.kt | 55 +++++------- .../MessageSenderClosedGroupHandler.kt | 22 +++-- .../libsession/snode/OnionRequestAPI.kt | 9 +- .../session/libsession/utilities/AESGCM.kt | 25 +++--- .../libsession/utilities/DownloadUtilities.kt | 54 +++-------- .../utilities/ProfilePictureUtilities.kt | 2 +- .../session/libsignal/crypto/ecc/Curve.java | 8 -- .../session/libsignal/crypto/kdf/HKDF.java | 81 ----------------- .../session/libsignal/crypto/kdf/HKDFv3.java | 13 --- .../streams/ProfileCipherInputStream.java | 90 ------------------- .../securesms/ApplicationContext.kt | 26 +----- .../securesms/crypto/IdentityKeyUtil.java | 17 ++-- .../securesms/mms/PushMediaConstraints.java | 10 +-- .../securesms/util/MockDataGenerator.kt | 11 ++- gradle/libs.versions.toml | 6 +- 21 files changed, 141 insertions(+), 355 deletions(-) delete mode 100644 app/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java delete mode 100644 app/src/main/java/org/session/libsignal/crypto/kdf/HKDFv3.java delete mode 100644 app/src/main/java/org/session/libsignal/streams/ProfileCipherInputStream.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8bd1786e3a..d7f23e3edd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -264,7 +264,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.conscrypt.android) - implementation(libs.aesgcmprovider) implementation(libs.android) implementation(libs.shortcutbadger) implementation(libs.photoview) @@ -309,7 +308,6 @@ dependencies { } implementation(libs.kryo) - implementation(libs.curve25519.java) testImplementation(libs.junit) testImplementation(libs.assertj.core) testImplementation(libs.mockito.kotlin) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index b5ee2ae2cc..7e9aa23b58 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -6,6 +6,7 @@ import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody @@ -21,9 +22,11 @@ import kotlin.time.Duration.Companion.milliseconds object FileServerApi { - private const val fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - const val fileServerURL = "http://filev2.getsession.org" - const val maxFileSize = 10_000_000 // 10 MB + private const val FILE_SERVER_PUBLIC_KEY = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" + const val FILE_SERVER_URL = "http://filev2.getsession.org" + const val MAX_FILE_SIZE = 10_000_000 // 10 MB + + val fileServerUrl: HttpUrl by lazy { FILE_SERVER_URL.toHttpUrl() } sealed class Error(message: String) : Exception(message) { object ParsingFailed : Error("Invalid response.") @@ -54,12 +57,8 @@ object FileServerApi { private fun send(request: Request): Promise { - val url = fileServerURL.toHttpUrlOrNull() ?: return Promise.ofFail(Error.InvalidURL) - - val urlBuilder = HttpUrl.Builder() - .scheme(url.scheme) - .host(url.host) - .port(url.port) + val urlBuilder = fileServerUrl + .newBuilder() .addPathSegments(request.endpoint) if (request.verb == HTTP.Verb.GET) { for ((key, value) in request.queryParameters) { @@ -76,7 +75,7 @@ object FileServerApi { HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) } return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), fileServerURL, fileServerPublicKey).map { + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), FILE_SERVER_URL, FILE_SERVER_PUBLIC_KEY).map { it.body ?: throw Error.ParsingFailed }.fail { e -> when (e) { diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 0629a4f97b..ea76dbfea4 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -18,7 +18,6 @@ import org.session.libsignal.streams.AttachmentCipherInputStream import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ByteArraySlice.Companion.write import org.thoughtcrime.securesms.database.model.MessageId import java.io.File import java.io.FileInputStream @@ -141,19 +140,23 @@ class AttachmentDownloadJob(val attachmentID: Long, val mmsMessageId: Long) : Jo return } messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.mmsMessageId) - tempFile = createTempFile() val openGroup = storage.getOpenGroup(threadID) - if (openGroup == null) { + val downloadedData = if (openGroup == null) { Log.d("AttachmentDownloadJob", "downloading normal attachment") - DownloadUtilities.downloadFile(tempFile, attachment.url) + DownloadUtilities.downloadFromFileServer(attachment.url) } else { Log.d("AttachmentDownloadJob", "downloading open group attachment") val url = attachment.url.toHttpUrlOrNull()!! val fileID = url.pathSegments.last() - OpenGroupApi.download(fileID, openGroup.room, openGroup.server).await().let { data -> - FileOutputStream(tempFile).use { output -> output.write(data) } + OpenGroupApi.download(fileID, openGroup.room, openGroup.server).await() + } + + tempFile = createTempFile().also { file -> + FileOutputStream(file).use { + it.write(downloadedData.data, downloadedData.offset, downloadedData.len) } } + Log.d("AttachmentDownloadJob", "getting input stream") val inputStream = getInputStream(tempFile, attachment) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 93a0a0001b..88d9d5dd34 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -64,7 +64,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess } handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } else { - val keyAndResult = upload(attachment, FileServerApi.fileServerURL, true) { + val keyAndResult = upload(attachment, FileServerApi.FILE_SERVER_URL, true) { FileServerApi.upload(it) } handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt index 963201c978..a60b3e2449 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -1,24 +1,22 @@ package org.session.libsession.messaging.jobs +import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.Address -import org.session.libsession.utilities.DownloadUtilities.downloadFile +import org.session.libsession.utilities.DownloadUtilities.downloadFromFileServer import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL -import org.session.libsession.utilities.Util.copy import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.exceptions.NonRetryableException -import org.session.libsignal.streams.ProfileCipherInputStream import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream -import java.io.InputStream import java.util.concurrent.ConcurrentSkipListSet class RetrieveProfileAvatarJob( @@ -78,14 +76,19 @@ class RetrieveProfileAvatarJob( return } - val downloadDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) try { - downloadFile(downloadDestination, profileAvatar) - val avatarStream: InputStream = ProfileCipherInputStream(FileInputStream(downloadDestination), profileKey) - val decryptDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) - copy(avatarStream, FileOutputStream(decryptDestination)) - decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address)) + val downloaded = downloadFromFileServer(profileAvatar) + val decrypted = AESGCM.decrypt( + downloaded.data, + offset = downloaded.offset, + len = downloaded.len, + symmetricKey = profileKey + ) + + FileOutputStream(AvatarHelper.getAvatarFile(context, recipient.address)).use { out -> + out.write(decrypted) + } if (recipient.isLocalNumber) { setProfileAvatarId(context, SECURE_RANDOM.nextInt()) @@ -111,8 +114,6 @@ class RetrieveProfileAvatarJob( } return delegate.handleJobFailed(this, dispatcherName, e) } - } finally { - downloadDestination.delete() } return delegate.handleJobSucceeded(this, dispatcherName) } diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index fa74925bd0..271ac5fb55 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -450,7 +450,7 @@ object OpenGroupApi { whisperMods: Boolean? = null, fileIds: List? = null ): Promise { - val signedMessage = message.sign(room, server, fallbackSigningType = IdPrefix.STANDARD) ?: return Promise.ofFail(Error.SigningFailed) + val signedMessage = message.sign(room, server) ?: return Promise.ofFail(Error.SigningFailed) val parameters = signedMessage.toJSON().toMutableMap() // add file IDs if there are any (from attachments) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt index 603c076b55..3dfb4afc70 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.open_groups +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability @@ -7,11 +8,9 @@ import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64.decode -import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString -import org.whispersystems.curve25519.Curve25519 data class OpenGroupMessage( val serverID: Long? = null, @@ -30,8 +29,6 @@ data class OpenGroupMessage( ) { companion object { - private val curve = Curve25519.getInstance(Curve25519.BEST) - fun fromJSON(json: Map): OpenGroupMessage? { val base64EncodedData = json["data"] as? String ?: return null val sentTimestamp = json["posted"] as? Double ?: return null @@ -48,37 +45,33 @@ data class OpenGroupMessage( } } - fun sign(room: String, server: String, fallbackSigningType: IdPrefix): OpenGroupMessage? { + fun sign(room: String, server: String): OpenGroupMessage? { if (base64EncodedData.isNullOrEmpty()) return null val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: return null val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server) - val signature = when { - serverCapabilities.contains(Capability.BLIND.name.lowercase()) -> { - runCatching { - BlindKeyAPI.blind15Sign( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = openGroup.publicKey, - message = decode(base64EncodedData) - ) - }.onFailure { - Log.e("OpenGroupMessage", "Failed to sign message with blind key", it) - }.getOrNull() ?: return null - } - - fallbackSigningType == IdPrefix.UN_BLINDED -> { - curve.calculateSignature(userEdKeyPair.secretKey.data, decode(base64EncodedData)) - } - - else -> { - val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().let { it.publicKey.serialize() to it.privateKey.serialize() } - if (sender != publicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null - try { - curve.calculateSignature(privateKey, decode(base64EncodedData)) - } catch (e: Exception) { - Log.w("Loki", "Couldn't sign open group message.", e) - return null - } + val signature = if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + runCatching { + BlindKeyAPI.blind15Sign( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = openGroup.publicKey, + message = decode(base64EncodedData) + ) + }.onFailure { + Log.e("OpenGroupMessage", "Failed to sign message with blind key", it) + }.getOrNull() ?: return null + } + else { + val x25519PublicKey = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().publicKey.serialize() + if (sender != x25519PublicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null + try { + ED25519.sign( + ed25519PrivateKey = userEdKeyPair.secretKey.data, + message = decode(base64EncodedData) + ) + } catch (e: Exception) { + Log.w("Loki", "Couldn't sign open group message.", e) + return null } } return copy(base64EncodedSignature = Base64.encodeBytes(signature)) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index b47e1b3939..cbfb3bf687 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -5,6 +5,7 @@ package org.session.libsession.messaging.sending_receiving import com.google.protobuf.ByteString import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel +import network.loki.messenger.libsession_util.Curve25519 import nl.komponents.kovenant.Promise import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.GroupLeavingJob @@ -19,15 +20,16 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Device import org.session.libsession.utilities.GroupUtil -import org.session.libsignal.crypto.ecc.Curve +import org.session.libsignal.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional -import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.session.libsignal.utilities.toHexString import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap @@ -46,9 +48,14 @@ fun MessageSender.create( val userPublicKey = storage.getUserPublicKey()!! val membersAsData = members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) } // Generate the group's public key - val groupPublicKey = Curve.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix + val groupPublicKey = Curve25519.generateKeyPair().pubKey.data.toHexString() // Includes the "05" prefix // Generate the key pair that'll be used for encryption and decryption - val encryptionKeyPair = Curve.generateKeyPair() + val encryptionKeyPair = Curve25519.generateKeyPair().let { k -> + ECKeyPair( + DjbECPublicKey(k.pubKey.data), + DjbECPrivateKey(k.secretKey.data), + ) + } // Create the group val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val admins = setOf( userPublicKey ) @@ -257,7 +264,12 @@ fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, ta throw Error.InvalidClosedGroupUpdate } // Generate the new encryption key pair - val newKeyPair = Curve.generateKeyPair() + val newKeyPair = Curve25519.generateKeyPair().let { + ECKeyPair( + DjbECPublicKey(it.pubKey.data), + DjbECPrivateKey(it.secretKey.data), + ) + } // Replace call will not succeed if no value already set pendingKeyPairs.putIfAbsent(groupPublicKey,Optional.absent()) do { diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index bb8aa6231e..265a4a6cee 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -341,7 +341,7 @@ object OnionRequestAPI { val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" val finalEncryptionResult = result.finalEncryptionResult val onion = finalEncryptionResult.ciphertext - if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.maxFileSize.toDouble()) { + if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.MAX_FILE_SIZE.toDouble()) { Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.") } @Suppress("NAME_SHADOWING") val parameters = mapOf( @@ -525,7 +525,7 @@ object OnionRequestAPI { if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response")) // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into // parts to properly process it - val plaintext = AESGCM.decrypt(response, destinationSymmetricKey) + val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response")) val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } val infoLenSlice = plaintext.slice(1 until infoSepIdx) @@ -581,7 +581,10 @@ object OnionRequestAPI { val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON")) val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) try { - val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey) + val plaintext = AESGCM.decrypt( + ivAndCiphertext, + symmetricKey = destinationSymmetricKey + ) try { @Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) diff --git a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt index e4438c577b..4685b5767c 100644 --- a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt +++ b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -1,11 +1,12 @@ package org.session.libsession.utilities import androidx.annotation.WorkerThread +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK import org.session.libsignal.utilities.ByteUtil -import org.session.libsignal.utilities.Util import org.session.libsignal.utilities.Hex -import org.whispersystems.curve25519.Curve25519 +import org.session.libsignal.utilities.Util import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec @@ -25,13 +26,17 @@ internal object AESGCM { /** * Sync. Don't call from the main thread. */ - internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray { - val iv = ivAndCiphertext.sliceArray(0 until ivSize) - val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count()) + internal fun decrypt( + ivAndCiphertext: ByteArray, + offset: Int = 0, + len: Int = ivAndCiphertext.size, + symmetricKey: ByteArray + ): ByteArray { + val iv = ivAndCiphertext.sliceArray(offset until (offset + ivSize)) synchronized(CIPHER_LOCK) { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) - return cipher.doFinal(ciphertext) + return cipher.doFinal(ivAndCiphertext, offset + ivSize, len - ivSize) } } @@ -39,7 +44,7 @@ internal object AESGCM { * Sync. Don't call from the main thread. */ private fun generateSymmetricKey(x25519PublicKey: ByteArray, x25519PrivateKey: ByteArray): ByteArray { - val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, x25519PrivateKey) + val ephemeralSharedSecret = SessionEncrypt.calculateECHDAgreement(x25519PubKey = x25519PublicKey, x25519PrivKey = x25519PrivateKey) val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256")) return mac.doFinal(ephemeralSharedSecret) @@ -62,10 +67,10 @@ internal object AESGCM { */ internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult { val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey) - val ephemeralKeyPair = Curve25519.getInstance(Curve25519.BEST).generateKeyPair() - val symmetricKey = generateSymmetricKey(x25519PublicKey, ephemeralKeyPair.privateKey) + val ephemeralKeyPair = Curve25519.generateKeyPair() + val symmetricKey = generateSymmetricKey(x25519PublicKey, ephemeralKeyPair.secretKey.data) val ciphertext = encrypt(plaintext, symmetricKey) - return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey) + return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.pubKey.data) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt b/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt index 585abe3353..94cf7ef290 100644 --- a/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -1,55 +1,29 @@ package org.session.libsession.utilities -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.HttpUrl.Companion.toHttpUrl import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.snode.utilities.await -import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ByteArraySlice.Companion.write -import java.io.File -import java.io.OutputStream object DownloadUtilities { - - /** - * Blocks the calling thread. - */ - suspend fun downloadFile(destination: File, url: String) { - var remainingAttempts = 2 - var exception: Exception? = null - - destination.outputStream().use { outputStream -> - while (remainingAttempts > 0) { - remainingAttempts -= 1 - - try { - downloadFile(outputStream, url) - return // return on success - } catch (e: HTTP.HTTPRequestFailedException) { - exception = e - } catch (e: Exception) { - exception = e - } - } - } - - throw exception ?: NonRetryableException("Couldn't download file: $url") - } - /** - * Blocks the calling thread. + * Downloads a file from the file server using the provided URL. + * + * This will assume the URL is a valid file server URL, and if not, */ - suspend fun downloadFile(outputStream: OutputStream, urlAsString: String) { - val url = urlAsString.toHttpUrlOrNull()!! - val fileID = url.pathSegments.last() + suspend fun downloadFromFileServer(urlAsString: String): ByteArraySlice { try { - val data = FileServerApi.download(fileID).await() - withContext(Dispatchers.IO) { - outputStream.write(data) + val url = urlAsString.toHttpUrl() + require(url.host == FileServerApi.fileServerUrl.host) { + "Invalid file server URL: $url" + } + val fileID = checkNotNull(url.pathSegments.lastOrNull()) { + "No file ID found in URL: $url" } + + return FileServerApi.download(fileID).await() } catch (e: Exception) { when (e) { // No need for the stack trace for HTTP errors diff --git a/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt b/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt index 79f967371e..b682fbf193 100644 --- a/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt +++ b/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt @@ -112,7 +112,7 @@ object ProfilePictureUtilities { }.await() TextSecurePreferences.setLastProfilePictureUpload(context, Date().time) - val url = "${FileServerApi.fileServerURL}/file/$id" + val url = "${FileServerApi.FILE_SERVER_URL}/file/$id" TextSecurePreferences.setProfilePictureURL(context, url) return url diff --git a/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java b/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java index da03b2a41a..e31eeaf0fe 100644 --- a/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java +++ b/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java @@ -5,20 +5,12 @@ */ package org.session.libsignal.crypto.ecc; -import org.whispersystems.curve25519.Curve25519; -import org.whispersystems.curve25519.Curve25519KeyPair; import org.session.libsignal.exceptions.InvalidKeyException; -import static org.whispersystems.curve25519.Curve25519.BEST; public class Curve { public static final int DJB_TYPE = 0x05; - public static ECKeyPair generateKeyPair() { - Curve25519KeyPair keyPair = Curve25519.getInstance(BEST).generateKeyPair(); - return new ECKeyPair(new DjbECPublicKey(keyPair.getPublicKey()), new DjbECPrivateKey(keyPair.getPrivateKey())); - } - public static ECPublicKey decodePoint(byte[] bytes, int offset) throws InvalidKeyException { diff --git a/app/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java b/app/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java deleted file mode 100644 index 73c87c075d..0000000000 --- a/app/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (C) 2013-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package org.session.libsignal.crypto.kdf; - -import java.io.ByteArrayOutputStream; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -public abstract class HKDF { - - private static final int HASH_OUTPUT_SIZE = 32; - - public static HKDF createFor(int messageVersion) { - switch (messageVersion) { - case 3: return new HKDFv3(); - default: throw new AssertionError("Unknown version: " + messageVersion); - } - } - - public byte[] deriveSecrets(byte[] inputKeyMaterial, byte[] info, int outputLength) { - byte[] salt = new byte[HASH_OUTPUT_SIZE]; - return deriveSecrets(inputKeyMaterial, salt, info, outputLength); - } - - public byte[] deriveSecrets(byte[] inputKeyMaterial, byte[] salt, byte[] info, int outputLength) { - byte[] prk = extract(salt, inputKeyMaterial); - return expand(prk, info, outputLength); - } - - private byte[] extract(byte[] salt, byte[] inputKeyMaterial) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(salt, "HmacSHA256")); - return mac.doFinal(inputKeyMaterial); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new AssertionError(e); - } - } - - private byte[] expand(byte[] prk, byte[] info, int outputSize) { - try { - int iterations = (int) Math.ceil((double) outputSize / (double) HASH_OUTPUT_SIZE); - byte[] mixin = new byte[0]; - ByteArrayOutputStream results = new ByteArrayOutputStream(); - int remainingBytes = outputSize; - - for (int i= getIterationStartOffset();i outputLength) { - throw new AssertionError("Need: " + cipher.getOutputSize(0) + " but only have: " + outputLength); - } - - finished = true; - return cipher.doFinal(output, outputOffset); - } else { - if (cipher.getOutputSize(read) > outputLength) { - throw new AssertionError("Need: " + cipher.getOutputSize(read) + " but only have: " + outputLength); - } - - return cipher.update(ciphertext, 0, read, output, outputOffset); - } - } - } catch (IllegalBlockSizeException | ShortBufferException e) { - throw new AssertionError(e); - } catch (BadPaddingException e) { - throw new IOException(e); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 8d0c900ad5..63ee45e3e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -66,7 +66,6 @@ import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue -import org.signal.aesgcmprovider.AesGcmProvider import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.configs.ConfigUploader @@ -426,29 +425,8 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, // Loki private fun initializeSecurityProvider() { - try { - Class.forName("org.signal.aesgcmprovider.AesGcmCipher") - } catch (e: ClassNotFoundException) { - Log.e(TAG, "Failed to find AesGcmCipher class") - throw ProviderInitializationException() - } - - val aesPosition = Security.insertProviderAt(AesGcmProvider(), 1) - Log.i( - TAG, - "Installed AesGcmProvider: $aesPosition" - ) - - if (aesPosition < 0) { - Log.e(TAG, "Failed to install AesGcmProvider()") - throw ProviderInitializationException() - } - - val conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2) - Log.i( - TAG, - "Installed Conscrypt provider: $conscryptPosition" - ) + val conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 0) + Log.i(TAG, "Installed Conscrypt provider: $conscryptPosition") if (conscryptPosition < 0) { Log.w(TAG, "Did not install Conscrypt provider. May already be present.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java index e2fe41b625..52826a2838 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -27,6 +27,8 @@ import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKeyPair; import org.session.libsignal.crypto.ecc.Curve; +import org.session.libsignal.crypto.ecc.DjbECPrivateKey; +import org.session.libsignal.crypto.ecc.DjbECPublicKey; import org.session.libsignal.crypto.ecc.ECKeyPair; import org.session.libsignal.crypto.ecc.ECPrivateKey; import org.session.libsignal.crypto.ecc.ECPublicKey; @@ -40,6 +42,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow; import kotlinx.coroutines.flow.MutableStateFlow; import kotlinx.coroutines.flow.SharedFlowKt; +import network.loki.messenger.libsession_util.Curve25519; +import network.loki.messenger.libsession_util.util.KeyPair; /** * Utility class for working with identity keys. @@ -117,11 +121,14 @@ public static void checkUpdate(Context context) { } public static void generateIdentityKeyPair(@NonNull Context context) { - ECKeyPair keyPair = Curve.generateKeyPair(); - ECPublicKey publicKey = keyPair.getPublicKey(); - ECPrivateKey privateKey = keyPair.getPrivateKey(); - save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(publicKey.serialize())); - save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(privateKey.serialize())); + KeyPair keyPair = Curve25519.INSTANCE.generateKeyPair(); + ECKeyPair ecKeyPair = new ECKeyPair( + new DjbECPublicKey(keyPair.getPubKey().getData()), + new DjbECPrivateKey(keyPair.getSecretKey().getData()) + ); + + save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(ecKeyPair.getPublicKey().serialize())); + save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(ecKeyPair.getPrivateKey().serialize())); } public static String retrieve(Context context, String key) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index 22af450aa8..2db9ea596b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -21,26 +21,26 @@ public int getImageMaxHeight(Context context) { @Override public int getImageMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getGifMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getVideoMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getAudioMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getDocumentMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index d0aa1654a7..54fe4361f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.Context +import network.loki.messenger.libsession_util.Curve25519 import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.messages.signal.IncomingTextMessage @@ -11,6 +12,9 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.Curve +import org.session.libsignal.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional @@ -232,7 +236,12 @@ object MockDataGenerator { storage.addClosedGroupPublicKey(randomGroupPublicKey) // Add the group to the user's set of public keys to poll for and store the key pair - val encryptionKeyPair = Curve.generateKeyPair() + val encryptionKeyPair = Curve25519.generateKeyPair().let { + ECKeyPair( + DjbECPublicKey(it.pubKey.data), + DjbECPrivateKey(it.secretKey.data) + ) + } storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69211cbc61..f59b8218e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,6 @@ androidCompileSdkVersion = "35" accompanistPermissionsVersion = "0.36.0" accompanistThemeadapterAppcompatVersion = "0.33.1-alpha" activityKtxVersion = "1.10.1" -aesgcmproviderVersion = "0.0.3" androidImageCropperVersion = "4.5.0" androidSmsmmsVersion = "4.0.1" androidVersion = "125.6422.07" @@ -22,7 +21,6 @@ conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" copperFlowVersion = "1.0.0" coreTestingVersion = "2.2.0" -curve25519JavaVersion = "0.6.0" espressoCoreVersion = "3.5.1" eventbusVersion = "3.0.0" exifinterfaceVersion = "1.3.4" @@ -38,7 +36,7 @@ kotlinxDatetimeVersion = "0.6.0" kryoVersion = "5.1.1" kspVersion = "2.1.10-1.0.31" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.4-4-g81b2127" +libsessionUtilAndroidVersion = "1.0.4-5-ga909332" media3ExoplayerVersion = "1.4.0" mockitoCoreVersion = "5.17.0" navVersion = "2.9.0" @@ -84,7 +82,6 @@ huaweiPushVersion = "6.7.0.300" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistThemeadapterAppcompatVersion" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } -aesgcmprovider = { module = "org.signal:aesgcmprovider", version.ref = "aesgcmproviderVersion" } android = { module = "io.github.webrtc-sdk:android", version.ref = "androidVersion" } android-image-cropper = { module = "com.vanniktech:android-image-cropper", version.ref = "androidImageCropperVersion" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotationVersion" } @@ -126,7 +123,6 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kryo = { module = "com.esotericsoftware:kryo", version.ref = "kryoVersion" } libsession-util-android = { module = "org.sessionfoundation:libsession-util-android", version.ref = "libsessionUtilAndroidVersion" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxingVersion" } -curve25519-java = { module = "com.github.session-foundation.session-android-curve-25519:curve25519-java", version.ref = "curve25519JavaVersion" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabindVersion" } junit = { module = "junit:junit", version.ref = "junitVersion" } kinkerapps-android-smsmms = { module = "com.klinkerapps:android-smsmms", version.ref = "androidSmsmmsVersion" } From 2a7fa53067db49317c84c4dd305cc47e3317b900 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 26 May 2025 15:32:09 +1000 Subject: [PATCH 346/867] Hide input for blinded recipients that have not given message request permissions --- .../conversation/v2/ConversationViewModel.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 182d4fb200..7ded2b88f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -181,11 +181,14 @@ class ConversationViewModel( val blindedRecipient: Recipient? get() = _recipient.value?.let { recipient -> - when { - recipient.isCommunityOutboxRecipient -> recipient - recipient.isCommunityInboxRecipient -> repository.maybeGetBlindedRecipient(recipient) - else -> null - } + getBlindedRecipient(recipient) + } + + private fun getBlindedRecipient(recipient: Recipient?): Recipient? = + when { + recipient?.isCommunityOutboxRecipient == true -> recipient + recipient?.isCommunityInboxRecipient == true -> repository.maybeGetBlindedRecipient(recipient) + else -> null } /** @@ -489,6 +492,7 @@ class ConversationViewModel( * 2. The legacy group is inactive, OR * 3. The legacy group is deprecated, OR * 4. The community chat is read only + * 5. Blinded recipient who have disabled message request from community members */ private fun shouldShowInput(recipient: Recipient?, community: OpenGroup?, @@ -501,6 +505,7 @@ class ConversationViewModel( deprecationState != LegacyGroupDeprecationManager.DeprecationState.DEPRECATED } community != null -> community.canWrite + getBlindedRecipient(recipient)?.blocksCommunityMessageRequests == true -> false else -> true } } From 55ad6a6aad8b13f3f15d64f069c961e279fe961d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 26 May 2025 15:58:33 +1000 Subject: [PATCH 347/867] Missing QA tag --- .../org/thoughtcrime/securesms/preferences/SettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 7157e03c0c..6bdf6b7b6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -510,7 +510,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { } Divider() - LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole) { push() } + LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole, Modifier.qaTag(R.string.AccessibilityId_sessionPrivacy)) { push() } Divider() LargeItemButton(R.string.sessionNotifications, R.drawable.ic_volume_2, Modifier.qaTag(R.string.AccessibilityId_notifications)) { push() } From aede7a015b7efb1712e3aaef0df1dea4f2edc22e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 26 May 2025 16:03:28 +1000 Subject: [PATCH 348/867] Fixed input padding --- .../conversation/v2/settings/ConversationSettingsDialogs.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 79c8110cd0..031fd577d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.settings import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -96,6 +97,7 @@ fun ConversationSettingsDialogs( .focusRequester(focusRequester) .padding(top = LocalDimensions.current.smallSpacing), placeholder = stringResource(R.string.nicknameEnter), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), onChange = { updatedText -> sendCommand(UpdateNickname(updatedText)) }, @@ -147,6 +149,7 @@ fun ConversationSettingsDialogs( modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_input) .focusRequester(focusRequester) .padding(top = LocalDimensions.current.smallSpacing), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), placeholder = stringResource(R.string.groupNameEnter), onChange = { updatedText -> sendCommand(UpdateGroupName(updatedText)) @@ -160,6 +163,7 @@ fun ConversationSettingsDialogs( modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_description_input) .padding(top = LocalDimensions.current.xxsSpacing), placeholder = stringResource(R.string.groupDescriptionEnter), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), minLines = 3, maxLines = 12, onChange = { updatedText -> From 39d4fcf4d146c801728f74ca54af2ab68a26650b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 27 May 2025 09:57:11 +1000 Subject: [PATCH 349/867] Modified the inputbar to cater more easily to the various states it can be set to --- .../conversation/v2/ConversationActivityV2.kt | 15 ++-- .../conversation/v2/ConversationViewModel.kt | 77 ++++++++++++++----- .../conversation/v2/input_bar/InputBar.kt | 42 +++++++--- app/src/main/res/layout/view_input_bar.xml | 8 +- .../v2/ConversationViewModelTest.kt | 18 ----- 5 files changed, 99 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 6000abd859..df68ce8fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -677,6 +677,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } startActivity(intent) } + + is ConversationUiEvent.ShowUnblockConfirmation -> { + unblock() + } } } } @@ -1093,12 +1097,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> - binding.inputBar.run { - isVisible = state.showInput - allowAttachMultimediaButtons = state.enableAttachMediaControls - // if the user is blocked, hide input and show blocked message - setBlockedState(state.userBlocked) - } + binding.inputBar.setState(state.inputBarState) binding.root.requestApplyInsets() @@ -1512,10 +1511,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - override fun unblockUserFromInput() { - unblock() - } - fun unblock() { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for unblock action") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 7ded2b88f3..59430a9b2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -322,10 +322,8 @@ class ConversationViewModel( _uiState.update { it.copy( shouldExit = recipient == null, - showInput = shouldShowInput(recipient, community, deprecationState), - enableAttachMediaControls = shouldEnableInputMediaControls(recipient), + inputBarState = getInputBarState(recipient, community, deprecationState), messageRequestState = buildMessageRequestState(recipient), - userBlocked = recipient?.isBlocked ?: false ) } } @@ -337,8 +335,7 @@ class ConversationViewModel( _uiState.update { it.copy( shouldExit = recipient == null, - enableAttachMediaControls = shouldEnableInputMediaControls(recipient), - userBlocked = recipient?.isBlocked ?: false + inputBarState = getInputBarState(recipient, _openGroup.value, legacyGroupDeprecationManager.deprecationState.value), ) } } @@ -362,6 +359,46 @@ class ConversationViewModel( } } + private fun getInputBarState( + recipient: Recipient?, + community: OpenGroup?, + deprecationState: LegacyGroupDeprecationManager.DeprecationState + ): InputBarState { + return when { + // prioritise cases that demand the input to be hidden + !shouldShowInput(recipient, community, deprecationState) -> InputBarState( + contentState = InputBarContentState.Hidden, + enableAttachMediaControls = false + ) + + // next are cases where the input is visible but disabled + // when the recipient is blocked + recipient?.isBlocked == true -> InputBarState( + contentState = InputBarContentState.Disabled( + text = application.getString(R.string.blockBlockedDescription), + onClick = { + _uiEvents.tryEmit(ConversationUiEvent.ShowUnblockConfirmation) + } + ), + enableAttachMediaControls = false + ) + + // the user does not have write access in the community + openGroup?.canWrite == false -> InputBarState( + contentState = InputBarContentState.Disabled( + text = "You don't have write permissions in this community", //todo INPUT replace with real crowdin string + ), + enableAttachMediaControls = false + ) + + // other cases the input is visible, and the buttons might be disabled based on some criteria + else -> InputBarState( + contentState = InputBarContentState.Visible, + enableAttachMediaControls = shouldEnableInputMediaControls(recipient) + ) + } + } + private fun updateAppBarData(conversation: Recipient?) { viewModelScope.launch { // sort out the pager data, if any @@ -491,8 +528,7 @@ class ConversationViewModel( * 1. The user has been kicked from a group(v2), OR * 2. The legacy group is inactive, OR * 3. The legacy group is deprecated, OR - * 4. The community chat is read only - * 5. Blinded recipient who have disabled message request from community members + * 4. Blinded recipient who have disabled message request from community members */ private fun shouldShowInput(recipient: Recipient?, community: OpenGroup?, @@ -504,7 +540,6 @@ class ConversationViewModel( groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true && deprecationState != LegacyGroupDeprecationManager.DeprecationState.DEPRECATED } - community != null -> community.canWrite getBlindedRecipient(recipient)?.blocksCommunityMessageRequests == true -> false else -> true } @@ -1071,15 +1106,6 @@ class ConversationViewModel( updateAppBarData(recipient) } - /** - * The input should be hidden when: - * - We are in a community without write access - * - We are dealing with a contact from a community (blinded recipient) that does not allow - * requests form community members - */ - fun shouldHideInputBar(): Boolean = openGroup?.canWrite == false || - blindedRecipient?.blocksCommunityMessageRequests == true - fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run { storage.getLastLegacyRecipient(address.toString())?.let { Recipient.from(context, Address.fromSerialized(it), false) } } @@ -1351,23 +1377,32 @@ data class ConversationUiState( val uiMessages: List = emptyList(), val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible, val shouldExit: Boolean = false, - val showInput: Boolean = true, + val inputBarState: InputBarState = InputBarState(), + val showLoader: Boolean = false, +) + +data class InputBarState( + val contentState: InputBarContentState = InputBarContentState.Visible, // Note: These input media controls are with regard to whether the user can attach multimedia files // or record voice messages to be sent to a recipient - they are NOT things like video or audio // playback controls. val enableAttachMediaControls: Boolean = true, +) - val userBlocked: Boolean = false, +sealed interface InputBarContentState { + data object Hidden : InputBarContentState + data object Visible : InputBarContentState + data class Disabled(val text: String, val onClick: (() -> Unit)? = null) : InputBarContentState +} - val showLoader: Boolean = false, -) sealed interface ConversationUiEvent { data class NavigateToConversation(val threadId: Long) : ConversationUiEvent data class ShowDisappearingMessages(val threadId: Long) : ConversationUiEvent data class ShowNotificationSettings(val threadId: Long) : ConversationUiEvent data class ShowGroupMembers(val groupId: String) : ConversationUiEvent + data object ShowUnblockConfirmation : ConversationUiEvent } sealed interface MessageRequestUiState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 13e79ad5e4..f0ab330b73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -21,6 +21,8 @@ import network.loki.messenger.databinding.ViewInputBarBinding import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.InputBarContentState +import org.thoughtcrime.securesms.conversation.v2.InputBarState import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftView import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.QuoteView @@ -70,7 +72,7 @@ class InputBar @JvmOverloads constructor( showOrHideInputIfNeeded() } } - var allowAttachMultimediaButtons: Boolean = true + private var allowAttachMultimediaButtons: Boolean = true set(value) { field = value updateMultimediaButtonsState() @@ -160,10 +162,6 @@ class InputBar @JvmOverloads constructor( val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled binding.inputBarEditText.delegate = this - - binding.blockedBanner.setSafeOnClickListener { - delegate?.unblockUserFromInput() - } } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { @@ -281,9 +279,36 @@ class InputBar @JvmOverloads constructor( binding.inputBarEditText.setEditableFactory(factory) } - fun setBlockedState(blocked: Boolean){ - binding.inputBarEditText.isVisible = !blocked - binding.blockedBanner.isVisible = blocked + fun setState(state: InputBarState){ + // handle content state + when(state.contentState){ + is InputBarContentState.Hidden ->{ + isVisible = false + } + + is InputBarContentState.Disabled ->{ + isVisible = true + binding.inputBarEditText.isVisible = false + binding.disabledBanner.isVisible = true + binding.disabledText.text = state.contentState.text + if(state.contentState.onClick == null){ + binding.disabledBanner.setOnClickListener(null) + } else { + binding.disabledBanner.setOnClickListener { + state.contentState.onClick() + } + } + } + + else -> { + isVisible = true + binding.inputBarEditText.isVisible = true + binding.disabledBanner.isVisible = false + } + } + + // handle buttons state + allowAttachMultimediaButtons = state.enableAttachMediaControls } } @@ -296,6 +321,5 @@ interface InputBarDelegate { fun onMicrophoneButtonCancel(event: MotionEvent) fun onMicrophoneButtonUp(event: MotionEvent) fun sendMessage() - fun unblockUserFromInput() fun commitInputContent(contentUri: Uri) } diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index ce81370d28..10f9c1db8f 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -1,7 +1,8 @@ + android:layout_height="wrap_content" + xmlns:tools="http://schemas.android.com/tools"> + tools:text="@string/blockBlockedDescription" /> \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index aa9376b102..c1e49ff3b9 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -203,22 +203,4 @@ class ConversationViewModelTest: BaseViewModelTest() { whenever(recipient.isCommunityInboxRecipient).thenReturn(false) assertThat(viewModel.blindedRecipient, nullValue()) } - - @Test - fun `local recipient should have input and no blinded recipient`() = runBlockingTest { - whenever(recipient.isLocalNumber).thenReturn(true) - assertThat(viewModel.shouldHideInputBar(), equalTo(false)) - assertThat(viewModel.blindedRecipient, nullValue()) - } - - @Test - fun `contact recipient should hide input bar if not accepting requests`() = runBlockingTest { - whenever(recipient.isCommunityInboxRecipient).thenReturn(true) - val blinded = mock { - whenever(it.blocksCommunityMessageRequests).thenReturn(true) - } - whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded) - assertThat(viewModel.blindedRecipient, notNullValue()) - assertThat(viewModel.shouldHideInputBar(), equalTo(true)) - } } \ No newline at end of file From c053ac40865d7d2fee35357d66a78a5ef3a6084f Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Tue, 27 May 2025 00:53:17 +0000 Subject: [PATCH 350/867] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+af+ZA/strings.xml | 1 - app/src/main/res/values-b+ar+SA/strings.xml | 1 - app/src/main/res/values-b+az+AZ/strings.xml | 1 - app/src/main/res/values-b+bal+BA/strings.xml | 1 - app/src/main/res/values-b+be+BY/strings.xml | 1 - app/src/main/res/values-b+bg+BG/strings.xml | 1 - app/src/main/res/values-b+bn+BD/strings.xml | 1 - app/src/main/res/values-b+ca+ES/strings.xml | 1 - app/src/main/res/values-b+cs+CZ/strings.xml | 1 - app/src/main/res/values-b+cy+GB/strings.xml | 1 - app/src/main/res/values-b+da+DK/strings.xml | 1 - app/src/main/res/values-b+de+DE/strings.xml | 1 - app/src/main/res/values-b+el+GR/strings.xml | 1 - app/src/main/res/values-b+eo+UY/strings.xml | 1 - app/src/main/res/values-b+es+419/strings.xml | 1 - app/src/main/res/values-b+es+ES/strings.xml | 1 - app/src/main/res/values-b+et+EE/strings.xml | 1 - app/src/main/res/values-b+eu+ES/strings.xml | 1 - app/src/main/res/values-b+fa+IR/strings.xml | 1 - app/src/main/res/values-b+fi+FI/strings.xml | 1 - app/src/main/res/values-b+fil+PH/strings.xml | 1 - app/src/main/res/values-b+fr+FR/strings.xml | 1 - app/src/main/res/values-b+gl+ES/strings.xml | 1 - app/src/main/res/values-b+ha+HG/strings.xml | 1 - app/src/main/res/values-b+he+IL/strings.xml | 1 - app/src/main/res/values-b+hi+IN/strings.xml | 1 - app/src/main/res/values-b+hr+HR/strings.xml | 1 - app/src/main/res/values-b+hu+HU/strings.xml | 1 - app/src/main/res/values-b+hy+AM/strings.xml | 1 - app/src/main/res/values-b+id+ID/strings.xml | 1 - app/src/main/res/values-b+it+IT/strings.xml | 1 - app/src/main/res/values-b+ja+JP/strings.xml | 1 - app/src/main/res/values-b+ka+GE/strings.xml | 1 - app/src/main/res/values-b+km+KH/strings.xml | 1 - app/src/main/res/values-b+kmr+TR/strings.xml | 1 - app/src/main/res/values-b+kn+IN/strings.xml | 1 - app/src/main/res/values-b+ko+KR/strings.xml | 1 - app/src/main/res/values-b+ku+TR/strings.xml | 1 - app/src/main/res/values-b+lg+UG/strings.xml | 1 - app/src/main/res/values-b+lt+LT/strings.xml | 1 - app/src/main/res/values-b+lv+LV/strings.xml | 1 - app/src/main/res/values-b+mk+MK/strings.xml | 1 - app/src/main/res/values-b+mn+MN/strings.xml | 1 - app/src/main/res/values-b+ms+MY/strings.xml | 1 - app/src/main/res/values-b+my+MM/strings.xml | 1 - app/src/main/res/values-b+nb+NO/strings.xml | 1 - app/src/main/res/values-b+ne+NP/strings.xml | 1 - app/src/main/res/values-b+nl+NL/strings.xml | 1 - app/src/main/res/values-b+nn+NO/strings.xml | 1 - app/src/main/res/values-b+no+NO/strings.xml | 1 - app/src/main/res/values-b+ny+MW/strings.xml | 1 - app/src/main/res/values-b+pa+IN/strings.xml | 1 - app/src/main/res/values-b+pl+PL/strings.xml | 1 - app/src/main/res/values-b+ps+AF/strings.xml | 1 - app/src/main/res/values-b+pt+BR/strings.xml | 1 - app/src/main/res/values-b+pt+PT/strings.xml | 1 - app/src/main/res/values-b+ro+RO/strings.xml | 1 - app/src/main/res/values-b+ru+RU/strings.xml | 1 - app/src/main/res/values-b+si+LK/strings.xml | 1 - app/src/main/res/values-b+sk+SK/strings.xml | 1 - app/src/main/res/values-b+sl+SI/strings.xml | 1 - app/src/main/res/values-b+sq+AL/strings.xml | 1 - app/src/main/res/values-b+sr+CS/strings.xml | 1 - app/src/main/res/values-b+sr+SP/strings.xml | 1 - app/src/main/res/values-b+sv+SE/strings.xml | 1 - app/src/main/res/values-b+sw+KE/strings.xml | 1 - app/src/main/res/values-b+ta+IN/strings.xml | 1 - app/src/main/res/values-b+te+IN/strings.xml | 1 - app/src/main/res/values-b+th+TH/strings.xml | 1 - app/src/main/res/values-b+tl+PH/strings.xml | 1 - app/src/main/res/values-b+tr+TR/strings.xml | 1 - app/src/main/res/values-b+uk+UA/strings.xml | 1 - app/src/main/res/values-b+ur+IN/strings.xml | 1 - app/src/main/res/values-b+uz+UZ/strings.xml | 1 - app/src/main/res/values-b+vi+VN/strings.xml | 1 - app/src/main/res/values-b+xh+ZA/strings.xml | 1 - app/src/main/res/values-b+zh+CN/strings.xml | 1 - app/src/main/res/values-b+zh+TW/strings.xml | 1 - app/src/main/res/values/strings.xml | 3 ++- 79 files changed, 2 insertions(+), 79 deletions(-) diff --git a/app/src/main/res/values-b+af+ZA/strings.xml b/app/src/main/res/values-b+af+ZA/strings.xml index 4439b9141f..bcd840b976 100644 --- a/app/src/main/res/values-b+af+ZA/strings.xml +++ b/app/src/main/res/values-b+af+ZA/strings.xml @@ -122,7 +122,6 @@ Blokkeer Gebruiker Gebruiker verban Blokkeer - Deblokkeer hierdie kontak om \'n boodskap te stuur. Geen geblokkeerde kontakte {name} Geblokkeer Is jy seker jy wil {name} blokkeer? Geblokkeerde gebruikers kan nie vir jou boodskapversoeke stuur, groepuitnodigings stuur of jou bel nie. diff --git a/app/src/main/res/values-b+ar+SA/strings.xml b/app/src/main/res/values-b+ar+SA/strings.xml index 5c64331100..6de4036c7b 100644 --- a/app/src/main/res/values-b+ar+SA/strings.xml +++ b/app/src/main/res/values-b+ar+SA/strings.xml @@ -143,7 +143,6 @@ منع المستخدم تم منع المستخدم حظر - إلغاء حظر جهة الإتصال لإرسال رسالة لا توجد جهات اتصال محظورة تم حظر {name} هل أنت متأكد من حظر {name}؟ المستخدمين المحظورين لا يمكنهم إرسال طلبات الرسائل، دعوات المجموعات أو الاتصال بك. diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 2de58d6c64..2bb9f7e7f1 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -146,7 +146,6 @@ İstifadəçi yasaqlandı Ban etdiyiniz istifadəçinin Hesab ID-sini daxil edin Əngəllə - Mesaj göndərmək üçün bu kontaktı əngəldən çıxardın. Əngəllənmiş kontakt yoxdur {name} əngəlləndi {name} əngəllənsin? Əngəllənmiş istifadəçilər sizə mesaj tələbi, qrup dəvəti göndərə və ya sizə zəng edə bilməz. diff --git a/app/src/main/res/values-b+bal+BA/strings.xml b/app/src/main/res/values-b+bal+BA/strings.xml index 7cb0c59c81..d5376bcb2a 100644 --- a/app/src/main/res/values-b+bal+BA/strings.xml +++ b/app/src/main/res/values-b+bal+BA/strings.xml @@ -122,7 +122,6 @@ صارف کو پابندی لگائیں صارف پر پابندی عائد کر دی گئی رکاوٹ - پیغام بھیجنے کے لئے اس رابطہ کو غیر بلاک کریں۔ هیچں بندکرتگ امدیدبونه یافت نه بیت {name} کو روکا گیا کیا آپ یقیناً {name} کو بلاک کرنا چاہتے ہیں؟ بلاک شدہ صارفین آپ کو پیغام کی درخواستیں، گروپ دعوتیں نہیں بھیجیں گے یا آپ کو کال نہیں کریں گے۔ diff --git a/app/src/main/res/values-b+be+BY/strings.xml b/app/src/main/res/values-b+be+BY/strings.xml index 9c8b45d71a..a0743de6cc 100644 --- a/app/src/main/res/values-b+be+BY/strings.xml +++ b/app/src/main/res/values-b+be+BY/strings.xml @@ -122,7 +122,6 @@ Забараніць карыстальніка Карыстальнік забаронены Заблакіраваць - Разблакуйце гэты кантакт, каб адправіць паведамленне. Няма заблакіраваных кантактаў Заблакавана {name} Вы ўпэўненыя, што жадаеце заблакіраваць {name}? Заблакіраваныя карыстальнікі не могуць адпраўляць вам запыты на паведамленні, запрашэнні ў групы ці тэлефанаваць вам. diff --git a/app/src/main/res/values-b+bg+BG/strings.xml b/app/src/main/res/values-b+bg+BG/strings.xml index df5c6b9d4c..38fc172a04 100644 --- a/app/src/main/res/values-b+bg+BG/strings.xml +++ b/app/src/main/res/values-b+bg+BG/strings.xml @@ -122,7 +122,6 @@ Забрана на потребител Потребителят е забранен Блокиране - Отблокирай този контакт за да изпратиш съобщение. Няма блокирани контакти Блокиран {name} Сигурен ли/ли сте, че искате да блокирате {name}? Блокираните потребители не могат да ви изпращат заявки за съобщения, покани за група или да ви се обаждат. diff --git a/app/src/main/res/values-b+bn+BD/strings.xml b/app/src/main/res/values-b+bn+BD/strings.xml index fa34fb053e..07a5cd048f 100644 --- a/app/src/main/res/values-b+bn+BD/strings.xml +++ b/app/src/main/res/values-b+bn+BD/strings.xml @@ -122,7 +122,6 @@ ব্যবহারকারীকে ব্যান করুন ইউজার ব্যান করা হয়েছে ব্লক - মেসেজ পাঠাতে এই কন্টাক্টটি আনব্লক করুন। কোনো ব্লক করা যোগাযোগ নেই {name} কে ব্লক করবেন আপনি কি নিশ্চিত যে আপনি {name} কে ব্লক করতে চান? ব্লক করা ব্যবহারকারীরা আপনাকে বার্তা অনুরোধ, গ্রুপ আমন্ত্রণ বা কল করতে পারবেন না। diff --git a/app/src/main/res/values-b+ca+ES/strings.xml b/app/src/main/res/values-b+ca+ES/strings.xml index 4539a17613..e56330dcfa 100644 --- a/app/src/main/res/values-b+ca+ES/strings.xml +++ b/app/src/main/res/values-b+ca+ES/strings.xml @@ -135,7 +135,6 @@ Bloquejar usuari Usuari exclòs Bloqueu - Desbloca aquest contacte per a enviar-li un missatge. No hi ha contactes bloquejats Bloquejat {name} Esteu segur que voleu blocar {name}? Els usuaris bloquejats no us poden enviar sol·licituds de missatges, invitacions de grups ni trucar-vos. diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index ecec8b390b..a02a093e26 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -153,7 +153,6 @@ Uživatel zablokován Zadejte ID účtu uživatele, kterého chcete blokovat Blokovat - Pro odeslání zprávy tento kontakt odblokujte. Žádné blokované kontakty Blokovat {name} Jste si jisti, že chcete zablokovat {name}? Zablokovaní uživatelé vám nemohou posílat žádosti o komunikaci, pozvánky do skupin ani vám nemohou volat. diff --git a/app/src/main/res/values-b+cy+GB/strings.xml b/app/src/main/res/values-b+cy+GB/strings.xml index f48aba8b2c..15e131ed9d 100644 --- a/app/src/main/res/values-b+cy+GB/strings.xml +++ b/app/src/main/res/values-b+cy+GB/strings.xml @@ -122,7 +122,6 @@ Gwahardd Defnyddiwr Defnyddiwr wedi\'i wahardd Rhwystro - Dadrwystro\'r cyswllt hwn i anfon neges. Dim cysylltiadau wedi\'u rhwystro Rhwystro {name} Ydych chi\'n siŵr eich bod am rwystro {name}? Ni all defnyddwyr rhwystredig anfon ceisiadau neges, gwahoddiadau grŵp na’ch galw. diff --git a/app/src/main/res/values-b+da+DK/strings.xml b/app/src/main/res/values-b+da+DK/strings.xml index a2623f9862..302564b2b2 100644 --- a/app/src/main/res/values-b+da+DK/strings.xml +++ b/app/src/main/res/values-b+da+DK/strings.xml @@ -146,7 +146,6 @@ Bruger bandlyst Indtast konto-ID\'et for den bruger, du vil blokere Bloker - Fjern blokering af denne kontakt for at sende en besked. Ingen blokerede kontakter Blokeret {name} Er du sikker på, at du vil blokere {name}? Blokerede brugere kan ikke sende dig beskedanmodninger, gruppeinvitationer eller ringe til dig. diff --git a/app/src/main/res/values-b+de+DE/strings.xml b/app/src/main/res/values-b+de+DE/strings.xml index 437f1f2c3d..07487e139d 100644 --- a/app/src/main/res/values-b+de+DE/strings.xml +++ b/app/src/main/res/values-b+de+DE/strings.xml @@ -142,7 +142,6 @@ Mitglied blockieren Mitglied gesperrt Blockieren - Gib die Blockierung dieses Kontakts frei, um eine Nachricht zu senden. Keine blockierten Kontakte Blockiert {name} Möchtest du {name} blockieren? Blockierte Personen können dir keine Nachrichtenanfragen, Gruppeneinladungen oder Anrufe senden. diff --git a/app/src/main/res/values-b+el+GR/strings.xml b/app/src/main/res/values-b+el+GR/strings.xml index 66d55ab067..d142947e46 100644 --- a/app/src/main/res/values-b+el+GR/strings.xml +++ b/app/src/main/res/values-b+el+GR/strings.xml @@ -122,7 +122,6 @@ Αποκλεισμός Χρήστη Ο χρήστης αποκλείστηκε Φραγή - Καταργήστε τη φραγή αυτής τη επαφής για να στείλετε ένα μήνυμα. Καμία μπλοκαρισμένη επαφή Σε φραγή {name} Είστε σίγουροι ότι θέλετε να θέσετε σε φραγή {name}; Οι μπλοκαρισμένοι χρήστες δεν μπορούν να σας στείλουν αιτήματα μηνύματα, προσκλήσεις ομάδας ή να σας καλέσουν. diff --git a/app/src/main/res/values-b+eo+UY/strings.xml b/app/src/main/res/values-b+eo+UY/strings.xml index e0ff387876..d66ffff4ed 100644 --- a/app/src/main/res/values-b+eo+UY/strings.xml +++ b/app/src/main/res/values-b+eo+UY/strings.xml @@ -122,7 +122,6 @@ Forbari uzanton Uzanto forigita Bloki - Malbloki tiun kontakton por sendi mesaĝon. Neniu blokata kontakto Blokis {name} Ĉu vi certas, ke vi volas bloki {name}? Blokitaj uzantoj ne povas sendi al vi mesaĝpetojn, grupinvitadojn aŭ telefonvokojn. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 2c38bae018..9cc5b12ec7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -128,7 +128,6 @@ Bloquear usuario Usuario reportado Bloquear - Desbloquea este contacto para enviarle mensajes. No hay contactos bloqueados Bloqueado {name} ¿Estás seguro de que quieres bloquear a {name}? Los usuarios bloqueados no pueden enviarte solicitudes de mensajes, invitaciones a grupos ni llamarte. diff --git a/app/src/main/res/values-b+es+ES/strings.xml b/app/src/main/res/values-b+es+ES/strings.xml index 2b7b0053b0..033734c65e 100644 --- a/app/src/main/res/values-b+es+ES/strings.xml +++ b/app/src/main/res/values-b+es+ES/strings.xml @@ -128,7 +128,6 @@ Banear usuario Usuario expulsado Bloquear - Desbloquea este contacto para enviar mensajes. No hay contactos bloqueados Bloqueado {name} ¿Estás seguro de que quieres bloquear a {name}? Los usuarios bloqueados no pueden enviarte solicitudes de mensajes, invitaciones a grupos ni llamarte. diff --git a/app/src/main/res/values-b+et+EE/strings.xml b/app/src/main/res/values-b+et+EE/strings.xml index cd4623a9f8..7a8b28ab8a 100644 --- a/app/src/main/res/values-b+et+EE/strings.xml +++ b/app/src/main/res/values-b+et+EE/strings.xml @@ -122,7 +122,6 @@ Blokeeri kasutaja Kasutaja blokeerimine Blokeeri - Sõnumi saatmiseks eemalda selle kontakti blokeering. Blokeeritud kontakte pole Blokeeritud {name} Kas olete kindel, et soovite blokeerida {name}? Blokeeritud kasutajad ei saa teile saata sõnumitaotlusi, grupikutseid ega helistada. diff --git a/app/src/main/res/values-b+eu+ES/strings.xml b/app/src/main/res/values-b+eu+ES/strings.xml index 2b342117cf..9d963b45fb 100644 --- a/app/src/main/res/values-b+eu+ES/strings.xml +++ b/app/src/main/res/values-b+eu+ES/strings.xml @@ -122,7 +122,6 @@ Ban User Erabiltzailea debekatu da Block - Kontaktu hau desblokeatu mezu bat bidaltzeko. Ez dago blokeatutako kontakturik Blocked {name} Ziur zaude {name} blokeatu nahi duzula? Erabiltzaile blokeatuek ezin dizkizute mezu-eskaerak, talde-gonbidapenak bidali edo deiak egin. diff --git a/app/src/main/res/values-b+fa+IR/strings.xml b/app/src/main/res/values-b+fa+IR/strings.xml index 6c8b3cb03c..08f23cf39a 100644 --- a/app/src/main/res/values-b+fa+IR/strings.xml +++ b/app/src/main/res/values-b+fa+IR/strings.xml @@ -122,7 +122,6 @@ مسدود کردن کاربر کاربر ممنوع شد مسدود کردن - برای ارسال پیام،‌ ابتدا این مخاطب را از مسدود بودن درآورید! هیج مخاطبی مسدود نشده مسدود شد {name} آیا مطمئنید می‌خواهید {name} را مسدود کنید؟ کاربران مسدود شده نمی‌توانند درخواست پیام، دعوت گروهی یا تماس ارسال کنند. diff --git a/app/src/main/res/values-b+fi+FI/strings.xml b/app/src/main/res/values-b+fi+FI/strings.xml index ea0b513a89..a09403fce2 100644 --- a/app/src/main/res/values-b+fi+FI/strings.xml +++ b/app/src/main/res/values-b+fi+FI/strings.xml @@ -122,7 +122,6 @@ Estä käyttäjä Käyttäjä estettiin Estä - Lähettääksesi viestin tälle yhteystiedolle sinun tulee ensin poistaa asettamasi esto. Ei estettyjä yhteystietoja Estetty {name} Haluatko varmasti estää käyttäjän {name}? Estetyt käyttäjät eivät voi lähettää sinulle viestipyyntöjä, ryhmäkutsuja tai soittaa sinulle. diff --git a/app/src/main/res/values-b+fil+PH/strings.xml b/app/src/main/res/values-b+fil+PH/strings.xml index d60ffd584a..b555a3ecdf 100644 --- a/app/src/main/res/values-b+fil+PH/strings.xml +++ b/app/src/main/res/values-b+fil+PH/strings.xml @@ -122,7 +122,6 @@ Ipagbawal ang taong ito User banned Harangin - I-unblock ang contact na ito para magpadala ng mensahe. Walang naka-block na contact Na-block na si {name} Sigurado ka bang gusto mong harangin si {name}? Ang mga hinarang na user ay hindi makakapagpadala ng mga kahilingan sa pagmemensahe, mga imbitasyon sa grupo o tira-tawag sa\'yo. diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index cfa7381c1c..9296265708 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -151,7 +151,6 @@ Utilisateur banni Entrez l\'identifiant du compte de l\'utilisateur que vous souhaitez débannir Bloquer - Débloquez ce contact pour envoyer un message. Aucun contact n’est bloqué Bloqué {name} Êtes-vous sûr·e de vouloir bloquer {name}? Les utilisateurs bloqués ne peuvent pas vous envoyer de demandes de message, d\'invitations de groupe ou vous appeler. diff --git a/app/src/main/res/values-b+gl+ES/strings.xml b/app/src/main/res/values-b+gl+ES/strings.xml index 34c40d647b..e70a1cf96f 100644 --- a/app/src/main/res/values-b+gl+ES/strings.xml +++ b/app/src/main/res/values-b+gl+ES/strings.xml @@ -115,7 +115,6 @@ Bloquear usuario Usuario bloqueado Bloquear - Desbloquea este contacto para enviar unha mensaxe. Ningún contacto bloqueado Bloqueouse a {name} Tes a certeza de querer bloquear a {name}? Os usuarios bloqueados non poderán enviar solicitudes de mensaxes, invitacións a grupos nin chamarte. diff --git a/app/src/main/res/values-b+ha+HG/strings.xml b/app/src/main/res/values-b+ha+HG/strings.xml index 678cb46fce..6b7812a3a9 100644 --- a/app/src/main/res/values-b+ha+HG/strings.xml +++ b/app/src/main/res/values-b+ha+HG/strings.xml @@ -122,7 +122,6 @@ Hana Mai Amfani Mai amfani ya kulle To\'she - Cire katanga wannan saduwa don aika saƙo. Babu an toshe lambobin sadarwa {name} an toshe Kana tabbata kana so ka toshe {name}?? Ba za su iya aiko maka da roƙon saƙonni, gayyatar rukuni ko kira ba idan an toshe su. diff --git a/app/src/main/res/values-b+he+IL/strings.xml b/app/src/main/res/values-b+he+IL/strings.xml index 9e3e6ed506..ee215f007f 100644 --- a/app/src/main/res/values-b+he+IL/strings.xml +++ b/app/src/main/res/values-b+he+IL/strings.xml @@ -122,7 +122,6 @@ חסום משתמש משתמש נחסם חסום - בטל חסימה של איש קשר זה כדי לשלוח הודעה. אין אנשי קשר חסומים נחסם {name} האם אתה בטוח שברצונך לחסום את {name}? משתמשים חסומים אינם יכולים לשלוח לך בקשות הודעות, הזמנות לקבוצות או להתקשר אליך. diff --git a/app/src/main/res/values-b+hi+IN/strings.xml b/app/src/main/res/values-b+hi+IN/strings.xml index 7be23901c9..6c53edd2b8 100644 --- a/app/src/main/res/values-b+hi+IN/strings.xml +++ b/app/src/main/res/values-b+hi+IN/strings.xml @@ -130,7 +130,6 @@ प्रतिबंध उपयोगकर्ता उपयोगकर्ता प्रतिबंधित खंड - कोई संदेश भेजने के लिए इस संपर्क को अनवरोधित करें कोई अवरुद्ध संपर्क नहीं अवरोधित {name} क्या आप वाकई {name} को ब्लॉक करना चाहते हैं? अवरोधित उपयोगकर्ता आपको संदेश अनुरोध नहीं भेज सकते, समूह निमंत्रण या कॉल नहीं कर सकते। diff --git a/app/src/main/res/values-b+hr+HR/strings.xml b/app/src/main/res/values-b+hr+HR/strings.xml index b3d905d860..c425278923 100644 --- a/app/src/main/res/values-b+hr+HR/strings.xml +++ b/app/src/main/res/values-b+hr+HR/strings.xml @@ -122,7 +122,6 @@ Zabrani korisnik Korisnik zabranjen Blokiraj - Deblokiraj ovaj kontakt za slanje poruke. Nema blokiranih kontakata Blokiran {name} Jeste li sigurni da želite blokirati {name}? Blokirani korisnici vam ne mogu poslati zahtjeve za poruke, pozive ili pozive u grupu. diff --git a/app/src/main/res/values-b+hu+HU/strings.xml b/app/src/main/res/values-b+hu+HU/strings.xml index 6cef227755..7864372910 100644 --- a/app/src/main/res/values-b+hu+HU/strings.xml +++ b/app/src/main/res/values-b+hu+HU/strings.xml @@ -122,7 +122,6 @@ Felhasználó kitiltása Felhasználó kitiltva Letiltás - Üzenet küldéséhez oldd fel a kontakt letiltását. Nincsenek blokkolt kontaktok {name} letiltva Biztos, hogy blokkolni szeretnéd {name}-t? A blokkolt felhasználók nem küldhetnek üzenetkérelmeket, csoportmeghívókat, és nem hívhatnak fel. diff --git a/app/src/main/res/values-b+hy+AM/strings.xml b/app/src/main/res/values-b+hy+AM/strings.xml index 52b0c8ebc3..da713db740 100644 --- a/app/src/main/res/values-b+hy+AM/strings.xml +++ b/app/src/main/res/values-b+hy+AM/strings.xml @@ -122,7 +122,6 @@ Արգելել օգտատիրոջը Օգտատեր արգելափակվել է Արգելափակել - Արգելաբացել այս կոնտակտը հաղորդագրություն ուղարկելու համար։ Արգելափակված կոնտակտներ չկան Արգելափակվել է {name}֊ը Վստա՞հ եք, որ ցանկանում եք արգելափակել {name}? Արգելափակված օգտատերերը չեն կարող հաղորդագրության հարցումներ, խմբի հրավերներ կամ զանգեր ուղարկել ձեզ։ diff --git a/app/src/main/res/values-b+id+ID/strings.xml b/app/src/main/res/values-b+id+ID/strings.xml index fb7044f7fc..315de5e346 100644 --- a/app/src/main/res/values-b+id+ID/strings.xml +++ b/app/src/main/res/values-b+id+ID/strings.xml @@ -128,7 +128,6 @@ Larang pengguna Pengguna diblokir Blokir - Lepaskan blokir kontak ini untuk mengirim pesan. Tidak ada kontak yang diblokir Blokir {name} Apakah Anda yakin ingin memblokir {name}? Pengguna yang diblokir tidak bisa mengirimkan Anda permintaan pesan, undangan grup, atau menelepon Anda. diff --git a/app/src/main/res/values-b+it+IT/strings.xml b/app/src/main/res/values-b+it+IT/strings.xml index 06d2de4690..b3e79a45e8 100644 --- a/app/src/main/res/values-b+it+IT/strings.xml +++ b/app/src/main/res/values-b+it+IT/strings.xml @@ -128,7 +128,6 @@ Rimuovi utente Utente bloccato Blocca - Per inviare un messaggio sblocca questo contatto. Nessun contatto bloccato {name} bloccato Confermi di voler bloccare {name}? Gli utenti bloccati non possono inviarti richieste di messaggi, inviti ai gruppi o chiamarti. diff --git a/app/src/main/res/values-b+ja+JP/strings.xml b/app/src/main/res/values-b+ja+JP/strings.xml index bf935ded1a..10476aa420 100644 --- a/app/src/main/res/values-b+ja+JP/strings.xml +++ b/app/src/main/res/values-b+ja+JP/strings.xml @@ -122,7 +122,6 @@ ユーザーを禁止する ユーザーが禁止されました ブロック - この連絡先にメッセージを送るためにブロックを解除する。 ブロックしている連絡先はありません {name} をブロックしました 本当に{name}をブロックしますか?ブロックされたユーザーは、メッセージリクエスト、グループ招待や通話を送ることができません。 diff --git a/app/src/main/res/values-b+ka+GE/strings.xml b/app/src/main/res/values-b+ka+GE/strings.xml index 683f4aeb67..497fb5c2ab 100644 --- a/app/src/main/res/values-b+ka+GE/strings.xml +++ b/app/src/main/res/values-b+ka+GE/strings.xml @@ -122,7 +122,6 @@ მომხმარებლის დაბლოკვა მომხმარებელი დაბლოკილია დაბლოკვა - შეტყობინების გაგზავნისთვის ბლოკი მოხსენით. არ არის დაბლოკილი კონტაქტები დაბლოკილი {name} დარწმუნებული ხართ, რომ გსურთ დაბლოკოთ {name}? დაბლოკილი მომხმარებლები ვერ გამოგიგზავნიან შეტყობინების მოთხოვნებს, ჯგუფის მოწვევებს ან ვერ დაგირეკავენ. diff --git a/app/src/main/res/values-b+km+KH/strings.xml b/app/src/main/res/values-b+km+KH/strings.xml index 34eaafde74..e5c1b101fc 100644 --- a/app/src/main/res/values-b+km+KH/strings.xml +++ b/app/src/main/res/values-b+km+KH/strings.xml @@ -122,7 +122,6 @@ ហាមឃាត់អ្នកប្រើ បានហាមឃាត់អ្នកប្រើ ទប់ស្កាត់ - ដោះការហាមឃាត់លេខទំនាក់ទំនងនេះ ដើម្បីផ្ញើសារ។ ពុំមានលេខដែលបានបិទ បានទប់ស្កាត់ {name} តើអ្នកប្រាកដទេថាអ្នកចង់រារាំង {name}? អ្នកប្រើប្រាស់ដែលត្រូវបានរារាំងមិនអាចផ្ញើសំណើសារ ការអញ្ជើញក្រុម ឬហៅអ្នកបានទេ។ diff --git a/app/src/main/res/values-b+kmr+TR/strings.xml b/app/src/main/res/values-b+kmr+TR/strings.xml index 2c36a91730..2bf192a060 100644 --- a/app/src/main/res/values-b+kmr+TR/strings.xml +++ b/app/src/main/res/values-b+kmr+TR/strings.xml @@ -126,7 +126,6 @@ Bikarhênerê Asteng bike Bikarhênker astengî bû Blok bike - Ji bo şandina peyamê vê bloka vî kontaktê rake. Kontaktên astengkirî nehatin dîtin {name} asteng kir Tu piştrast î ku tu dixwazî {name} blok bikî? Bikarhênerên blokkirî nikarin daxwazên peyamê bişînin, dawetên li grûban bişînin an telefona te bikin. diff --git a/app/src/main/res/values-b+kn+IN/strings.xml b/app/src/main/res/values-b+kn+IN/strings.xml index e7fcd9d109..2b654468f9 100644 --- a/app/src/main/res/values-b+kn+IN/strings.xml +++ b/app/src/main/res/values-b+kn+IN/strings.xml @@ -122,7 +122,6 @@ ಬಳಕೆದಾರರನ್ನು ವಿಲಂಬಿಸಿ ಬಳಕೆದಾರನನ್ನು ಬ್ಯಾನ್ ಮಾಡಲಾಗಿದೆ ತಡೆಯುವ ಸಹಿತ - ಸಂದೇಶವೊಂದನ್ನು ಕಳುಹಿಸಲು ಈ ಸಂಪರ್ಕವನ್ನು ಬ್ಲಾಕ್ ಮಾಡಿ. ತಡೆಗಟ್ಟಿದ ಸಂಪರ್ಕಗಳು ಎಂಥಹುದೂ ಇಲ್ಲ {name} ತಡೆ ಮಾಡಲಾಗಿದೆ ನೀವು ಖಚಿತವಾಗಿ {name}ನ್ನು ತಡೆಯಲು ಬಯಸುವಿರಾ? ತಡೆಮಾಡಿದ ಬಳಕೆದಾರರು ನಿಮಗೆ ಸಂದೇಶ ವಿನಂತಿಗಳನ್ನು, ಗುಂಪು ಆಹ್ವಾನವನ್ನು ಕಳುಹಿಸಲು ಅಥವಾ ನಿಮಗೆ ಕರೆಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. diff --git a/app/src/main/res/values-b+ko+KR/strings.xml b/app/src/main/res/values-b+ko+KR/strings.xml index 05c2c3ac5a..82455d002e 100644 --- a/app/src/main/res/values-b+ko+KR/strings.xml +++ b/app/src/main/res/values-b+ko+KR/strings.xml @@ -127,7 +127,6 @@ 사용자 차단 사용자 금지됨 차단 - 보내기 차단 해제하기 차단된 연락처가 없습니다. {name} 차단됨 Are you sure you want to block {name}? Blocked users cannot send you message requests, group invites or call you. diff --git a/app/src/main/res/values-b+ku+TR/strings.xml b/app/src/main/res/values-b+ku+TR/strings.xml index 8647ce8417..458cb66575 100644 --- a/app/src/main/res/values-b+ku+TR/strings.xml +++ b/app/src/main/res/values-b+ku+TR/strings.xml @@ -122,7 +122,6 @@ دەسەڵاتدانە کەسی بەکارهێنەر بەرچاوکرا دووری دەخەیتەوە‬ - ئەم پەیوەندە لابردن بۆ بریتیە لە ناردنی پەیامێک. هیچ پەیوەندیکەرێکی بڵاوکراو نییە {name} دوورکرایەوە دڵنیایت لەوەی ناو بەچەندەی {name} ? بەچەندەکانی بەچەرمی نەتوانێ بمەMarker دووبارە. diff --git a/app/src/main/res/values-b+lg+UG/strings.xml b/app/src/main/res/values-b+lg+UG/strings.xml index 1cabf35a15..0c819ec834 100644 --- a/app/src/main/res/values-b+lg+UG/strings.xml +++ b/app/src/main/res/values-b+lg+UG/strings.xml @@ -122,7 +122,6 @@ Ban User Omukozesa asazibwaamu Block - Sazaamu omukozesa kuno okusindika obubaka. Tolina mikutu Blocked {name} Oli mbanankubye {name} ? Abanankubize tebayinza kusindikidde obubaka, obubaka bw\'ekibinja oba okubakubira simu. diff --git a/app/src/main/res/values-b+lt+LT/strings.xml b/app/src/main/res/values-b+lt+LT/strings.xml index 0bc98eca81..a7106581e2 100644 --- a/app/src/main/res/values-b+lt+LT/strings.xml +++ b/app/src/main/res/values-b+lt+LT/strings.xml @@ -122,7 +122,6 @@ Drausti naudotoją Vartotojas užblokuotas Užblokuoti - Atblokuokite šį kontaktą, kad išsiųstumėte žinutę. Nėra užblokuotų adresatų Užblokuotas {name} Ar tikrai norite užblokuoti {name}? Užblokuoti naudotojai negali siųsti žinučių užklausų, grupių kvietimų ar skambinti jums. diff --git a/app/src/main/res/values-b+lv+LV/strings.xml b/app/src/main/res/values-b+lv+LV/strings.xml index 07dcabfc7f..2d068b6174 100644 --- a/app/src/main/res/values-b+lv+LV/strings.xml +++ b/app/src/main/res/values-b+lv+LV/strings.xml @@ -116,7 +116,6 @@ Aizliegt lietotāju Lietotājs bloķēts Bloķēt - Atbloķējiet šo kontaktu, lai nosūtītu ziņojumu. Nav bloķētu kontaktu Bloķēts {name} Vai esat pārliecināts, ka vēlaties bloķēt {name}? Bloķētie lietotāji nevarēs jums nosūtīt ziņojumu pieprasījumus, grupu uzaicinājumus vai zvanīt jums. diff --git a/app/src/main/res/values-b+mk+MK/strings.xml b/app/src/main/res/values-b+mk+MK/strings.xml index c4fec2122f..4efc81527f 100644 --- a/app/src/main/res/values-b+mk+MK/strings.xml +++ b/app/src/main/res/values-b+mk+MK/strings.xml @@ -122,7 +122,6 @@ Забрани корисник Корисникот е забранет Блокирај - Одблокирај го овој контакт за да испратиш порака. Нема блокирани контакти Корисникот {name} е блокиран Дали сте сигурни дека сакате да го блокирате {name}? Блокираните корисници не можат да ви испраќаат барања за пораки, покани за групи или да ве повикуваат. diff --git a/app/src/main/res/values-b+mn+MN/strings.xml b/app/src/main/res/values-b+mn+MN/strings.xml index f39f50bd42..a3bfd3d40f 100644 --- a/app/src/main/res/values-b+mn+MN/strings.xml +++ b/app/src/main/res/values-b+mn+MN/strings.xml @@ -122,7 +122,6 @@ Хэрэглэгчийг хориглох Хэрэглэгчийг хөндийлсөн Хаах - Түгжээг арилгаж, мессеж илгээх боломжтой болно. Хаасан контакт байхгүй {name} -г хориглосон Та {name}-ийг хаахыг хүсэж байгаадаа итгэлтэй байна уу? Хаагдсан хэрэглэгчид танд мессеж хүсэлт илгээж, бүлгийн урилга илгээж эсвэл залгах боломжгүй. diff --git a/app/src/main/res/values-b+ms+MY/strings.xml b/app/src/main/res/values-b+ms+MY/strings.xml index 239fec4aec..42a6f12516 100644 --- a/app/src/main/res/values-b+ms+MY/strings.xml +++ b/app/src/main/res/values-b+ms+MY/strings.xml @@ -122,7 +122,6 @@ Sekat Pengguna Pengguna diharamkan Sekat - Nyahsekat kontak ini untuk menghantar mesej. Tiada kenalan yang disekat {name} disekat Adakah anda pasti mahu menyekat {name}? Pengguna yang disekat tidak boleh menghantar permintaan mesej, jemputan kumpulan atau menghubungi anda. diff --git a/app/src/main/res/values-b+my+MM/strings.xml b/app/src/main/res/values-b+my+MM/strings.xml index df3a49934a..a6fa0f7932 100644 --- a/app/src/main/res/values-b+my+MM/strings.xml +++ b/app/src/main/res/values-b+my+MM/strings.xml @@ -122,7 +122,6 @@ သုံးစွဲသူကို ပိတ်ဆို့မည် အသုံးပြုသူကို ပိတ်မည် ဘလော့ပါ - မက်ဆေ့ချ် ပို့ရန်အတွက်ဤဆက်သွယ်မှုသို့ ဘလော့ကိုဖြေပါ။ ဘလော့လုပ်ထားသော ဆက်သွယ်မှုမရှိ {name} မှ ဘလော့ ခံထားသည် သင် {name} ကို ဘလော့ခ်ချင်တာ သေချာရဲ ့လား? ဘလော့ခ်ထားတဲ့ လူတွေက မက်ဆေ့ချ်လာပို့ခွင့်၊အဖွဲ့ခေါ်ခံရခွင့် နဲ့ဖုန်းခေါ်ရခွင့် ရနိုင်မှာမဟုတ်ပါဘူး။ diff --git a/app/src/main/res/values-b+nb+NO/strings.xml b/app/src/main/res/values-b+nb+NO/strings.xml index cb7a6f0e74..224028521b 100644 --- a/app/src/main/res/values-b+nb+NO/strings.xml +++ b/app/src/main/res/values-b+nb+NO/strings.xml @@ -122,7 +122,6 @@ Utesteng bruker Bruker utestengt Blokker - Avblokker denne kontakten for å sende en beskjed. Ingen blokkerte kontakter Blokkerte {name} Er du sikker på at du vil blokkere {name} ? Blokkerte brukere kan ikke sende deg meldingsforespørsler, gruppeinvitasjoner eller ringe deg. diff --git a/app/src/main/res/values-b+ne+NP/strings.xml b/app/src/main/res/values-b+ne+NP/strings.xml index 00bc7856f2..f554b02edb 100644 --- a/app/src/main/res/values-b+ne+NP/strings.xml +++ b/app/src/main/res/values-b+ne+NP/strings.xml @@ -122,7 +122,6 @@ प्रयोगकर्ता प्रतिबन्ध गर्नुहोस् प्रयोगकर्ता प्रतिबन्धित ब्लक गर्नुहोस् - सन्देश पठाउन यो सम्पर्क अनब्लक गर्नुहोस्। कुनै ब्लक गरिएका सम्पर्कहरू छैनन् {name} ब्लक गरिएको छ के तपाईंलाई {name} بادुकाउनुभो? Block गरिएका प्रयोगकर्ताहरूले तपाईलाई सन्देश अनुरोधहरू, समूह आमन्त्रण वा कल दिन सक्दैनन्।. diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 216d106aaa..46128b833d 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -142,7 +142,6 @@ Gebruiker verbannen Gebruiker verbannen Blokkeren - Deblokkeer dit contact om een bericht te verzenden. Geen geblokkeerde contactpersonen {name} geblokkeerd Weet u zeker dat u {name} wilt blokkeren? Geblokkeerde gebruikers kunnen u geen berichtverzoeken, groepsuitnodiging sturen of bellen. diff --git a/app/src/main/res/values-b+nn+NO/strings.xml b/app/src/main/res/values-b+nn+NO/strings.xml index 3a218d56df..d9b91ecc20 100644 --- a/app/src/main/res/values-b+nn+NO/strings.xml +++ b/app/src/main/res/values-b+nn+NO/strings.xml @@ -122,7 +122,6 @@ Bannlys brukar Bruker utestengt Blokker - Opphev blokkeringen på denne kontakten for å sende en melding. Ingen blokkerte kontakter Blokkert {name} Er du sikker på at du vil blokkera {name}? Blokkerte brukere kan ikkje senda deg meldingsforespørsler, gruppeinvitasjonar eller ringa deg. diff --git a/app/src/main/res/values-b+no+NO/strings.xml b/app/src/main/res/values-b+no+NO/strings.xml index 1cca49667e..fa1faefe10 100644 --- a/app/src/main/res/values-b+no+NO/strings.xml +++ b/app/src/main/res/values-b+no+NO/strings.xml @@ -122,7 +122,6 @@ Bannlys bruker Bruker utestengt Blokker - Opphev blokkeringen på denne kontakten for å sende en melding. Ingen blokkerte kontakter Blokkert {name} Er du sikker på at du vil blokkere {name}? Blokkerte brukere kan ikke sende deg meldingsforespørsler, gruppeinvitasjoner eller ringe deg. diff --git a/app/src/main/res/values-b+ny+MW/strings.xml b/app/src/main/res/values-b+ny+MW/strings.xml index a6f4a59a28..3b3497fcf0 100644 --- a/app/src/main/res/values-b+ny+MW/strings.xml +++ b/app/src/main/res/values-b+ny+MW/strings.xml @@ -122,7 +122,6 @@ Ban User Munthu wotsalira atachotsedwa Block - Pokankha Lamulo Llitsa lemba uthenga. Palibe Zilumikizana Zotsekedwa Blocked {name} Mukutsimikiza kuti mukufuna kuletsa {name}?? Ogwiritsa omwe aletsedwa sangathe kukutumizirani mauthenga ofunira, kukupemphani mu mgulu kapena kukuyimbirani. diff --git a/app/src/main/res/values-b+pa+IN/strings.xml b/app/src/main/res/values-b+pa+IN/strings.xml index 94efeee51c..de001ddcea 100644 --- a/app/src/main/res/values-b+pa+IN/strings.xml +++ b/app/src/main/res/values-b+pa+IN/strings.xml @@ -122,7 +122,6 @@ ਉਪਭੋਗਤਾ ਨੂੰ ਬੈਨ ਕਰੋ ਉਪਭੋਗਤਾ ਨੂੰ ਬੈਨ ਕੀਤਾ ਗਿਆ ਹੈ ਬਲੌਕ - ਸੁਨੇਹਾ ਭੇਜਣ ਲਈ ਇਸ ਸੰਪਰਕ ਨੂੰ ਅਨਬਲੌਕ ਕਰੋ। ਕੋਈ ਬਲਾਕ ਕੀਤੇ ਹੋਏ ਸੰਪਰਕ ਨਹੀਂ {name} ਨੂੰ ਬਲੌਕ ਕੀਤਾ ਗਿਆ ਕੀ ਤੁਸੀਂ ਯਕੀਨਨ {name} ਨੂੰ ਰੋਕਣਾ ਚਾਹੁੰਦੇ ਹੋ? ਰੋਕੇ ਹੋਏ ਯੂਜ਼ਰ ਤੁਹਾਨੂੰ ਸੁਨੇਹੇ ਦੀਆਂ ਬੇਨਤੀਆਂ ਨਹੀਂ ਭੇਜ ਸਕਦੇ, ਗਰੁੱਪ ਨਿਮੰਤ੍ਰਣ ਨਹੀਂ ਦੇ ਸਕਦੇ ਜਾਂ ਕਾਲ ਨਹੀਂ ਕਰ ਸਕਦੇ। diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index 3d929ca41b..a6e90683da 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -148,7 +148,6 @@ Zablokowano użytkownikowi dostęp Wprowadź identyfikator konta użytkownika, którego chcesz zablokować Zablokuj - Odblokuj ten kontakt, aby wysłać wiadomość. Brak zablokowanych kontaktów Zablokowano {name} Czy na pewno chcesz zablokować {name}? Zablokowani użytkownicy nie mogą wysyłać próśb o wiadomości, zaproszeń do grupy ani dzwonić. diff --git a/app/src/main/res/values-b+ps+AF/strings.xml b/app/src/main/res/values-b+ps+AF/strings.xml index ad1d9c372a..d6f1037644 100644 --- a/app/src/main/res/values-b+ps+AF/strings.xml +++ b/app/src/main/res/values-b+ps+AF/strings.xml @@ -122,7 +122,6 @@ کاروونکی بند کړئ کارن بندیز شو بلاک - د پیغام استولو لپاره له دې اړیکې بې بندیز وکړئ. هیڅ بند شوي اړیکې نشته {name} بلاک کړ آیا تاسې ډاډه یاست چې غواړئ {name}‌ بلاک کړئ؟ بلاک شوي کاروونکي تاسو ته د پیغام غوښتنې، د ډلې بلنې یا زنګ وهلو وړتیا نه لري. diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 5d3b1d65c8..dbd62011a7 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -122,7 +122,6 @@ Banir Usuário Usuário banido Bloquear - Desbloquear este contato para enviar uma mensagem. Nenhum contato bloqueado Bloqueado {name} Tem certeza que deseja bloquear {name}? Usuários bloqueados não podem enviar mensagens, pedidos de grupo para você ou ligar para você. diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index 991e85b451..6714293894 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -122,7 +122,6 @@ Banir Utilizador Utilizador banido Bloquear - Desbloqueie este contacto para enviar uma mensagem. Sem contactos bloqueados Bloqueado {name} Tem a certeza que quer bloquear {name}? Utilizadores bloqueados não podem enviar pedidos de mensagem, convites de grupo ou ligar para si. diff --git a/app/src/main/res/values-b+ro+RO/strings.xml b/app/src/main/res/values-b+ro+RO/strings.xml index d5b804c7b5..6af1b91c3e 100644 --- a/app/src/main/res/values-b+ro+RO/strings.xml +++ b/app/src/main/res/values-b+ro+RO/strings.xml @@ -122,7 +122,6 @@ Interzice utilizatorul Utilizator interzis Blochează - Deblochează acest contact pentru a putea trimite mesaje. Nu aveți contacte blocate Blocat {name} Ești sigur/ă că vrei să blochezi pe {name}? Utilizatorii blocați nu îți pot trimite solicitări de mesaje, invitații de grup sau să te apeleze. diff --git a/app/src/main/res/values-b+ru+RU/strings.xml b/app/src/main/res/values-b+ru+RU/strings.xml index 5ce40716ce..57caaf89bf 100644 --- a/app/src/main/res/values-b+ru+RU/strings.xml +++ b/app/src/main/res/values-b+ru+RU/strings.xml @@ -144,7 +144,6 @@ Забанить пользователя Пользователь забанен Блокировка - Разблокируйте этот контакт, чтобы отправить сообщение. Нет заблокированных контактов Заблокирован {name} Вы уверены, что хотите заблокировать {name}? Заблокированные пользователи не смогут отправлять вам запросы сообщений, приглашения в группы, а также звонить вам. diff --git a/app/src/main/res/values-b+si+LK/strings.xml b/app/src/main/res/values-b+si+LK/strings.xml index af14c312d0..0878e1b6cc 100644 --- a/app/src/main/res/values-b+si+LK/strings.xml +++ b/app/src/main/res/values-b+si+LK/strings.xml @@ -122,7 +122,6 @@ පරිශීලක තහනම් කරන්න පරිශීලක තහනම් කර ඇත. අවහිර - පණිවිඩය යැවීමට මෙම සබඳතාවය අනවහිර කරන්න. අවහිර කළ සබඳතා නැත අවහිර කළා {name} ඔබට {name} ඔවුන් අවහිර කිරීමට අවශ්‍ය බව විශ්වාසද? අවහිර කළ පරිශීලකයින්ට ඔබට පණිවිඩ ඉල්ලීම්, සමූහ ආරාධනා හෝ ඇමතුම් යැවිය නොහැක. diff --git a/app/src/main/res/values-b+sk+SK/strings.xml b/app/src/main/res/values-b+sk+SK/strings.xml index 6fedbe7c7e..bb3c4c032b 100644 --- a/app/src/main/res/values-b+sk+SK/strings.xml +++ b/app/src/main/res/values-b+sk+SK/strings.xml @@ -122,7 +122,6 @@ Zakázať používateľa Používateľ zakázaný Blokovať - Pre odoslanie správy kontakt odblokujte. Žiadne zablokované kontakty Zablokovaný {name} Ste si istý, že chcete zablokovať {name}? Zablokovaní používatelia vám nemôžu posielať žiadosti o správy, pozvánky do skupín alebo vám volať. diff --git a/app/src/main/res/values-b+sl+SI/strings.xml b/app/src/main/res/values-b+sl+SI/strings.xml index dcaaf8c899..d0c3c5ca4c 100644 --- a/app/src/main/res/values-b+sl+SI/strings.xml +++ b/app/src/main/res/values-b+sl+SI/strings.xml @@ -122,7 +122,6 @@ Onemogoči uporabnika Uporabnik je blokiran Blokiraj - Za pošiljanje sporočila morate najprej odblokirati ta stik. Ni blokiranih stikov Blokiram {name} Ali ste prepričani, da želite blokirati {name}? Blokirani uporabniki vam ne morejo poslati zahtev za sporočila, povabil v skupino ali vas poklicati. diff --git a/app/src/main/res/values-b+sq+AL/strings.xml b/app/src/main/res/values-b+sq+AL/strings.xml index f1afac9987..faa21aabdd 100644 --- a/app/src/main/res/values-b+sq+AL/strings.xml +++ b/app/src/main/res/values-b+sq+AL/strings.xml @@ -122,7 +122,6 @@ Dëboni përdorues Përdoruesi u dëbua Bllokoni - Që t’i dërgohet një mesazh, zhbllokojeni këtë kontakt. S’ka kontakte të bllokuara Të bllokohet {name} A jeni të sigurt që doni ta bllokoni {name}? Përdoruesit e bllokuar nuk mund t\'ju dërgojnë kërkesa për mesazhe, ftesa grupi apo t’ju telefonojnë. diff --git a/app/src/main/res/values-b+sr+CS/strings.xml b/app/src/main/res/values-b+sr+CS/strings.xml index 0146d3dca1..c1d206cf2e 100644 --- a/app/src/main/res/values-b+sr+CS/strings.xml +++ b/app/src/main/res/values-b+sr+CS/strings.xml @@ -122,7 +122,6 @@ Zabrani korisnika Korisnik zabranjen Blokiraj korisnika - Одблокирајте дописника да би послали поруку. Nema blokiranih kontakata Blokiran {name} Da li ste sigurni da želite da blokirate {name}? Blokirani korisnici ne mogu vam slati zahteve za porukama, pozivnice za grupu ili vas pozivati. diff --git a/app/src/main/res/values-b+sr+SP/strings.xml b/app/src/main/res/values-b+sr+SP/strings.xml index ef96fb8ec2..19ef28710f 100644 --- a/app/src/main/res/values-b+sr+SP/strings.xml +++ b/app/src/main/res/values-b+sr+SP/strings.xml @@ -122,7 +122,6 @@ Блокирај корисника Корисник блокиран. Блокирај - Одблокирајте дописника да би послали поруку. Нема блокираних контаката Блокирао/ла {name} Да ли сте сигурни да желите да блокирате {name}? Блокирани корисници не могу да вам шаљу захтеве за поруке, позивнице за групе или позиве. diff --git a/app/src/main/res/values-b+sv+SE/strings.xml b/app/src/main/res/values-b+sv+SE/strings.xml index 73a959f88d..77f014f478 100644 --- a/app/src/main/res/values-b+sv+SE/strings.xml +++ b/app/src/main/res/values-b+sv+SE/strings.xml @@ -142,7 +142,6 @@ Bannlys användare Användare bannlyst Blockera - Avblockera denna kontakt för att skicka meddelanden. Inga blockerade kontakter Blockerade {name} Är du säker på att du vill blockera {name}? Blockerade användare kan inte skicka meddelandeförfrågningar, gruppinbjudningar eller ringa dig. diff --git a/app/src/main/res/values-b+sw+KE/strings.xml b/app/src/main/res/values-b+sw+KE/strings.xml index c12fab30bf..9d11d640a3 100644 --- a/app/src/main/res/values-b+sw+KE/strings.xml +++ b/app/src/main/res/values-b+sw+KE/strings.xml @@ -122,7 +122,6 @@ Piga marufuku mtumiaji Mtumiaji amepigwa marufuku Zuia - Ondolea kizuizi kwa mawasiliano haya kutuma ujumbe. Hakuna mawasiliano yaliyofungiwa Amezuiliwa {name} Una uhakika unataka kumzuia {name}? Watumiaji waliodhamiriwa hawawezi kukutumia maombi ya ujumbe, mialiko ya kikundi au kukupigia simu. diff --git a/app/src/main/res/values-b+ta+IN/strings.xml b/app/src/main/res/values-b+ta+IN/strings.xml index 2cd4eab5cb..086fe7e1a1 100644 --- a/app/src/main/res/values-b+ta+IN/strings.xml +++ b/app/src/main/res/values-b+ta+IN/strings.xml @@ -122,7 +122,6 @@ பயனரை தடை செய்யவும் பயனர் தடை செய்யப்பட்டது தடை - ஒரு செய்தியை அனுப்ப இந்த தொடர்பை விடுவிக்கவும். தடைசெய்யப்பட்ட தொடர்புகள் இல்லை {name} தடை நீங்கள் நிச்சயமாக {name} ஐ தடுக்க விரும்புகிறீர்களா? தடுக்கப்பட்ட பயனர்களால் உங்களுக்கு தகவல் கோரிக்கைகளை அனுப்ப முடியாது, குழு அழைப்புகளை கையாளவும் அல்லது அழைக்கவும் முடியாது. diff --git a/app/src/main/res/values-b+te+IN/strings.xml b/app/src/main/res/values-b+te+IN/strings.xml index cf947dad77..864057b276 100644 --- a/app/src/main/res/values-b+te+IN/strings.xml +++ b/app/src/main/res/values-b+te+IN/strings.xml @@ -122,7 +122,6 @@ వినియోగదారుని నిషేధించు వాడుకరి నిషేధించబడినారు నిరోధించు - సందేశాన్ని పంపడానికి ఈ పరిచయాన్ని అనుమతించు. నిరోధించిన పరిచయాలు లేవు {name} నిరోధించబడింది మీరు {name}ని బ్లాక్ చేయాలనుకుంటున్నారా? బ్లాక్ చేసిన వినియోగదారులు మీకు సందేశ వివరణలను పంపలేరు, సమూహ ఆహ్వానాలు లేదా మీకు కాల్ చేయలేరు. diff --git a/app/src/main/res/values-b+th+TH/strings.xml b/app/src/main/res/values-b+th+TH/strings.xml index 5eeca3d6ad..bcea75b693 100644 --- a/app/src/main/res/values-b+th+TH/strings.xml +++ b/app/src/main/res/values-b+th+TH/strings.xml @@ -122,7 +122,6 @@ แบนผู้ใช้ แบนผู้ใช้แล้ว. บล็อก - เลิกปิดกั้นผู้ติดต่อนี้เพื่อส่งข้อความ. ไม่มีผู้ติดต่อที่ถูกปิดกั้น บล็อก {name} คุณแน่ใจหรือไม่ว่าต้องการบล็อก {name}? ผู้ใช้ที่ถูกบล็อกจะไม่สามารถส่งคำร้องขอข้อความเชิญกลุ่ม หรือโทรหาคุณได้ diff --git a/app/src/main/res/values-b+tl+PH/strings.xml b/app/src/main/res/values-b+tl+PH/strings.xml index de4e91b752..dd9219b8e2 100644 --- a/app/src/main/res/values-b+tl+PH/strings.xml +++ b/app/src/main/res/values-b+tl+PH/strings.xml @@ -122,7 +122,6 @@ I-ban ang user Na-ban ang user I-block - I-unblock ang contact na ito upang magpadala ng mensahe. Walang naka-block na contact Naka-block {name} Sigurado ka bang gusto mong i-block si {name}? Ang mga na-block na gumagamit ay hindi maaaring magpadala sa iyo ng mga kahilingan sa mensahe, mga paanyaya sa grupo, o tumawag sa iyo. diff --git a/app/src/main/res/values-b+tr+TR/strings.xml b/app/src/main/res/values-b+tr+TR/strings.xml index fdb5985a39..58426bd166 100644 --- a/app/src/main/res/values-b+tr+TR/strings.xml +++ b/app/src/main/res/values-b+tr+TR/strings.xml @@ -128,7 +128,6 @@ Kullanıcıyı Engelle Kullanıcı yasaklandı Engelle - İleti göndermek için bu kişinin engellenmesini kaldırın. Engellenmiş kişi yok {name} engellendi {name}\'i engellemek istediğinizden emin misiniz? Engelli kullanıcılar size ileti isteği gönderemez, grup davetiyeleri gönderemez veya sizi arayamaz. diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index e35d624778..885b8c19ea 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -150,7 +150,6 @@ Додати користувача до чорного списку Користувач заблокований Заблокувати - Розблокувати контакт для надсилання повідомлення. Немає заблокованих контактів Заблоковано {name} Ви дійсно бажаєте заблокувати {name}? Заблоковані користувачі не можуть відправляти вам запити, групові запрошення або зателефонувати вам. diff --git a/app/src/main/res/values-b+ur+IN/strings.xml b/app/src/main/res/values-b+ur+IN/strings.xml index 946b158407..f3c74d6971 100644 --- a/app/src/main/res/values-b+ur+IN/strings.xml +++ b/app/src/main/res/values-b+ur+IN/strings.xml @@ -122,7 +122,6 @@ صارف کو بین کریں صارف بین کیا گیا بلاک کریں - پیغام بھیجنے کے لیے اس رابطے کو ان بلاک کریں. کوئی بلاک شدہ رابطہ نہیں ہے {name} کو بلاک کر دیا گیا کیا آپ واقعی {name} کو بلاک کرنا چاہتے ہیں؟ بلاک کئے گئے صارفین آپ کو میسیج کی درخواستیں، گروپ دعوتیں یا کال نہیں بھیج سکتے۔ diff --git a/app/src/main/res/values-b+uz+UZ/strings.xml b/app/src/main/res/values-b+uz+UZ/strings.xml index 2a07b10bad..a20e1e0a1f 100644 --- a/app/src/main/res/values-b+uz+UZ/strings.xml +++ b/app/src/main/res/values-b+uz+UZ/strings.xml @@ -122,7 +122,6 @@ Iflosni surgun qilish Foydalanuvchi surgun qilindi Bloklash - Xabar yuborish uchun ushbu kontaktni blokdan chiqaring. Bloklangan kontaktlar yoʻq Foydalanuvchi {name} bloklandi Haqiqatan shu {name}ni bloklashni xohlaysizmi? Bloklangan foydalanuvchilar sizga xabar so\'rovlarini, guruh takliflarini yubora olmaydi yoki sizni chaqira olmaydi. diff --git a/app/src/main/res/values-b+vi+VN/strings.xml b/app/src/main/res/values-b+vi+VN/strings.xml index 30054f2b21..56bda652ca 100644 --- a/app/src/main/res/values-b+vi+VN/strings.xml +++ b/app/src/main/res/values-b+vi+VN/strings.xml @@ -139,7 +139,6 @@ Cấm người dùng Người dùng bị cấm Chặn - Mở khóa người (liên lạc) này để gởi thông báo. Không có liên lạc bị chặn Đã chặn {name} Bạn có chắc chắn rằng bạn muốn chặn {name}? Người dùng bị chặn không thể gửi yêu cầu tin nhắn, lời mời nhóm hoặc gọi cho bạn. diff --git a/app/src/main/res/values-b+xh+ZA/strings.xml b/app/src/main/res/values-b+xh+ZA/strings.xml index 38b03f7cbf..093a27bc8d 100644 --- a/app/src/main/res/values-b+xh+ZA/strings.xml +++ b/app/src/main/res/values-b+xh+ZA/strings.xml @@ -122,7 +122,6 @@ Vimba Umsebenzisi Umsebenzisi urhoxisiwe Zziyiiza - Susa lo qhakamshelwano ukuze uthumele umyalezo. Akukho qhagamshela okubhlokiweyo Uvimbile {name} Uqinisekile ukuba ufuna ukuzivimba {name}? Abasebenzisi abavalweyo abanakukuthumela izicelo zemiyalezo, izimemo zeqela okanye bakufowunele. diff --git a/app/src/main/res/values-b+zh+CN/strings.xml b/app/src/main/res/values-b+zh+CN/strings.xml index 27d840740d..f749e34894 100644 --- a/app/src/main/res/values-b+zh+CN/strings.xml +++ b/app/src/main/res/values-b+zh+CN/strings.xml @@ -125,7 +125,6 @@ 禁言该用户 用户已被封禁 屏蔽 - 取消屏蔽此联系人以发送消息。 没有屏蔽的联系人 已屏蔽{name} 您确定要屏蔽{name}吗?被屏蔽的用户将无法向您发送消息请求、群聊邀请或者语音通话。 diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 640ce7894a..e2739d7155 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -122,7 +122,6 @@ 封鎖用戶 已封鎖用戶 封鎖 - 解除封鎖聯絡人以傳送訊息。 無已封鎖的聯絡人 已封鎖 {name} 您確定要封鎖 {name} 嗎?被封鎖的使用者無法向您發送訊息請求、群組邀請或呼叫您。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4c3226b5f..17e70eef42 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,7 +152,7 @@ User banned Enter the Account ID of the user you are banning Block - Unblock this contact to send a message. + Unblock this contact to send a message No blocked contacts Blocked {name} Are you sure you want to block {name}? Blocked users cannot send you message requests, group invites or call you. @@ -810,6 +810,7 @@ {app_name} needs storage access to save attachments and media. {app_name} needs storage access to save photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". {app_name} needs storage access to send photos and videos. + You don\'t have write permissions in this community Pin Pin Conversation Unpin From f61df756dfd8ce00739ced8eb80aaeb604b0ba0c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 27 May 2025 11:13:37 +1000 Subject: [PATCH 351/867] Proper string and hide calls for non approved contacts --- .../org/session/libsession/utilities/recipients/Recipient.java | 2 +- .../securesms/conversation/v2/ConversationViewModel.kt | 2 +- .../thoughtcrime/securesms/repository/ConversationRepository.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 62cbfd54d4..23c1c28852 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -800,7 +800,7 @@ public synchronized Recipient resolve() { } public synchronized boolean showCallMenu() { - return !isGroupOrCommunityRecipient() && hasApprovedMe(); + return !isGroupOrCommunityRecipient() && hasApprovedMe() && isApproved(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 59430a9b2f..c7f2f0e1e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -386,7 +386,7 @@ class ConversationViewModel( // the user does not have write access in the community openGroup?.canWrite == false -> InputBarState( contentState = InputBarContentState.Disabled( - text = "You don't have write permissions in this community", //todo INPUT replace with real crowdin string + text = application.getString(R.string.permissionsWriteCommunity), ), enableAttachMediaControls = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 552cb1ffc8..7323f9c876 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -471,7 +471,7 @@ class DefaultConversationRepository @Inject constructor( approved = false ) } else { - storage.deleteConversation(threadId) + storage.deleteConversation(threadId) //todo should we delete the contact here instead of just the conversation? } } } From ea8a145be5398f512ffa139545d69e4a0b5496e2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 27 May 2025 11:20:22 +1000 Subject: [PATCH 352/867] real string --- app/src/main/res/layout/view_input_bar.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index 10f9c1db8f..83c85f3d5f 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -70,6 +70,7 @@ android:id="@+id/disabledBanner" android:layout_width="match_parent" android:layout_height="match_parent" + tools:visibility="visible" android:visibility="gone"> + tools:text="@string/permissionsWriteCommunity" /> \ No newline at end of file From 363941e080f883c71b131b4587455251c20a905d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 27 May 2025 13:36:14 +1000 Subject: [PATCH 353/867] Fixed ripple on avatar --- .../securesms/ui/components/ConversationAppBar.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index aab3549870..ebded951a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,6 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -35,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -51,6 +54,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryOrange import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement @@ -132,8 +136,11 @@ fun ConversationAppBar( Avatar( modifier = Modifier.qaTag(R.string.qa_conversation_avatar) .padding(end = LocalDimensions.current.xsSpacing) - .clip(CircleShape) - .clickable { onAvatarPressed() }, + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = LocalDimensions.current.iconLargeAvatar/2), + onClick = onAvatarPressed + ), size = LocalDimensions.current.iconLargeAvatar, data = data.avatarUIData ) @@ -416,6 +423,10 @@ fun ConversationTopBarPreview( AvatarUIElement( name = "TO", color = primaryBlue + ), + AvatarUIElement( + name = "TA", + color = primaryOrange ) ) ) From 29e2c5e62ac9a4ce422535128d645b576842eb73 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Tue, 27 May 2025 06:46:09 +0000 Subject: [PATCH 354/867] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17e70eef42..a1686897a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -662,6 +662,7 @@ Are you sure you want to clear all message requests and group invites? Community Message Requests Allow message requests from Community conversations. + Are you sure you want to delete this message request and the associated contact? Are you sure you want to delete this message request? You have a new message request No pending message requests From 7db26b0bf8148a8ac8666801f15a5c482c665868 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 27 May 2025 16:59:48 +1000 Subject: [PATCH 355/867] SES-3845 - delete contact when deleting message request --- .../securesms/conversation/v2/ConversationActivityV2.kt | 4 +--- .../securesms/messagerequests/MessageRequestsActivity.kt | 2 +- .../securesms/repository/ConversationRepository.kt | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index df68ce8fbb..20804f8341 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -714,8 +714,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, true, screenshotObserver ) - - //todo AVATAR Old code was force refreshing avatar here. Is it needed? } override fun onPause() { @@ -1198,7 +1196,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, showSessionDialog { title(R.string.delete) - text(resources.getString(R.string.messageRequestsDelete)) + text(resources.getString(R.string.messageRequestsContactDelete)) dangerButton(R.string.delete) { doDecline() } button(R.string.cancel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index e835297a93..5fa8a50ff7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -107,7 +107,7 @@ class MessageRequestsActivity : ScreenLockActionBarActivity(), ConversationClick showSessionDialog { title(R.string.delete) - text(resources.getString(R.string.messageRequestsDelete)) + text(resources.getString(R.string.messageRequestsContactDelete)) dangerButton(R.string.delete) { doDecline() } button(R.string.cancel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 7323f9c876..d08904b27d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -471,7 +471,7 @@ class DefaultConversationRepository @Inject constructor( approved = false ) } else { - storage.deleteConversation(threadId) //todo should we delete the contact here instead of just the conversation? + storage.deleteContactAndSyncConfig(recipient.address.toString()) } } } From 4705cae02f828f7ffb1f687db2d862dee929f156 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 10:40:47 +1000 Subject: [PATCH 356/867] SES-3852 - delete contact from conversation list item long press --- .../home/ConversationOptionsBottomSheet.kt | 11 +++++++++- .../securesms/home/HomeActivity.kt | 20 +++++++++++++++++++ .../fragment_conversation_bottom_sheet.xml | 8 ++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 8dda8dd46b..2072c832fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -46,6 +46,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto var onDeleteTapped: (() -> Unit)? = null var onMarkAllAsReadTapped: (() -> Unit)? = null var onNotificationTapped: (() -> Unit)? = null + var onDeleteContactTapped: (() -> Unit)? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentConversationBottomSheetBinding.inflate(LayoutInflater.from(parentContext), container, false) @@ -64,6 +65,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.deleteTextView -> onDeleteTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke() + binding.deleteContactTextView -> onDeleteContactTapped?.invoke() } } @@ -71,6 +73,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto super.onViewCreated(view, savedInstanceState) if (!this::thread.isInitialized) { return dismiss() } val recipient = thread.recipient + + binding.deleteContactTextView.isVisible = false + if (!recipient.isGroupOrCommunityRecipient && !recipient.isLocalNumber) { binding.detailsTextView.visibility = View.VISIBLE binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE @@ -138,9 +143,13 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // 1on1 else -> { - text = context.getString(R.string.delete) + text = context.getString(R.string.conversationsDelete) contentDescription = context.getString(R.string.AccessibilityId_delete) drawableStartRes = R.drawable.ic_trash_2 + + // also show delete contact for 1on1 + binding.deleteContactTextView.isVisible = true + binding.deleteContactTextView.setOnClickListener(this@ConversationOptionsBottomSheet) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index a4df43a87a..5bd30f024f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -606,6 +606,10 @@ class HomeActivity : ScreenLockActionBarActivity(), bottomSheet.dismiss() markAllAsRead(thread) } + bottomSheet.onDeleteContactTapped = { + bottomSheet.dismiss() + confirmDeleteContact(thread) + } bottomSheet.show(supportFragmentManager, bottomSheet.tag) } @@ -647,6 +651,22 @@ class HomeActivity : ScreenLockActionBarActivity(), } } + private fun confirmDeleteContact(thread: ThreadRecord) { + showSessionDialog { + title(R.string.contactDelete) + text( + Phrase.from(context, R.string.deleteContactDescription) + .put(NAME_KEY, thread.recipient?.name ?: "") + .put(NAME_KEY, thread.recipient?.name ?: "") + .format() + ) + dangerButton(R.string.delete, R.string.qa_conversation_settings_dialog_delete_contact_confirm) { + homeViewModel.deleteContact(thread.recipient.address.toString()) + } + cancelButton() + } + } + private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.Default) { storage.setPinned(threadId, pinned) diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index 49971383ee..6d0764274f 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -98,4 +98,12 @@ app:drawableTint="?attr/colorControlNormal" tools:drawableStartCompat="@drawable/ic_trash_2" /> + + From d4e44853fc1ffe1ae832db575d82c028dc2dbdc4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 11:23:05 +1000 Subject: [PATCH 357/867] SES-2547 - filter out communities without write access from share menu --- .../java/org/thoughtcrime/securesms/ShareActivity.kt | 11 +++++------ ...lectionListLoader.kt => ShareContactListLoader.kt} | 9 ++++++++- app/src/main/res/layout/share_activity.xml | 2 +- ...t_fragment.xml => share_contact_list_fragment.xml} | 0 4 files changed, 14 insertions(+), 8 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/contacts/{ContactSelectionListLoader.kt => ShareContactListLoader.kt} (77%) rename app/src/main/res/layout/{contact_selection_list_fragment.xml => share_contact_list_fragment.xml} (100%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt index 13f693b513..efe800d81d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -40,9 +40,8 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.components.SearchToolbar import org.thoughtcrime.securesms.components.SearchToolbar.SearchListener -import org.thoughtcrime.securesms.contacts.ContactSelectionListFragment -import org.thoughtcrime.securesms.contacts.ContactSelectionListFragment.OnContactSelectedListener -import org.thoughtcrime.securesms.contacts.ContactSelectionListLoader +import org.thoughtcrime.securesms.contacts.ShareContactListFragment +import org.thoughtcrime.securesms.contacts.ShareContactListFragment.OnContactSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.PartAuthority @@ -68,7 +67,7 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { get() = false // Lateinit UI elements - private lateinit var contactsFragment: ContactSelectionListFragment + private lateinit var contactsFragment: ShareContactListFragment private lateinit var searchToolbar: SearchToolbar private lateinit var searchAction: ImageView private lateinit var progressWheel: View @@ -81,7 +80,7 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { override fun onCreate(icicle: Bundle?, ready: Boolean) { - intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false) + intent.putExtra(ShareContactListFragment.REFRESHABLE, false) setContentView(R.layout.share_activity) @@ -138,7 +137,7 @@ class ShareActivity : ScreenLockActionBarActivity(), OnContactSelectedListener { progressWheel = findViewById(R.id.progress_wheel) searchToolbar = findViewById(R.id.search_toolbar) searchAction = findViewById(R.id.search_action) - contactsFragment = supportFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment + contactsFragment = supportFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ShareContactListFragment contactsFragment.onContactSelectedListener = this } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListLoader.kt similarity index 77% rename from app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt rename to app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListLoader.kt index 5dec06f0e1..d3374b37bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListLoader.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.contacts import android.content.Context +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.util.AsyncLoader @@ -11,7 +12,7 @@ sealed class ContactSelectionListItem { class Contact(val recipient: Recipient) : ContactSelectionListItem() } -class ContactSelectionListLoader( +class ShareContactListLoader( context: Context, val mode: Int, val filter: String?, @@ -22,6 +23,12 @@ class ContactSelectionListLoader( val contacts = ContactUtilities.getAllContacts(context).asSequence() .filter { if(it.first.isLegacyGroupRecipient && deprecationManager.isDeprecated) return@filter false // ignore legacy group when deprecated + if(it.first.isCommunityRecipient) { // ignore communities without write access + val storage = MessagingModuleConfiguration.shared.storage + val threadId = storage.getThreadId(it.first) ?: return@filter false + val openGroup = storage.getOpenGroup(threadId) ?: return@filter false + return@filter openGroup.canWrite + } if (filter.isNullOrEmpty()) return@filter true it.first.name.contains(filter.trim(), true) || it.first.address.toString().contains(filter.trim(), true) }.sortedWith( diff --git a/app/src/main/res/layout/share_activity.xml b/app/src/main/res/layout/share_activity.xml index 756834053f..1c3000ddd1 100644 --- a/app/src/main/res/layout/share_activity.xml +++ b/app/src/main/res/layout/share_activity.xml @@ -50,7 +50,7 @@ android:layout_below="@id/search_toolbar" android:layout_width="match_parent" android:layout_height="match_parent" - android:name="org.thoughtcrime.securesms.contacts.ContactSelectionListFragment" /> + android:name="org.thoughtcrime.securesms.contacts.ShareContactListFragment" /> Date: Wed, 28 May 2025 11:23:13 +1000 Subject: [PATCH 358/867] Rename --- ...Fragment.kt => ShareContactListFragment.kt} | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/contacts/{ContactSelectionListFragment.kt => ShareContactListFragment.kt} (89%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListFragment.kt similarity index 89% rename from app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt rename to app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListFragment.kt index 728dfc752f..1cccf0bb25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ShareContactListFragment.kt @@ -4,7 +4,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding @@ -12,18 +11,17 @@ import androidx.fragment.app.Fragment import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager -import network.loki.messenger.databinding.ContactSelectionListFragmentBinding -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R +import network.loki.messenger.databinding.ShareContactListFragmentBinding import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import javax.inject.Inject @AndroidEntryPoint -class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks>, ContactClickListener { - private lateinit var binding: ContactSelectionListFragmentBinding +class ShareContactListFragment : Fragment(), LoaderManager.LoaderCallbacks>, ContactClickListener { + private lateinit var binding: ShareContactListFragmentBinding private var cursorFilter: String? = null var onContactSelectedListener: OnContactSelectedListener? = null @@ -57,7 +55,7 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks> { - return ContactSelectionListLoader( + return ShareContactListLoader( context = requireActivity(), mode = ContactsCursorLoader.DisplayMode.FLAG_ALL, filter = cursorFilter, @@ -109,7 +107,7 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks) { if (activity?.isDestroyed == true) { - Log.e(ContactSelectionListFragment::class.java.name, + Log.e(ShareContactListFragment::class.java.name, "Received a loader callback after the fragment was detached from the activity.", IllegalStateException()) return From ac85b3396ddc3c63e164be22e477c4a9485bcc68 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 13:27:42 +1000 Subject: [PATCH 359/867] Handling null market cap since the API can now return a null value --- .../thoughtcrime/securesms/tokenpage/TokenPage.kt | 4 ---- .../securesms/tokenpage/TokenPageDataTypes.kt | 14 ++------------ .../securesms/tokenpage/TokenPageUIState.kt | 2 -- .../securesms/tokenpage/TokenPageViewModel.kt | 9 ++++----- 4 files changed, 6 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt index 5bd680338c..e2978368f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt @@ -812,11 +812,9 @@ fun PreviewTokenPage() { uiState = TokenPageUIState( currentSessionNodesInSwarm = 5, currentSessionNodesSecuringMessages = 125349, - currentSentPriceUSD = SerializableBigDecimal(1.23), currentSentPriceUSDString = "$1,472.22 USD", networkSecuredBySENTString = "12M SENT", networkSecuredByUSDString = "$1,234,567 USD", - currentMarketCapUSD = SerializableBigDecimal(420_000_000), currentStakingRewardPool = SerializableBigDecimal(40_000_000), currentMarketCapUSDString = "$20,456,259 USD", currentStakingRewardPoolString = "40,567,789,654,789 SESH", @@ -837,10 +835,8 @@ fun PreviewTokenPageLoading() { uiState = TokenPageUIState( currentSessionNodesInSwarm = 5, currentSessionNodesSecuringMessages = 123, - currentSentPriceUSD = SerializableBigDecimal(1.23), networkSecuredBySENTString = "12M SENT", networkSecuredByUSDString = "$1,234,567 USD", - currentMarketCapUSD = SerializableBigDecimal(420_000_000), currentStakingRewardPool = SerializableBigDecimal(40_000_000), showNodeCountsAsRefreshing = true ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt index 0254e5e767..c33c93e5bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt @@ -33,7 +33,7 @@ data class PriceData( @SerialName("usd") val tokenPriceUSD: SerializableBigDecimal, // Current market cap value in US dollars - @SerialName("usd_market_cap") val marketCapUSD: SerializableBigDecimal, + @SerialName("usd_market_cap") val marketCapUSD: SerializableBigDecimal?, // The timestamp (in seconds) of when the server's CoinGecko-sourced token price data was last updated @SerialName("t_price") val priceTimestampSecs: Long, @@ -44,17 +44,7 @@ data class PriceData( // current time is lower than `t_stale`, and if it is then we don't poll the server again as // we'd just be getting the same data. @SerialName("t_stale") val staleTimestampSecs: Long -) { - // Get the token price in USD to 2 decimal places in a locale-aware manner - fun getLocaleFormattedTokenPriceString(): String { - return "\$" + tokenPriceUSD.formatWithDecimalPlaces(2) + " USD" - } - - // Get the token price in USD to ZERO decimal places in a locale-aware manner - fun getLocaleFormattedMarketCapString(): String { - return "\$" + marketCapUSD.formatWithDecimalPlaces( 0) + " USD" - } -} +) // Data class to hold details provided in a InfoResponse or via the `GET /token` endpoint @Serializable diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt index 6dec45ff6c..af44890f11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt @@ -24,11 +24,9 @@ data class TokenPageUIState( // ----- PriceResponse / PriceData UI representations ----- // Number so we can perform calculation (this value is obtained from PriceData.usd) - var currentSentPriceUSD: SerializableBigDecimal = BigDecimal.ZERO, var currentSentPriceUSDString: String = "", // Number so we can perform calculations (this value is obtained from PriceData.usd_market_cap) - val currentMarketCapUSD: SerializableBigDecimal = BigDecimal.ZERO, val currentMarketCapUSDString: String = "", // ----- TokenResponse / TokenData UI representations ----- diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index ae6ff30644..ef307a91dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -164,11 +164,10 @@ class TokenPageViewModel @Inject constructor( showNodeCountsAsRefreshing = false, - currentSentPriceUSD = infoResponse.priceData.tokenPriceUSD, // Raw token price value "1.23" etc. - currentSentPriceUSDString = infoResponse.priceData.getLocaleFormattedTokenPriceString(), // Formatted token price value "$1.23 USD" etc. - currentMarketCapUSD = infoResponse.priceData.marketCapUSD, // Raw market cap value "1,234,567" etc. - currentMarketCapUSDString = infoResponse.priceData.getLocaleFormattedMarketCapString(), // Formatted market cap value "$1,234,567 USD" etc. - + currentSentPriceUSDString = "\$" + infoResponse.priceData.tokenPriceUSD.formatWithDecimalPlaces(2) + " $USD_NAME_SHORT", // Formatted token price value "$1.23 USD" etc. + currentMarketCapUSDString = if(infoResponse.priceData.marketCapUSD == null) unavailableString + else "\$" + infoResponse.priceData.marketCapUSD.formatWithDecimalPlaces( 0) + " $USD_NAME_SHORT", // Formatted market cap value "$1,234,567 USD" etc. + currentStakingRewardPool = infoResponse.tokenData.stakingRewardPool, currentStakingRewardPoolString = infoResponse.tokenData.getLocaleFormattedStakingRewardPool() + " $TOKEN_NAME_SHORT", From a34d63feb330d988bb836810fa873d45bad21e55 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 15:09:08 +1000 Subject: [PATCH 360/867] Fixing community long press --- .../securesms/home/ConversationOptionsBottomSheet.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 2072c832fd..e5058fd9af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -117,7 +117,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // the text, content description and icon will change depending on the type when { // groups and communities - recipient.isGroupOrCommunityRecipient -> { + recipient.isGroupRecipient -> { val accountId = AccountId(recipient.address.toString()) val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return // if you are in a group V2 and have been kicked of that group, or the group was destroyed, @@ -134,6 +134,12 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } } + recipient.isCommunityRecipient -> { + text = context.getString(R.string.leave) + contentDescription = context.getString(R.string.AccessibilityId_leave) + drawableStartRes = R.drawable.ic_log_out + } + // note to self recipient.isLocalNumber -> { text = context.getString(R.string.hide) From c0151e3f0444c2a2b70051d816d1386ba814862c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 15:10:21 +1000 Subject: [PATCH 361/867] Extra debug options for new notification screen --- .../NotificationSettingsViewModel.kt | 26 ++++++++++++++++++- .../src/main/res/values/strings.xml | 2 ++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index ed84594ce0..1b09f84c4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -141,7 +141,26 @@ class NotificationSettingsViewModel @AssistedInject constructor( ) } - // add the regular options + // add debug options on non prod builds + muteRadioOptions.addAll( + debugMuteDurations.map { + RadioOption( + value = it.first, + title = GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.first.milliseconds + ) + ), + subtitle = GetString("For testing purposes"), + qaTag = GetString(it.second), + selected = selectedMuteDuration == it.first + ) + } + ) + + + // add the regular options muteRadioOptions.addAll( muteDurations.map { RadioOption( @@ -259,6 +278,11 @@ class NotificationSettingsViewModel @AssistedInject constructor( data object Mute: NotificationType(NOTIFY_TYPE_NONE) } + private val debugMuteDurations = listOf( + TimeUnit.MINUTES.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1m, + TimeUnit.MINUTES.toMillis(5) to R.string.qa_conversation_settings_notifications_radio_5m, + ) + private val muteDurations = listOf( durationForever to R.string.qa_conversation_settings_notifications_radio_forever, TimeUnit.HOURS.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1h, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 7b1c7c6636..749f1a42c3 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -102,6 +102,8 @@ notifications-mute-radio-button notifications-muted-until-radio-button notifications-forever-time-option + notifications-one-minute-time-option + notifications-five-minute-time-option notifications-one-hour-time-option notifications-two-hours-time-option notifications-one-day-time-option From 5818a392f3ba39c90e7fedc59a0456621877c9ef Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 28 May 2025 15:19:10 +1000 Subject: [PATCH 362/867] Only for non prod builds... --- .../NotificationSettingsViewModel.kt | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 1b09f84c4b..0759e3ff29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY @@ -142,25 +143,26 @@ class NotificationSettingsViewModel @AssistedInject constructor( } // add debug options on non prod builds - muteRadioOptions.addAll( - debugMuteDurations.map { - RadioOption( - value = it.first, - title = GetString( - LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - it.first.milliseconds - ) - ), - subtitle = GetString("For testing purposes"), - qaTag = GetString(it.second), - selected = selectedMuteDuration == it.first - ) - } - ) - + if (BuildConfig.BUILD_TYPE != "release") { + muteRadioOptions.addAll( + debugMuteDurations.map { + RadioOption( + value = it.first, + title = GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.first.milliseconds + ) + ), + subtitle = GetString("For testing purposes"), + qaTag = GetString(it.second), + selected = selectedMuteDuration == it.first + ) + } + ) + } - // add the regular options + // add the regular options muteRadioOptions.addAll( muteDurations.map { RadioOption( From 7e62bacaf218d7a71ac4cfbbc47e84c3a26482fb Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 29 May 2025 09:22:28 +1000 Subject: [PATCH 363/867] [SES-3538] - Clean up community related classes (#1208) --- .../libsession/database/StorageProtocol.kt | 3 +- .../messaging/jobs/BackgroundGroupAddJob.kt | 1 - .../messaging/open_groups/OpenGroupApi.kt | 4 +- .../pollers/OpenGroupPoller.kt | 199 +++++++++++------- .../pollers/OpenGroupPollerManager.kt | 114 ++++++++++ .../securesms/ApplicationContext.kt | 13 +- .../securesms/configs/ConfigToDatabaseSync.kt | 4 +- .../conversation/v2/ConversationActivityV2.kt | 13 +- .../v2/ConversationReactionOverlay.kt | 8 +- .../conversation/v2/ConversationViewModel.kt | 12 +- .../v2/dialogs/JoinOpenGroupDialog.kt | 5 +- .../menus/ConversationActionModeCallback.kt | 7 +- .../v2/messages/VisibleMessageView.kt | 7 +- .../settings/ConversationSettingsViewModel.kt | 5 +- .../securesms/database/GroupDatabase.java | 2 +- .../securesms/database/Storage.kt | 12 +- .../securesms/dependencies/AppModule.kt | 6 +- .../securesms/groups/GroupManager.java | 1 - .../securesms/groups/JoinCommunityFragment.kt | 8 +- .../securesms/groups/OpenGroupManager.kt | 141 ++++--------- .../securesms/home/HomeActivity.kt | 4 +- .../notifications/BackgroundPollWorker.kt | 18 +- .../OptimizedMessageNotifier.java | 21 +- .../v2/ConversationViewModelTest.kt | 5 +- 24 files changed, 362 insertions(+), 251 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index e48e002a62..98f856d8c8 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -20,7 +20,6 @@ import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage @@ -82,7 +81,7 @@ interface StorageProtocol { fun getAllOpenGroups(): Map fun updateOpenGroup(openGroup: OpenGroup) fun getOpenGroup(threadId: Long): OpenGroup? - suspend fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? + suspend fun addOpenGroup(urlAsString: String) fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index 5f7bb34ce4..cb89d892ab 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -1,6 +1,5 @@ package org.session.libsession.messaging.jobs -import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroup diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 271ac5fb55..14f199a9d7 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -18,7 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.MAX_INACTIVITIY_PERIOD_MILLS import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI @@ -623,7 +623,7 @@ object OpenGroupApi { val context = MessagingModuleConfiguration.shared.context val timeSinceLastOpen = this.timeSinceLastOpen val shouldRetrieveRecentMessages = (hasPerformedInitialPoll[server] != true - && timeSinceLastOpen > maxInactivityPeriod) + && timeSinceLastOpen > MAX_INACTIVITIY_PERIOD_MILLS) hasPerformedInitialPoll[server] = true if (!hasUpdatedLastOpenDate) { hasUpdatedLastOpenDate = true diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index e40b5f00a0..7a061bd1b9 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -1,14 +1,26 @@ package org.session.libsession.messaging.sending_receiving.pollers import com.google.protobuf.ByteString -import nl.komponents.kovenant.Promise +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import nl.komponents.kovenant.functional.map +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob @@ -25,35 +37,49 @@ import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.successBackground +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import java.util.UUID -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture +import org.thoughtcrime.securesms.util.AppVisibilityManager import java.util.concurrent.TimeUnit -class OpenGroupPoller(private val server: String, private val executorService: ScheduledExecutorService?) { - var hasStarted = false - var isCaughtUp = false - var secondToLastJob: MessageReceiveJob? = null - private var future: ScheduledFuture<*>? = null - @Volatile private var runId: UUID = UUID.randomUUID() +private typealias ManualPollRequestToken = Channel> + +/** + * A [OpenGroupPoller] is responsible for polling all communities on a particular server. + * + * Once this class is created, it will start polling when the app becomes visible (and stop whe + * the app becomes invisible), it will also respond to manual poll requests regardless of the app visibility. + * + * To stop polling, you can cancel the [CoroutineScope] that was passed to the constructor. + */ +class OpenGroupPoller @AssistedInject constructor( + private val storage: StorageProtocol, + private val appVisibilityManager: AppVisibilityManager, + @Assisted private val server: String, + @Assisted private val scope: CoroutineScope, +) { + private val mutableIsCaughtUp = MutableStateFlow(false) + val isCaughtUp: StateFlow get() = mutableIsCaughtUp + + private val manualPollRequest = Channel() companion object { - private const val pollInterval: Long = 4000L - const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000 + private const val POLL_INTERVAL_MILLS: Long = 4000L + const val MAX_INACTIVITIY_PERIOD_MILLS = 14 * 24 * 60 * 60 * 1000L // 14 days + + private const val TAG = "OpenGroupPoller" - public fun handleRoomPollInfo( + fun handleRoomPollInfo( + storage: StorageProtocol, server: String, roomToken: String, pollInfo: OpenGroupApi.RoomPollInfo, createGroupIfMissingWithPublicKey: String? = null ) { - val storage = MessagingModuleConfiguration.shared.storage val groupId = "$server.$roomToken" val dbGroupId = GroupUtil.getEncodedOpenGroupID(groupId.toByteArray()) val existingOpenGroup = storage.getOpenGroup(roomToken, server) @@ -133,79 +159,94 @@ class OpenGroupPoller(private val server: String, private val executorService: S } } - fun startIfNeeded() { - if (hasStarted) { return } - hasStarted = true - runId = UUID.randomUUID() - future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS) - } + init { + scope.launch { + while (true) { + // Wait until the app is visible before starting the polling, + // or when we receive a manual poll request + val token = merge( + appVisibilityManager.isAppVisible.filter { it }.map { null }, + manualPollRequest.receiveAsFlow() + ).first() + + mutableIsCaughtUp.value = false + var delayDuration = POLL_INTERVAL_MILLS + try { + Log.d(TAG, "Polling open group messages for server: $server") + pollOnce() + mutableIsCaughtUp.value = true + token?.trySend(Result.success(Unit)) + } catch (e: Exception) { + Log.e(TAG, "Error while polling open group messages", e) + delayDuration = 2000L + token?.trySend(Result.failure(e)) + } - fun stop() { - future?.cancel(false) - hasStarted = false + delay(delayDuration) + } + } } - fun poll(isPostCapabilitiesRetry: Boolean = false): Promise { - val currentRunId = runId - val storage = MessagingModuleConfiguration.shared.storage - val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room } + private suspend fun pollOnce(isPostCapabilitiesRetry: Boolean = false) { + val rooms = storage.getAllOpenGroups() + .values + .asSequence() + .filter { it.server == server } + .map { it.room } + .toList() - return OpenGroupApi.poll(rooms, server).successBackground { responses -> - responses.filterNot { it.body == null }.forEach { response -> - when (response.endpoint) { - is Endpoint.Capabilities -> { - handleCapabilities(server, response.body as OpenGroupApi.Capabilities) - } - is Endpoint.RoomPollInfo -> { - handleRoomPollInfo(server, response.endpoint.roomToken, response.body as OpenGroupApi.RoomPollInfo) - } - is Endpoint.RoomMessagesRecent -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) - } - is Endpoint.RoomMessagesSince -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + try { + OpenGroupApi + .poll(rooms, server) + .await() + .asSequence() + .filterNot { it.body == null } + .forEach { response -> + when (response.endpoint) { + is Endpoint.Capabilities -> { + handleCapabilities(server, response.body as OpenGroupApi.Capabilities) + } + is Endpoint.RoomPollInfo -> { + handleRoomPollInfo(storage, server, response.endpoint.roomToken, response.body as OpenGroupApi.RoomPollInfo) + } + is Endpoint.RoomMessagesRecent -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.RoomMessagesSince -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.Inbox, is Endpoint.InboxSince -> { + handleDirectMessages(server, false, response.body as List) + } + is Endpoint.Outbox, is Endpoint.OutboxSince -> { + handleDirectMessages(server, true, response.body as List) + } + else -> { /* We don't care about the result of any other calls (won't be polled for) */} } - is Endpoint.Inbox, is Endpoint.InboxSince -> { - handleDirectMessages(server, false, response.body as List) - } - is Endpoint.Outbox, is Endpoint.OutboxSince -> { - handleDirectMessages(server, true, response.body as List) - } - else -> { /* We don't care about the result of any other calls (won't be polled for) */} - } - if (secondToLastJob == null && !isCaughtUp) { - isCaughtUp = true } - } + } catch (e: Exception) { + updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, e) + throw e + } + } - // Only poll again if it's the same poller run - if (currentRunId == runId) { - future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) - } - }.fail { - updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, currentRunId, it) - }.map { } + suspend fun manualPollOnce() { + val token = Channel>() + manualPollRequest.send(token) + token.receive().getOrThrow() } - private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, currentRunId: UUID, exception: Exception) { + private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) { if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) { if (!isPostCapabilitiesRetry) { OpenGroupApi.getCapabilities(server).map { handleCapabilities(server, it) } - - // Only poll again if it's the same poller run - if (currentRunId == runId) { - future = executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS) - } } - } else if (currentRunId == runId) { - future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) } } private fun handleCapabilities(server: String, capabilities: OpenGroupApi.Capabilities) { - val storage = MessagingModuleConfiguration.shared.storage storage.setServerCapabilities(server, capabilities.capabilities) } @@ -216,7 +257,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S ) { val sortedMessages = messages.sortedBy { it.seqno } sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> - MessagingModuleConfiguration.shared.storage.setLastMessageServerID(roomToken, server, seqNo) + storage.setLastMessageServerID(roomToken, server, seqNo) OpenGroupApi.pendingReactions.removeAll { !(it.seqNo == null || it.seqNo!! > seqNo) } } val (deletions, additions) = sortedMessages.partition { it.deleted } @@ -239,7 +280,6 @@ class OpenGroupPoller(private val server: String, private val executorService: S messages: List ) { if (messages.isEmpty()) return - val storage = MessagingModuleConfiguration.shared.storage val serverPublicKey = storage.getOpenGroupPublicKey(server)!! val sortedMessages = messages.sortedBy { it.id } val lastMessageId = sortedMessages.last().id @@ -281,22 +321,21 @@ class OpenGroupPoller(private val server: String, private val executorService: S } mappingCache[it.recipient] = mapping } - val threadId = Message.getThreadId(message, null, MessagingModuleConfiguration.shared.storage, false) + val threadId = Message.getThreadId(message, null, storage, false) MessageReceiver.handle(message, proto, threadId ?: -1, null, null) } catch (e: Exception) { - Log.e("Loki", "Couldn't handle direct message", e) + Log.e(TAG, "Couldn't handle direct message", e) } } } private fun handleNewMessages(server: String, roomToken: String, messages: List) { - val storage = MessagingModuleConfiguration.shared.storage val openGroupID = "$server.$roomToken" val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) // check thread still exists val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1 val threadExists = threadId >= 0 - if (!hasStarted || !threadExists) { return } + if (!threadExists) { return } val envelopes = mutableListOf?>>() messages.sortedBy { it.serverID!! }.forEach { message -> if (!message.base64EncodedData.isNullOrEmpty()) { @@ -329,7 +368,6 @@ class OpenGroupPoller(private val server: String, private val executorService: S private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List) { val openGroupId = "$server.$roomToken" - val storage = MessagingModuleConfiguration.shared.storage val groupID = GroupUtil.getEncodedOpenGroupID(openGroupId.toByteArray()) val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return @@ -338,4 +376,9 @@ class OpenGroupPoller(private val server: String, private val executorService: S JobQueue.shared.add(deleteJob) } } + + @AssistedFactory + interface Factory { + fun create(server: String, scope: CoroutineScope): OpenGroupPoller + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt new file mode 100644 index 0000000000..7df532438c --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -0,0 +1,114 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "OpenGroupPollerManager" + +/** + * [OpenGroupPollerManager] manages the lifecycle of [OpenGroupPoller] instances for all + * subscribed open groups. It creates a poller for a server (a server can host + * multiple open groups), and it stops the poller when the server is no longer subscribed by + * any open groups. + * + * This process is fully responsive to changes in the user's config and as long as the config + * is up to date, the pollers will be created and stopped correctly. + */ +@Singleton +class OpenGroupPollerManager @Inject constructor( + pollerFactory: OpenGroupPoller.Factory, + configFactory: ConfigFactoryProtocol, + preferences: TextSecurePreferences, +) { + val pollers: StateFlow> = + preferences.watchLocalNumber() + .map { it != null } + .distinctUntilChanged() + .flatMapLatest { loggedIn -> + if (loggedIn) { + (configFactory + .configUpdateNotifications + .filter { it is ConfigUpdateNotification.UserConfigsMerged || it == ConfigUpdateNotification.UserConfigsModified } as Flow<*>) + .onStart { emit(Unit) } + .map { + configFactory.withUserConfigs { configs -> + configs.userGroups.allCommunityInfo() + }.mapTo(hashSetOf()) { it.community.baseUrl } + } + } else { + flowOf(emptySet()) + } + } + .scan(emptyMap()) { acc, value -> + if (acc.keys == value) { + acc // No change, return the same map + } else { + val newPollerStates = value.associateWith { baseUrl -> + acc[baseUrl] ?: run { + val scope = CoroutineScope(Dispatchers.Default) + Log.d(TAG, "Creating new poller for $baseUrl") + PollerHandle( + poller = pollerFactory.create(baseUrl, scope), + pollerScope = scope + ) + } + } + + for ((baseUrl, handle) in acc) { + if (baseUrl !in value) { + Log.d(TAG, "Stopping poller for $baseUrl") + handle.pollerScope.cancel() + } + } + + newPollerStates + } + } + .stateIn(GlobalScope, SharingStarted.Eagerly, emptyMap()) + + val isAllCaughtUp: Boolean + get() = pollers.value.values.all { it.poller.isCaughtUp.value } + + + suspend fun pollAllOpenGroupsOnce() { + Log.d(TAG, "Polling all open groups once") + supervisorScope { + pollers.value.map { (server, handle) -> + handle.pollerScope.launch { + runCatching { + handle.poller.manualPollOnce() + }.onFailure { + Log.e(TAG, "Error polling open group ${server}", it) + } + } + }.joinAll() + } + } + + data class PollerHandle( + val poller: OpenGroupPoller, + val pollerScope: CoroutineScope + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 63ee45e3e7..e3cda743a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -49,6 +49,7 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.messaging.sending_receiving.pollers.Poller import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SnodeModule.Companion.configure @@ -82,8 +83,6 @@ import org.thoughtcrime.securesms.disguise.AppDisguiseManager import org.thoughtcrime.securesms.emoji.EmojiSource.Companion.refresh import org.thoughtcrime.securesms.groups.ExpiredGroupManager import org.thoughtcrime.securesms.groups.GroupPollerManager -import org.thoughtcrime.securesms.groups.OpenGroupManager.startPolling -import org.thoughtcrime.securesms.groups.OpenGroupManager.stopPolling import org.thoughtcrime.securesms.groups.handler.AdminStateSync import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync @@ -205,6 +204,9 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, @Inject lateinit var expiredGroupManager: Lazy // Exists here only to start upon app starts + @Inject + lateinit var openGroupPollerManager: Lazy + @Volatile var isAppVisible: Boolean = false @@ -379,6 +381,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, appVisibilityManager.get() groupPollerManager.get() expiredGroupManager.get() + openGroupPollerManager.get() } override fun onStart(owner: LifecycleOwner) { @@ -394,11 +397,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, startPollingIfNeeded() - queue { - startPolling() - Unit - } - // fetch last version data versionDataFetcher.get().startTimedVersionCheck() } @@ -417,7 +415,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, override fun onTerminate() { stopKovenant() // Loki - stopPolling() versionDataFetcher.get().stopTimedVersionCheck() super.onTerminate() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 870649a5db..56ce7c3462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -64,13 +64,13 @@ class ConfigToDatabaseSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, private val threadDatabase: ThreadDatabase, - private val recipientDatabase: RecipientDatabase, private val clock: SnodeClock, private val profileManager: ProfileManager, private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, private val mmsSmsDatabase: MmsSmsDatabase, private val legacyClosedGroupPollerV2: LegacyClosedGroupPollerV2, + private val openGroupManager: OpenGroupManager, ) { init { if (!preferences.migratedToGroupV2Config) { @@ -285,7 +285,7 @@ class ConfigToDatabaseSync @Inject constructor( // delete the ones which are not listed in the config toDeleteCommunities.values.forEach { openGroup -> - OpenGroupManager.delete(openGroup.server, openGroup.room, context) + openGroupManager.delete(openGroup.server, openGroup.room, context) } toDeleteLegacyClosedGroups.forEach { deleteGroup -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 20804f8341..58f08aff08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -257,6 +257,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var groupManagerV2: GroupManagerV2 @Inject lateinit var typingStatusRepository: TypingStatusRepository @Inject lateinit var typingStatusSender: TypingStatusSender + @Inject lateinit var openGroupManager: OpenGroupManager override val applyDefaultWindowInsets: Boolean get() = false @@ -1547,7 +1548,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, adapter = adapter, threadID = viewModel.threadId, context = this, - deprecationManager = viewModel.legacyGroupDeprecationManager + deprecationManager = viewModel.legacyGroupDeprecationManager, + openGroupManager = openGroupManager, ) actionModeCallback.delegate = this actionModeCallback.updateActionModeMenu(actionMode.menu) @@ -1571,7 +1573,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, adapter = adapter, threadID = viewModel.threadId, context = this, - deprecationManager = viewModel.legacyGroupDeprecationManager + deprecationManager = viewModel.legacyGroupDeprecationManager, + openGroupManager = openGroupManager, ) actionModeCallback.delegate = this if(binding.searchBottomBar.isVisible) onSearchClosed() @@ -1927,7 +1930,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (viewModel.recipient?.isGroupOrCommunityRecipient == true) { val isUserModerator = viewModel.openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false - OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey) + openGroupManager.isUserModerator( + openGroup.id, + userPublicKey, + viewModel.blindedPublicKey + ) } ?: false val fragment = ReactionsDialogFragment.create(messageId, isUserModerator, emoji, viewModel.canRemoveReaction) fragment.show(supportFragmentManager, null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 88b7beff31..5c83b5a740 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2 import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.content.Context @@ -29,11 +28,9 @@ import dagger.hilt.android.AndroidEntryPoint import java.util.Locale import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -48,7 +45,6 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocal import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.menu.ActionItem @@ -56,7 +52,6 @@ import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord -import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.groups.OpenGroupManager @@ -109,6 +104,7 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var threadDatabase: ThreadDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager + @Inject lateinit var openGroupManager: OpenGroupManager private var job: Job? = null @@ -643,7 +639,7 @@ class ConversationReactionOverlay : FrameLayout { private fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String, blindedPublicKey: String?): Boolean { if (openGroup == null) return false if (message.isOutgoing) return false // Users can't ban themselves - return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) + return openGroupManager.isUserModerator(openGroup.groupId, userPublicKey, blindedPublicKey) } private fun handleActionItemClicked(action: Action) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index c7f2f0e1e6..0df32c8882 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -107,8 +107,8 @@ class ConversationViewModel( private val expiredGroupManager: ExpiredGroupManager, private val usernameUtils: UsernameUtils, private val avatarUtils: AvatarUtils, - private val recipientChangeSource: RecipientChangeSource - + private val recipientChangeSource: RecipientChangeSource, + private val openGroupManager: OpenGroupManager, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -343,7 +343,7 @@ class ConversationViewModel( // Listen for changes in the open group's write access viewModelScope.launch { - OpenGroupManager.getCommunitiesWriteAccessFlow() + openGroupManager.getCommunitiesWriteAccessFlow() .map { withContext(Dispatchers.Default) { if (openGroup?.groupId != null) @@ -1015,7 +1015,7 @@ class ConversationViewModel( private fun isUserCommunityManager() = openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false - OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey) + openGroupManager.isUserModerator(openGroup.id, userPublicKey, blindedPublicKey) } ?: false /** @@ -1297,6 +1297,7 @@ class ConversationViewModel( private val usernameUtils: UsernameUtils, private val avatarUtils: AvatarUtils, private val recipientChangeSource: RecipientChangeSource, + private val openGroupManager: OpenGroupManager, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -1321,7 +1322,8 @@ class ConversationViewModel( expiredGroupManager = expiredGroupManager, usernameUtils = usernameUtils, avatarUtils = avatarUtils, - recipientChangeSource = recipientChangeSource + recipientChangeSource = recipientChangeSource, + openGroupManager = openGroupManager, ) as T } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index 9f1e571bc4..ac1b624b16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -30,6 +30,9 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D @Inject lateinit var storage: StorageProtocol + @Inject + lateinit var openGroupManager: OpenGroupManager + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(resources.getString(R.string.communityJoin)) val explanation = Phrase.from(context, R.string.communityJoinDescription).put(COMMUNITY_NAME_KEY, name).format() @@ -53,7 +56,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D GlobalScope.launch(Dispatchers.Main) { try { withContext(Dispatchers.Default) { - OpenGroupManager.add( + openGroupManager.add( server = openGroup.server, room = openGroup.room, publicKey = openGroup.serverPublicKey, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 0dfaeb6d07..61017dbff7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -28,6 +28,7 @@ class ConversationActionModeCallback( private val threadID: Long, private val context: Context, private val deprecationManager: LegacyGroupDeprecationManager, + private val openGroupManager: OpenGroupManager, ) : ActionMode.Callback { var delegate: ConversationActionModeCallbackDelegate? = null @@ -76,7 +77,11 @@ class ConversationActionModeCallback( if (anySentByCurrentUser) { return false } // Users can't ban themselves val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet() if (selectedUsers.size > 1) { return false } - return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) + return openGroupManager.isUserModerator( + openGroup.groupId, + userPublicKey, + blindedPublicKey + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 02750daaa5..c24520b0fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -87,6 +87,7 @@ class VisibleMessageView : FrameLayout { @Inject lateinit var dateUtils: DateUtils @Inject lateinit var configFactory: ConfigFactoryProtocol @Inject lateinit var usernameUtils: UsernameUtils + @Inject lateinit var openGroupManager: OpenGroupManager private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true) @@ -229,7 +230,11 @@ class VisibleMessageView : FrameLayout { } else { standardPublicKey = senderAccountID } - val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey) + val isModerator = openGroupManager.isUserModerator( + openGroup.groupId, + standardPublicKey, + blindedPublicKey + ) binding.moderatorIconImageView.isVisible = isModerator } else if (thread.isLegacyGroupRecipient) { // legacy groups diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index fc2e49eb97..8f8f2cfb59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -94,6 +94,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val prefs: TextSecurePreferences, private val lokiThreadDatabase: LokiThreadDatabase, private val groupManager: GroupManagerV2, + private val openGroupManager: OpenGroupManager, ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow( @@ -497,7 +498,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( serverPubKey = Hex.fromStringCondensed(it), )?.pubKey?.data } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString - return OpenGroupManager.isUserModerator(context, community!!.id, userPublicKey, blindedPublicKey) + return openGroupManager.isUserModerator(community!!.id, userPublicKey, blindedPublicKey) } } @@ -717,7 +718,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( withContext(Dispatchers.Default) { val community = lokiThreadDatabase.getOpenGroupChat(threadId) if (community != null) { - OpenGroupManager.delete(community.server, community.room, context) + openGroupManager.delete(community.server, community.room, context) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 654217d4ad..8b4e859056 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -36,7 +36,7 @@ /** * @deprecated This database table management is only used for - * legacy group management. It is not used in groupv2. For group v2 data, you generally need + * legacy group and community management. It is not used in groupv2. For group v2 data, you generally need * to query config system directly. The Storage class may also be more up-to-date. * */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 8d3c0d05e4..d25d0cfdd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED @@ -44,7 +45,6 @@ import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage @@ -129,7 +129,8 @@ open class Storage @Inject constructor( private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, private val clock: SnodeClock, private val preferences: TextSecurePreferences, - private val usernameUtils: UsernameUtils + private val usernameUtils: UsernameUtils, + private val openGroupManager: Lazy, ) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { init { @@ -1055,19 +1056,18 @@ open class Storage @Inject constructor( } override fun updateOpenGroup(openGroup: OpenGroup) { - OpenGroupManager.updateOpenGroup(openGroup, context) + openGroupManager.get().updateOpenGroup(openGroup, context) } override fun getAllGroups(includeInactive: Boolean): List { return groupDatabase.getAllGroups(includeInactive) } - override suspend fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { - return OpenGroupManager.addOpenGroup(urlAsString, context) + override suspend fun addOpenGroup(urlAsString: String) { + return openGroupManager.get().addOpenGroup(urlAsString, context) } override fun onOpenGroupAdded(server: String, room: String) { - OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) configFactory.withMutableUserConfigs { configs -> val groups = configs.userGroups val volatileConfig = configs.convoInfoVolatile diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 06680901e2..4737435e27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -10,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.SSKEnvironment @@ -32,9 +33,10 @@ class AppModule { @Provides @Singleton fun provideMessageNotifier( - avatarUtils: AvatarUtils + avatarUtils: AvatarUtils, + openGroupPollerManager: OpenGroupPollerManager, ): MessageNotifier { - return OptimizedMessageNotifier(DefaultMessageNotifier(avatarUtils)) + return OptimizedMessageNotifier(DefaultMessageNotifier(avatarUtils), openGroupPollerManager) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 153c67b3da..b1a0c5d9fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -63,7 +63,6 @@ public static long getThreadIDFromGroupID(String groupID, @NonNull Context cont long threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor( groupRecipient, DistributionTypes.CONVERSATION); - DatabaseComponent.get(context).threadDatabase().setThreadArchived(threadID); return new GroupActionResult(groupRecipient, threadID); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index 8590372cf4..86c3ccf372 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -12,7 +12,6 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.tabs.TabLayoutMediator -import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -29,7 +28,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject @AndroidEntryPoint class JoinCommunityFragment : Fragment() { @@ -40,6 +39,9 @@ class JoinCommunityFragment : Fragment() { var lastUrl: String? = null + @Inject + lateinit var openGroupManager: OpenGroupManager + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -104,7 +106,7 @@ class JoinCommunityFragment : Fragment() { try { val sanitizedServer = openGroup.server.removeSuffix("/") val openGroupID = "$sanitizedServer.${openGroup.room}" - OpenGroupManager.add( + openGroupManager.add( sanitizedServer, openGroup.room, openGroup.serverPublicKey, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 2ae7e83fa7..e8000318c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -2,86 +2,51 @@ package org.thoughtcrime.securesms.groups import android.content.Context import android.widget.Toast -import androidx.annotation.WorkerThread import com.squareup.phrase.Phrase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.Executors import network.loki.messenger.R import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object OpenGroupManager { - private val executorService = Executors.newScheduledThreadPool(4) - private val pollers = mutableMapOf() // One for each server - private var isPolling = false - private val pollUpdaterLock = Any() - - val isAllCaughtUp: Boolean - get() { - pollers.values.forEach { poller -> - val jobID = poller.secondToLastJob?.id - jobID?.let { - val storage = MessagingModuleConfiguration.shared.storage - if (storage.getMessageReceiveJob(jobID) == null) { - // If the second to last job is done, it means we are now handling the last job - poller.isCaughtUp = true - poller.secondToLastJob = null - } - } - if (!poller.isCaughtUp) { return false } - } - return true - } +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage common operations for open groups, such as adding, deleting, and updating them. + */ +@Singleton +class OpenGroupManager @Inject constructor( + private val storage: StorageProtocol, + private val lokiThreadDB: LokiThreadDatabase, + private val threadDb: ThreadDatabase, + private val configFactory: ConfigFactoryProtocol, + private val groupMemberDatabase: GroupMemberDatabase, +) { // flow holding information on write access for our current communities private val _communityWriteAccess: MutableStateFlow> = MutableStateFlow(emptyMap()) - fun startPolling() { - if (isPolling) { return } - isPolling = true - val storage = MessagingModuleConfiguration.shared.storage - val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null } - toDelete.forEach { openGroup -> - Log.w("Loki", "Need to delete a group") - delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context) - } - - val servers = serverGroups.map { it.server }.toSet() - synchronized(pollUpdaterLock) { - servers.forEach { server -> - pollers[server]?.stop() // Shouldn't be necessary - pollers[server] = OpenGroupPoller(server, executorService).apply { startIfNeeded() } - } - } - } - - fun stopPolling() { - synchronized(pollUpdaterLock) { - pollers.forEach { it.value.stop() } - pollers.clear() - isPolling = false - } - } fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow() - @WorkerThread - suspend fun add(server: String, room: String, publicKey: String, context: Context): Pair { + suspend fun add(server: String, room: String, publicKey: String, context: Context) { val openGroupID = "$server.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) - val storage = MessagingModuleConfiguration.shared.storage - val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() // Check it it's added already - val existingOpenGroup = threadDB.getOpenGroupChat(threadID) - if (existingOpenGroup != null) { return threadID to null } + val existingOpenGroup = lokiThreadDB.getOpenGroupChat(threadID) + if (existingOpenGroup != null) { + return + } // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -96,48 +61,23 @@ object OpenGroupManager { if (threadID < 0) { GroupManager.createOpenGroup(openGroupID, context, null, info.name) } + OpenGroupPoller.handleRoomPollInfo( + storage = storage, server = server, roomToken = room, pollInfo = info.toPollInfo(), createGroupIfMissingWithPublicKey = publicKey ) - return threadID to info - } - - fun restartPollerForServer(server: String) { - // Start the poller if needed - synchronized(pollUpdaterLock) { - pollers[server]?.stop() - pollers[server]?.startIfNeeded() ?: run { - val poller = OpenGroupPoller(server, executorService) - Log.d("Loki", "Starting poller for open group: $server") - pollers[server] = poller - poller.startIfNeeded() - } - } } - @WorkerThread fun delete(server: String, room: String, context: Context) { try { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory - val threadDB = DatabaseComponent.get(context).threadDatabase() val openGroupID = "${server.removeSuffix("/")}.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) - val recipient = threadDB.getRecipientForThreadId(threadID) ?: return - threadDB.setThreadArchived(threadID) + val recipient = threadDb.getRecipientForThreadId(threadID) ?: return val groupID = recipient.address.toString() // Stop the poller if needed - val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.isNotEmpty()) { - synchronized(pollUpdaterLock) { - val poller = pollers[server] - poller?.stop() - pollers.remove(server) - } - } configFactory.withMutableUserConfigs { it.userGroups.eraseCommunity(server, room) it.convoInfoVolatile.eraseCommunity(server, room) @@ -147,7 +87,6 @@ object OpenGroupManager { storage.removeLastMessageServerID(room, server) storage.removeLastInboxMessageId(server) storage.removeLastOutboxMessageId(server) - val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) storage.deleteConversation(threadID) // Must be invoked on a background thread GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread @@ -160,20 +99,18 @@ object OpenGroupManager { } } - @WorkerThread - suspend fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { - val url = urlAsString.toHttpUrlOrNull() ?: return null + suspend fun addOpenGroup(urlAsString: String, context: Context) { + val url = urlAsString.toHttpUrlOrNull() ?: return val server = OpenGroup.getServer(urlAsString) - val room = url.pathSegments.firstOrNull() ?: return null - val publicKey = url.queryParameter("public_key") ?: return null + val room = url.pathSegments.firstOrNull() ?: return + val publicKey = url.queryParameter("public_key") ?: return - return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function + add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function } fun updateOpenGroup(openGroup: OpenGroup, context: Context) { - val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadID = GroupManager.getOpenGroupThreadID(openGroup.groupId, context) - threadDB.setOpenGroupChat(openGroup, threadID) + lokiThreadDB.setOpenGroupChat(openGroup, threadID) // update write access for this community val writeAccesses = _communityWriteAccess.value.toMutableMap() @@ -181,11 +118,13 @@ object OpenGroupManager { _communityWriteAccess.value = writeAccesses } - fun isUserModerator(context: Context, groupId: String, standardPublicKey: String, blindedPublicKey: String? = null): Boolean { - val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() - val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) - val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() + fun isUserModerator( + groupId: String, + standardPublicKey: String, + blindedPublicKey: String? = null + ): Boolean { + val standardRoles = groupMemberDatabase.getGroupMemberRoles(groupId, standardPublicKey) + val blindedRoles = blindedPublicKey?.let { groupMemberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator } } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 5bd30f024f..5b60feb0e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -123,6 +123,7 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var clock: SnodeClock @Inject lateinit var messageNotifier: MessageNotifier @Inject lateinit var dateUtils: DateUtils + @Inject lateinit var openGroupManager: OpenGroupManager private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -282,7 +283,6 @@ class HomeActivity : ScreenLockActionBarActivity(), // update things based on TextSecurePrefs (profile info etc) // Set up remaining components if needed if (textSecurePreferences.getLocalNumber() != null) { - OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() } @@ -718,7 +718,7 @@ class HomeActivity : ScreenLockActionBarActivity(), // Delete the conversation val community = lokiThreadDatabase.getOpenGroupChat(threadID) if (community != null) { - OpenGroupManager.delete(community.server, community.room, context) + openGroupManager.delete(community.server, community.room, context) } else { lifecycleScope.launch(Dispatchers.Default) { storage.deleteConversation(threadID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 208c05c955..93c236e535 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -23,6 +23,7 @@ import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.Log @@ -37,8 +38,8 @@ class BackgroundPollWorker @AssistedInject constructor( @Assisted params: WorkerParameters, private val storage: StorageProtocol, private val deprecationManager: LegacyGroupDeprecationManager, - private val lokiThreadDatabase: LokiThreadDatabase, private val groupPollerManager: GroupPollerManager, + private val openGroupPollerManager: OpenGroupPollerManager, ) : CoroutineWorker(context, params) { enum class Target { ONE_TO_ONE, @@ -133,17 +134,10 @@ class BackgroundPollWorker @AssistedInject constructor( // Open groups if (requestTargets.contains(Target.OPEN_GROUPS)) { - lokiThreadDatabase.getAllOpenGroups() - .mapTo(hashSetOf()) { it.value.server } - .mapTo(tasks) { server -> - async { - Log.d(TAG, "Polling open group server $server.") - OpenGroupPoller(server, null) - .apply { hasStarted = true } - .poll() - .await() - } - } + tasks += async { + Log.d(TAG, "Polling open groups.") + openGroupPollerManager.pollAllOpenGroupsOnce() + } } // Close group diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index 5f712e3210..0332a5d40a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -3,16 +3,15 @@ import android.content.Context; import android.os.Looper; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager; import org.session.libsession.messaging.sending_receiving.pollers.Poller; -import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.Debouncer; +import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.ThreadUtils; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.groups.OpenGroupManager; import java.util.concurrent.TimeUnit; @@ -22,10 +21,12 @@ public class OptimizedMessageNotifier implements MessageNotifier { private final MessageNotifier wrapped; private final Debouncer debouncer; - @MainThread - public OptimizedMessageNotifier(@NonNull MessageNotifier wrapped) { + private final OpenGroupPollerManager openGroupPollerManager; + + public OptimizedMessageNotifier(@NonNull MessageNotifier wrapped, OpenGroupPollerManager openGroupPollerManager) { this.wrapped = wrapped; - this.debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(2)); + this.openGroupPollerManager = openGroupPollerManager; + this.debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(2)); } @Override @@ -55,7 +56,7 @@ public void updateNotification(@NonNull Context context) { isCaughtUp = isCaughtUp && !poller.isPolling(); } - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context)); @@ -72,7 +73,7 @@ public void updateNotification(@NonNull Context context, long threadId) { isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); } - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, threadId)); @@ -89,7 +90,7 @@ public void updateNotification(@NonNull Context context, long threadId, boolean isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); } - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, threadId, signal)); @@ -106,7 +107,7 @@ public void updateNotification(@androidx.annotation.NonNull Context context, boo isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); } - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, signal, reminderCount)); diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index c1e49ff3b9..97f12c2821 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -90,7 +90,10 @@ class ConversationViewModelTest: BaseViewModelTest() { avatarUtils = avatarUtils, lokiAPIDb = mock(), recipientChangeSource = NoopRecipientChangeSource, - dateUtils = mock() + dateUtils = mock(), + openGroupManager = mock { + on { getCommunitiesWriteAccessFlow() } doReturn MutableStateFlow(emptyMap()) + } ) } From 89f4ee82c15573fc1f28d99239d0c1439e5a626f Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 29 May 2025 11:06:08 +1000 Subject: [PATCH 364/867] [SES-383] - Fix voice message duration issue (#1200) --- .../libsession/database/StorageProtocol.kt | 4 - .../messaging/jobs/AttachmentUploadJob.kt | 6 +- .../attachments/Attachment.java | 126 +++++++---- .../attachments/DatabaseAttachment.java | 6 +- .../attachments/PointerAttachment.java | 38 +--- .../attachments/UriAttachment.java | 14 +- .../MmsNotificationAttachment.java | 36 ---- .../securesms/audio/AudioCodec.java | 193 ----------------- .../securesms/audio/AudioRecorder.java | 111 ---------- .../securesms/audio/AudioRecorder.kt | 199 ++++++++++++++++++ .../conversation/v2/ConversationActivityV2.kt | 167 ++++++++------- .../v2/messages/VoiceMessageView.kt | 40 ++-- .../v2/utilities/AttachmentManager.java | 5 +- .../database/AttachmentDatabase.java | 96 ++------- .../securesms/database/MediaDatabase.java | 1 + .../securesms/database/MmsDatabase.kt | 15 +- .../securesms/database/MmsSmsDatabase.java | 1 + .../securesms/database/SmsDatabase.java | 11 +- .../securesms/database/Storage.kt | 20 +- .../thoughtcrime/securesms/mms/AudioSlide.kt | 12 +- .../org/thoughtcrime/securesms/mms/Slide.kt | 7 +- .../AndroidAutoReplyReceiver.java | 4 +- .../notifications/RemoteReplyReceiver.java | 4 +- .../securesms/providers/BlobProvider.java | 13 +- .../webrtc/audio/SignalAudioManager.kt | 6 +- .../main/res/layout/view_voice_message.xml | 6 +- 26 files changed, 480 insertions(+), 661 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 98f856d8c8..0d7df53089 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -114,10 +114,6 @@ interface StorageProtocol { fun getReceivedMessageTimestamps(): Set fun addReceivedMessageTimestamp(timestamp: Long) fun removeReceivedMessageTimestamps(timestamps: Set) - /** - * Returns the IDs of the saved attachments. - */ - fun persistAttachments(messageID: Long, attachments: List): List fun getAttachmentsForMessage(mmsMessageId: Long): List fun getMessageBy(timestamp: Long, author: String): MessageRecord? fun updateSentTimestamp(messageId: MessageId, openGroupSentTimestamp: Long, threadId: Long) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 88d9d5dd34..373d3274d9 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -116,15 +116,13 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider messageDataProvider.handleSuccessfulAttachmentUpload(attachmentID, attachment, attachmentKey, uploadResult) - // Outgoing voice messages do not have their final duration set because older Android versions (API 28 and below) - // can have bugs where the media duration is calculated incorrectly. In such cases we leave the correct "interim" - // voice message duration as the final duration as we know that it'll be correct.. + // We don't need to calculate the duration for voice notes, as they will have it set already. if (attachment.contentType.startsWith("audio/") && !attachment.voiceNote) { - // ..but for outgoing audio files we do process the duration to the best of our ability. try { val inputStream = messageDataProvider.getAttachmentStream(attachmentID)!!.inputStream!! InputStreamMediaDataSource(inputStream).use { mediaDataSource -> val durationMS = (DecodedAudio.create(mediaDataSource).totalDurationMicroseconds / 1000.0).toLong() + Log.d(TAG, "Audio attachment duration calculated as: $durationMS ms") messageDataProvider.getDatabaseAttachment(attachmentID)?.attachmentId?.let { attachmentId -> messageDataProvider.updateAudioAttachmentDuration(attachmentId, durationMS, threadID.toLong()) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java index c9438bb7cf..e0cf2c28d6 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java @@ -6,43 +6,53 @@ public abstract class Attachment { - @NonNull private final String contentType; + @NonNull + private final String contentType; private final int transferState; private final long size; private final String filename; - @Nullable private final String location; - @Nullable private final String key; - @Nullable private final String relay; - @Nullable private final byte[] digest; - @Nullable private final String fastPreflightId; + @Nullable + private final String location; + @Nullable + private final String key; + @Nullable + private final String relay; + @Nullable + private final byte[] digest; + @Nullable + private final String fastPreflightId; private final boolean voiceNote; private final int width; private final int height; private final boolean quote; - @Nullable private final String caption; + @Nullable + private final String caption; private final String url; + private final long audioDurationMs; + public Attachment(@NonNull String contentType, int transferState, long size, String filename, @Nullable String location, @Nullable String key, @Nullable String relay, @Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote, - int width, int height, boolean quote, @Nullable String caption, String url) - { - this.contentType = contentType; - this.transferState = transferState; - this.size = size; - this.filename = filename; - this.location = location; - this.key = key; - this.relay = relay; - this.digest = digest; + int width, int height, boolean quote, @Nullable String caption, String url, + long audioDurationMs) { + this.contentType = contentType; + this.transferState = transferState; + this.size = size; + this.filename = filename; + this.location = location; + this.key = key; + this.relay = relay; + this.digest = digest; this.fastPreflightId = fastPreflightId; - this.voiceNote = voiceNote; - this.width = width; - this.height = height; - this.quote = quote; - this.caption = caption; - this.url = url; + this.voiceNote = voiceNote; + this.width = width; + this.height = height; + this.quote = quote; + this.caption = caption; + this.url = url; + this.audioDurationMs = audioDurationMs; } @Nullable @@ -51,7 +61,9 @@ public Attachment(@NonNull String contentType, int transferState, long size, Str @Nullable public abstract Uri getThumbnailUri(); - public int getTransferState() { return transferState; } + public int getTransferState() { + return transferState; + } public boolean isInProgress() { return transferState == AttachmentState.DOWNLOADING.getValue(); @@ -65,37 +77,75 @@ public boolean isFailed() { return transferState == AttachmentState.FAILED.getValue(); } - public long getSize() { return size; } + public long getSize() { + return size; + } - public String getFilename() { return filename; } + public String getFilename() { + return filename; + } @NonNull - public String getContentType() { return contentType; } + public String getContentType() { + return contentType; + } @Nullable - public String getLocation() { return location; } + public String getLocation() { + return location; + } @Nullable - public String getKey() { return key; } + public String getKey() { + return key; + } @Nullable - public String getRelay() { return relay; } + public String getRelay() { + return relay; + } @Nullable - public byte[] getDigest() { return digest; } + public byte[] getDigest() { + return digest; + } @Nullable - public String getFastPreflightId() { return fastPreflightId; } + public String getFastPreflightId() { + return fastPreflightId; + } + + public boolean isVoiceNote() { + return voiceNote; + } - public boolean isVoiceNote() { return voiceNote; } + public int getWidth() { + return width; + } - public int getWidth() { return width; } + public int getHeight() { + return height; + } - public int getHeight() { return height; } + public boolean isQuote() { + return quote; + } - public boolean isQuote() { return quote; } + public @Nullable String getCaption() { + return caption; + } - public @Nullable String getCaption() { return caption; } + public String getUrl() { + return url; + } - public String getUrl() { return url; } + /** + * Returns the duration of the audio in milliseconds. + * This is only relevant for audio attachments. + * + * Returns -1 if the information is not available. + */ + public long getAudioDurationMs() { + return audioDurationMs; + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java index 8f8abfc169..de6de160a5 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java @@ -1,7 +1,9 @@ package org.session.libsession.messaging.sending_receiving.attachments; import android.net.Uri; + import androidx.annotation.Nullable; + import org.session.libsession.messaging.MessagingModuleConfiguration; public class DatabaseAttachment extends Attachment { @@ -18,9 +20,9 @@ public DatabaseAttachment(AttachmentId attachmentId, long mmsId, String filename, String location, String key, String relay, byte[] digest, String fastPreflightId, boolean voiceNote, int width, int height, boolean quote, @Nullable String caption, - String url + String url, long audioDurationMs ) { - super(contentType, transferProgress, size, filename, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, caption, url); + super(contentType, transferProgress, size, filename, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, caption, url, audioDurationMs); this.attachmentId = attachmentId; this.hasData = hasData; this.hasThumbnail = hasThumbnail; diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java index 662795ddf0..8d0d5b50e0 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java @@ -7,7 +7,6 @@ import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.messages.SignalServiceAttachment; -import org.session.libsignal.messages.SignalServiceDataMessage; import org.session.libsignal.utilities.Base64; import org.session.libsignal.protos.SignalServiceProtos; @@ -22,7 +21,7 @@ private PointerAttachment(@NonNull String contentType, int transferState, long s @Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote, int width, int height, @Nullable String caption, String url) { - super(contentType, transferState, size, fileName, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, caption, url); + super(contentType, transferState, size, fileName, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, caption, url, -1L); } @Nullable @@ -54,22 +53,6 @@ public static List forPointers(Optional forPointersOfDataMessage(List pointers) { - List results = new LinkedList<>(); - - if (pointers != null) { - for (SignalServiceDataMessage.Quote.QuotedAttachment pointer : pointers) { - Optional result = forPointer(pointer); - - if (result.isPresent()) { - results.add(result.get()); - } - } - } - - return results; - } - public static List forPointers(List pointers) { List results = new LinkedList<>(); @@ -151,25 +134,6 @@ public static Optional forPointer(SignalServiceProtos.DataMessage.Qu thumbnail != null ? thumbnail.getUrl() : "")); } - public static Optional forPointer(SignalServiceDataMessage.Quote.QuotedAttachment pointer) { - SignalServiceAttachment thumbnail = pointer.getThumbnail(); - - return Optional.of(new PointerAttachment(pointer.getContentType(), - AttachmentState.PENDING.getValue(), - thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0, - pointer.getFileName(), - String.valueOf(thumbnail != null ? thumbnail.asPointer().getId() : 0), - thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null, - null, - thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null, - null, - false, - thumbnail != null ? thumbnail.asPointer().getWidth() : 0, - thumbnail != null ? thumbnail.asPointer().getHeight() : 0, - thumbnail != null ? thumbnail.asPointer().getCaption().orNull() : null, - thumbnail != null ? thumbnail.asPointer().getUrl() : "")); - } - /** * Converts a Session Attachment to a Signal Attachment * @param attachment Session Attachment diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java index b87210faad..1aeb3f86a1 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java @@ -13,15 +13,23 @@ public class UriAttachment extends Attachment { public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size, @Nullable String fileName, boolean voiceNote, boolean quote, @Nullable String caption) { - this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption); + this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, -1); } public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri, @NonNull String contentType, int transferState, long size, int width, int height, @Nullable String fileName, @Nullable String fastPreflightId, - boolean voiceNote, boolean quote, @Nullable String caption) + boolean voiceNote, boolean quote, @Nullable String caption) { + this(dataUri, thumbnailUri, contentType, transferState, size, width, height, fileName, fastPreflightId, + voiceNote, quote, caption, -1); + } + + public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri, + @NonNull String contentType, int transferState, long size, int width, int height, + @Nullable String fileName, @Nullable String fastPreflightId, + boolean voiceNote, boolean quote, @Nullable String caption, long audioDurationMs) { - super(contentType, transferState, size, fileName, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, caption, ""); + super(contentType, transferState, size, fileName, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, caption, "", audioDurationMs); this.dataUri = dataUri; this.thumbnailUri = thumbnailUri; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java deleted file mode 100644 index 0124a3d17f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.attachments; - -import android.net.Uri; - -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState; -import org.thoughtcrime.securesms.database.MmsDatabase; - -public class MmsNotificationAttachment extends Attachment { - - public MmsNotificationAttachment(int status, long size) { - super("application/mms", getTransferStateFromStatus(status).getValue(), size, null, null, null, null, null, null, false, 0, 0, false, null, ""); - } - - @Nullable - @Override - public Uri getDataUri() { return null; } - - @Nullable - @Override - public Uri getThumbnailUri() { return null; } - - private static AttachmentState getTransferStateFromStatus(int status) { - if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED || - status == MmsDatabase.Status.DOWNLOAD_NO_CONNECTIVITY) - { - return AttachmentState.PENDING; - } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) { - return AttachmentState.DOWNLOADING; - } else { - return AttachmentState.FAILED; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java deleted file mode 100644 index 64c7ac3df2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.thoughtcrime.securesms.audio; - -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.MediaCodec; -import android.media.MediaCodecInfo; -import android.media.MediaFormat; -import android.media.MediaRecorder; - -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.ByteBuffer; - -public class AudioCodec { - - private static final String TAG = AudioCodec.class.getSimpleName(); - - private static final int SAMPLE_RATE = 44100; - private static final int SAMPLE_RATE_INDEX = 4; - private static final int CHANNELS = 1; - private static final int BIT_RATE = 32000; - - private final int bufferSize; - private final MediaCodec mediaCodec; - private final AudioRecord audioRecord; - - private boolean running = true; - private boolean finished = false; - - public AudioCodec() throws IOException { - this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); - this.mediaCodec = createMediaCodec(this.bufferSize); - try { - this.audioRecord = createAudioRecord(this.bufferSize); - this.mediaCodec.start(); - audioRecord.startRecording(); - } catch (Exception e) { - Log.w(TAG, e); - mediaCodec.release(); - throw new IOException(e); - } - } - - public synchronized void stop() { - running = false; - while (!finished) Util.wait(this, 0); - } - - public void start(final OutputStream outputStream) { - new Thread(new Runnable() { - @Override - public void run() { - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - byte[] audioRecordData = new byte[bufferSize]; - ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers(); - ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers(); - - try { - while (true) { - boolean running = isRunning(); - - handleCodecInput(audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running); - handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream); - - if (!running) break; - } - } catch (IOException e) { - Log.w(TAG, e); - } finally { - mediaCodec.stop(); - audioRecord.stop(); - - mediaCodec.release(); - audioRecord.release(); - - Util.close(outputStream); - setFinished(); - } - } - }, AudioCodec.class.getSimpleName()).start(); - } - - private synchronized boolean isRunning() { - return running; - } - - private synchronized void setFinished() { - finished = true; - notifyAll(); - } - - private void handleCodecInput(AudioRecord audioRecord, byte[] audioRecordData, - MediaCodec mediaCodec, ByteBuffer[] codecInputBuffers, - boolean running) - { - int length = audioRecord.read(audioRecordData, 0, audioRecordData.length); - int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000); - - if (codecInputBufferIndex >= 0) { - ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex]; - codecBuffer.clear(); - codecBuffer.put(audioRecordData); - mediaCodec.queueInputBuffer(codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); - } - } - - private void handleCodecOutput(MediaCodec mediaCodec, - ByteBuffer[] codecOutputBuffers, - MediaCodec.BufferInfo bufferInfo, - OutputStream outputStream) - throws IOException - { - int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); - - while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { - if (codecOutputBufferIndex >= 0) { - ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex]; - - encoderOutputBuffer.position(bufferInfo.offset); - encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size); - - if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) { - byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset); - - - outputStream.write(header); - - byte[] data = new byte[encoderOutputBuffer.remaining()]; - encoderOutputBuffer.get(data); - outputStream.write(data); - } - - encoderOutputBuffer.clear(); - - mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); - } else if (codecOutputBufferIndex== MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { - codecOutputBuffers = mediaCodec.getOutputBuffers(); - } - - codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); - } - - } - - private byte[] createAdtsHeader(int length) { - int frameLength = length + 7; - byte[] adtsHeader = new byte[7]; - - adtsHeader[0] = (byte) 0xFF; // Sync Word - adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC - adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6); - adtsHeader[2] |= (((byte) SAMPLE_RATE_INDEX) << 2); - adtsHeader[2] |= (((byte) CHANNELS) >> 2); - adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03)); - adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF); - adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f); - adtsHeader[6] = (byte) 0xFC; - - return adtsHeader; - } - - private AudioRecord createAudioRecord(int bufferSize) throws SecurityException { - return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); - } - - private MediaCodec createMediaCodec(int bufferSize) throws IOException { - MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm"); - MediaFormat mediaFormat = new MediaFormat(); - - mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm"); - mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); - mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); - mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize); - mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE); - mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); - - try { - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); - } catch (Exception e) { - Log.w(TAG, e); - mediaCodec.release(); - throw new IOException(e); - } - - return mediaCodec; - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java deleted file mode 100644 index 1eb663c3d6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.thoughtcrime.securesms.audio; - -import android.content.Context; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Pair; -import androidx.annotation.NonNull; -import java.io.IOException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; - -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.SettableFuture; -import org.session.libsignal.utilities.ThreadUtils; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; - -public class AudioRecorder { - - private static final String TAG = AudioRecorder.class.getSimpleName(); - - private static final ExecutorService executor = ThreadUtils.newDynamicSingleThreadedExecutor(); - - private final Context context; - - private AudioCodec audioCodec; - private Future blobWritingTask; - - // Simple interface that allows us to provide a callback method to our `startRecording` method - public interface AudioMessageRecordingFinishedCallback { - void onAudioMessageRecordingFinished(); - } - - public AudioRecorder(@NonNull Context context) { - this.context = context; - } - - public void startRecording(AudioMessageRecordingFinishedCallback callback) { - Log.i(TAG, "startRecording()"); - - executor.execute(() -> { - Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId()); - try { - if (audioCodec != null) { - Log.e(TAG, "Trying to start recording while another recording is in progress, exiting..."); - return; - } - - ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); - - blobWritingTask = BlobProvider.getInstance() - .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) - .withMimeType(MediaTypes.AUDIO_AAC) - .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); - - audioCodec = new AudioCodec(); - audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); - - callback.onAudioMessageRecordingFinished(); - } catch (IOException e) { - Log.w(TAG, e); - } - }); - } - - public @NonNull ListenableFuture> stopRecording(boolean voiceMessageMeetsMinimumDuration) { - Log.i(TAG, "stopRecording()"); - - final SettableFuture> future = new SettableFuture<>(); - - executor.execute(() -> { - if (audioCodec == null || blobWritingTask == null) { - sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!")); - return; - } - - audioCodec.stop(); - try { - final Uri captureUri = blobWritingTask.get(); - long size = 0L; - // Only obtain the media size if the voice message was at least our minimum allowed - // duration (bypassing this work prevents the audio recording mechanism from getting into - // a broken state should the user rapidly spam the record button for several seconds). - if (voiceMessageMeetsMinimumDuration) { - size = MediaUtil.getMediaSize(context, captureUri); - } - sendToFuture(future, new Pair<>(captureUri, size)); - } catch (IOException | ExecutionException | InterruptedException e) { - Log.w(TAG, e); - sendToFuture(future, e); - } - - audioCodec = null; - blobWritingTask = null; - }); - - return future; - } - - private void sendToFuture(final SettableFuture future, final Exception exception) { - Util.runOnMain(() -> future.setException(exception)); - } - - private void sendToFuture(final SettableFuture future, final T result) { - Util.runOnMain(() -> future.set(result)); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt new file mode 100644 index 0000000000..91c469d027 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.SystemClock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.session.libsignal.utilities.Log +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "AudioRecorder" + +data class AudioRecordResult( + val file: File, + val length: Long, + val duration: Duration +) + + +class AudioRecorderHandle( + private val onStopCommand: suspend () -> Unit, + private val deferred: Deferred>, + private val startedResult: SharedFlow>, +) { + + private val listenerScope = CoroutineScope(Dispatchers.Main) + + /** + * Add a listener that will be called on main thread, when the recording has started. + * + * Note that after stop/cancel is called, this listener will not be called again. + */ + fun addOnStartedListener(onStartedResult: (Result) -> Unit) { + listenerScope.launch { + startedResult.collectLatest { result -> + onStartedResult(result) + } + } + } + + /** + * Stop the recording process and return the result. Note that if there's error + * during the recording, this method will throw an exception. + */ + suspend fun stop(): AudioRecordResult { + listenerScope.cancel() + onStopCommand() + return deferred.await().getOrThrow() + } + + /** + * Cancel the recording process and discard any result. + * + * The cancellation is best effort only. When the method returns, there's no + * guarantee that the recording has been stopped. But it's guaranteed that if you + * spin up a new recording immediately after calling this method, the new recording session + * won't start until the old one is properly cleaned up. + */ + @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) + fun cancel() { + listenerScope.cancel() + deferred.cancel() + + if (deferred.isCompleted && deferred.getCompleted().isSuccess) { + // Clean up the temporary file if the recording was completed while we were cancelling. + GlobalScope.launch { + deferred.getCompleted().getOrThrow().file.delete() + } + } + } +} + +private sealed interface RecorderCommand { + data object Stop : RecorderCommand + data class ErrorReceived(val error: Throwable) : RecorderCommand +} + +// There can only be on instance of MediaRecorder running at a time, we use a coroutine Mutex to ensure only +// one coroutine can access the MediaRecorder at a time. +private val mediaRecorderMutex = Mutex() + +/** + * Start recording audio. THe recording will be bound to the lifecycle of the coroutine scope. + * + * To stop recording and grab the result, call [AudioRecorderHandle.stop] + */ +fun recordAudio( + scope: CoroutineScope, + context: Context, +): AudioRecorderHandle { + // Channel to send commands to the recorder coroutine. + val commandChannel = Channel(capacity = 1) + + // Channel to notify if the recording has started successfully. + val startResultChannel = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + + // Start the recording in a coroutine + val deferred = scope.async(Dispatchers.IO) { + runCatching { + mediaRecorderMutex.withLock { + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + var started = false + + try { + val file by lazy { + File.createTempFile("audio_recording_", ".m4a", context.cacheDir) + } + + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setAudioChannels(1) + recorder.setAudioSamplingRate(44100) + recorder.setAudioEncodingBitRate(32000) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setOutputFile(file) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setOnErrorListener { _, what, extra -> + commandChannel.trySend( + RecorderCommand.ErrorReceived( + RuntimeException("MediaRecorder error: what=$what, extra=$extra") + ) + ) + } + + recorder.prepare() + recorder.start() + val recordingStarted = SystemClock.elapsedRealtime() + started = true + startResultChannel.emit(Result.success(Unit)) + + // Wait for either stop signal or error + when (val c = commandChannel.receive()) { + is RecorderCommand.Stop -> { + Log.d(TAG, "Received stop command, stopping recording.") + val duration = + (SystemClock.elapsedRealtime() - recordingStarted).milliseconds + recorder.stop() + + val length = file.length() + + return@runCatching AudioRecordResult( + file = file, + length = length, + duration = duration + ) + } + + is RecorderCommand.ErrorReceived -> { + Log.e(TAG, "Error received during recording: ${c.error.message}") + file.delete() + throw c.error + } + } + } catch (e: Exception) { + if (e is CancellationException) { + Log.d(TAG, "Recording cancelled by coroutine cancellation") + } else { + Log.e(TAG, "Error during audio recording", e) + } + + if (!started) { + startResultChannel.emit(Result.failure(e)) + } + throw e + } finally { + Log.d(TAG, "Releasing MediaRecorder resources") + recorder.release() + } + } + } + } + + return AudioRecorderHandle( + onStopCommand = { commandChannel.send(RecorderCommand.Stop) }, + deferred = deferred, + startedResult = startResultChannel + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 58f08aff08..a529f61dcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -59,6 +59,7 @@ import com.annimon.stream.Stream import com.bumptech.glide.Glide import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -115,7 +116,8 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver -import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.audio.AudioRecorderHandle +import org.thoughtcrime.securesms.audio.recordAudio import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity @@ -150,6 +152,7 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase @@ -210,6 +213,7 @@ import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity.Companion.ACTION_START_CALL import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge.Companion.EXTRA_RECIPIENT_ADDRESS +import java.io.File import java.lang.ref.WeakReference import java.util.LinkedList import java.util.Locale @@ -258,6 +262,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var typingStatusRepository: TypingStatusRepository @Inject lateinit var typingStatusSender: TypingStatusSender @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var attachmentDatabase: AttachmentDatabase override val applyDefaultWindowInsets: Boolean get() = false @@ -309,7 +314,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private var unreadCount = Int.MAX_VALUE // Attachments private var voiceMessageStartTimestamp: Long = 0L - private val audioRecorder = AudioRecorder(this) + private var audioRecorderHandle: AudioRecorderHandle? = null private val stopAudioHandler = Handler(Looper.getMainLooper()) private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() } private val attachmentManager by lazy { AttachmentManager(this, this) } @@ -2024,7 +2029,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, outgoingTextMessage, false, message.sentTimestamp!!, - null, true ), false) @@ -2040,7 +2044,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, attachments: List, body: String?, quotedMessage: MessageRecord? = binding.inputBar.quote, - linkPreview: LinkPreview? = null + linkPreview: LinkPreview? = null, + deleteAttachmentFilesAfterSave: Boolean = false, ): Pair? { if (viewModel.recipient == null) { Log.w(TAG, "Cannot send attachments to a null recipient") @@ -2091,9 +2096,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, outgoingTextMessage, viewModel.threadId, false, - null, runThreadUpdate = true - ), true) + ), mms = true) + + if (deleteAttachmentFilesAfterSave) { + attachments + .asSequence() + .mapNotNull { a -> a.dataUri?.takeIf { it.scheme == "file" }?.path?.let(::File) } + .filter { it.exists() } + .forEach { it.delete() } + } waitForApprovalJobToBeSubmitted() @@ -2225,26 +2237,33 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, binding.inputBar.voiceRecorderState = VoiceRecorderState.SettingUpToRecord if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { - showVoiceMessageUI() - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - - // Allow the caller (us!) to define what should happen when the voice recording finishes. - // Specifically in this instance, if we just tap the record audio button then by the time - // we actually finish setting up and get here the recording has been cancelled and the voice - // recorder state is Idle! As such we'll only tick the recorder state over to Recording if - // we were still in the SettingUpToRecord state when we got here (i.e., the record voice - // message button is still held or is locked to keep recording audio without being held). - val callback: () -> Unit = { - if (binding.inputBar.voiceRecorderState == VoiceRecorderState.SettingUpToRecord) { - binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording - } - } + // Cancel any previous recording attempt + audioRecorderHandle?.cancel() + audioRecorderHandle = null + + audioRecorderHandle = recordAudio(lifecycleScope, this@ConversationActivityV2).also { + it.addOnStartedListener { result -> + if (result.isSuccess) { + showVoiceMessageUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - voiceMessageStartTimestamp = System.currentTimeMillis() - audioRecorder.startRecording(callback) + voiceMessageStartTimestamp = System.currentTimeMillis() + binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording - // Limit voice messages to 5 minute each - stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 5.minutes.inWholeMilliseconds) + // Limit voice messages to 5 minute each + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 5.minutes.inWholeMilliseconds) + } else { + Log.e(TAG, "Error while starting voice message recording", result.exceptionOrNull()) + hideVoiceMessageUI() + binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle + Toast.makeText( + this@ConversationActivityV2, + R.string.audioUnableToRecord, + Toast.LENGTH_LONG + ).show() + } + } + } } else { binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle @@ -2257,82 +2276,80 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - override fun sendVoiceMessage() { - Log.i(TAG, "Sending voice message at: ${System.currentTimeMillis()}") - + private fun stopRecording(send: Boolean) { // When the record voice message button is released we always need to reset the UI and cancel // any further recording operation. hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - // How long was the voice message? Because the pointer up event could have been a regular - // hold-and-release or a release over the lock icon followed by a final tap to send so we - // update the voice message duration based on the current time here. - val voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartTimestamp + binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle - val voiceMessageMeetsMinimumDuration = MediaUtil.voiceMessageMeetsMinimumDuration(voiceMessageDurationMS) - val future = audioRecorder.stopRecording(voiceMessageMeetsMinimumDuration) + // Clear the audio session immediately for the next recording attempt + val handle = audioRecorderHandle + audioRecorderHandle = null stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) - binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle - - // Generate a filename from the current time such as: "Session-VoiceMessage_2025-01-08-152733.aac" - val voiceMessageFilename = FilenameUtils.constructNewVoiceMessageFilename(applicationContext) + if (handle == null) { + Log.w(TAG, "Audio recorder handle is null - cannot stop recording") + return + } - // Voice message too short? Warn with toast instead of sending. - // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity. - if (voiceMessageDurationMS != 0L && !voiceMessageMeetsMinimumDuration) { + if (!MediaUtil.voiceMessageMeetsMinimumDuration(System.currentTimeMillis() - voiceMessageStartTimestamp)) { + handle.cancel() + // If the voice message is too short, we show a toast and return early + Log.w(TAG, "Voice message is too short: ${System.currentTimeMillis() - voiceMessageStartTimestamp}ms") voiceNoteTooShortToast.setText(applicationContext.getString(R.string.messageVoiceErrorShort)) showVoiceMessageToastIfNotAlreadyVisible() return } - // Note: We could return here if there was a network or node path issue, but instead we'll try - // our best to send the voice message even if it might fail - because in that case it'll get put - // into the draft database and can be retried when we regain network connectivity and a working - // node path. + // If we don't send, we'll cancel the audio recording + if (!send) { + handle.cancel() + return + } - // Attempt to send it the voice message - future.addListener(object : ListenableFuture.Listener> { + // If we do send, we will stop the audio recording, wait for it to complete successfully, + // then send the audio message as an attachment. + lifecycleScope.launch { + try { + val result = handle.stop() - override fun onSuccess(result: Pair) { - val uri = result.first - val dataSizeBytes = result.second + // Generate a filename from the current time such as: "Session-VoiceMessage_2025-01-08-152733.aac" + val voiceMessageFilename = FilenameUtils.constructNewVoiceMessageFilename(applicationContext) - // Only proceed with sending the voice message if it's long enough - if (voiceMessageMeetsMinimumDuration) { - val formattedAudioDuration = MediaUtil.getFormattedVoiceMessageDuration(voiceMessageDurationMS) - val audioSlide = AudioSlide(this@ConversationActivityV2, uri, voiceMessageFilename, dataSizeBytes, MediaTypes.AUDIO_AAC, true, formattedAudioDuration) - val slideDeck = SlideDeck() - slideDeck.addSlide(audioSlide) - sendAttachments(slideDeck.asAttachments(), body = null) - } - } + val audioSlide = AudioSlide(this@ConversationActivityV2, + Uri.fromFile(result.file), + voiceMessageFilename, + result.length, + MediaTypes.AUDIO_AAC, + true, + result.duration.inWholeMilliseconds) - override fun onFailure(e: ExecutionException) { + val slideDeck = SlideDeck() + slideDeck.addSlide(audioSlide) + sendAttachments(slideDeck.asAttachments(), body = null, deleteAttachmentFilesAfterSave = true) + + } catch (ec: CancellationException) { + // If we get cancelled then do nothing + throw ec + } catch (ec: Exception) { + Log.e(TAG, "Error while recording", ec) Toast.makeText(this@ConversationActivityV2, R.string.audioUnableToRecord, Toast.LENGTH_LONG).show() } - }) + } + } + + override fun sendVoiceMessage() { + Log.i(TAG, "Sending voice message at: ${System.currentTimeMillis()}") + + stopRecording(true) } // Cancel voice message is called when the user is press-and-hold recording a voice message and then // slides the microphone icon left, or when they lock voice recording on but then later click Cancel. override fun cancelVoiceMessage() { - val voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartTimestamp - - hideVoiceMessageUI() - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - val voiceMessageMeetsMinimumDuration = MediaUtil.voiceMessageMeetsMinimumDuration(voiceMessageDurationMS) - audioRecorder.stopRecording(voiceMessageMeetsMinimumDuration) - stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) - - binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle - - // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity - if (voiceMessageDurationMS != 0L && !voiceMessageMeetsMinimumDuration) { - voiceNoteTooShortToast.setText(applicationContext.getString(R.string.messageVoiceErrorShort)) - showVoiceMessageToastIfNotAlreadyVisible() - } + stopRecording(false) } override fun selectMessages(messages: Set) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index f77d11a277..271ce60893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -56,11 +56,16 @@ class VoiceMessageView @JvmOverloads constructor( cornerMask.setBottomRightRadius(cornerRadii[2]) cornerMask.setBottomLeftRadius(cornerRadii[3]) - // In the case of transmitting a voice message we extract and set the interim upload duration from the audio slide's `caption` field. - // Note: The UriAttachment `caption` field was previously always null for AudioSlides, so there is no harm in re-using it in this way. - // In the case of uploaded audio files we do not have a duration until file processing is complete, in which case we set a reasonable - // placeholder value while we determine the duration of the uploaded audio. - binding.voiceMessageViewDurationTextView.text = if (audioSlide.caption.isPresent) audioSlide.caption.get().toString() else "--:--" + // This sets the final duration of the uploaded voice message + (audioSlide.asAttachment() as? DatabaseAttachment)?.let { attachment -> + if (attachment.audioDurationMs > 0) { + val formattedVoiceMessageDuration = MediaUtil.getFormattedVoiceMessageDuration(attachment.audioDurationMs) + binding.voiceMessageViewDurationTextView.text = formattedVoiceMessageDuration + } else { + Log.w(TAG, "For some reason attachment.audioDurationMs was NOT greater than zero!") + binding.voiceMessageViewDurationTextView.text = "--:--" + } + } // On initial upload (and while processing audio) we will exit at this point and then return when processing is complete if (audioSlide.isPendingDownload || audioSlide.isInProgress) { @@ -69,29 +74,13 @@ class VoiceMessageView @JvmOverloads constructor( } this.player = AudioSlidePlayer.createFor(context.applicationContext, audioSlide, this) + } - // This sets the final duration of the uploaded voice message - (audioSlide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - attachmentDb.getAttachmentAudioExtras(attachment.attachmentId)?.let { audioExtras -> - - // When audio processing is complete we set the final audio duration. For recorded voice - // messages this will be identical to our interim duration, but for uploaded audio files - // it will update the placeholder to the actual audio duration now that we know it. - if (audioExtras.durationMs > 0) { - durationMS = audioExtras.durationMs - val formattedVoiceMessageDuration = MediaUtil.getFormattedVoiceMessageDuration(durationMS) - binding.voiceMessageViewDurationTextView.text = formattedVoiceMessageDuration - } else { - Log.w(TAG, "For some reason audioExtras.durationMs was NOT greater than zero!") - binding.voiceMessageViewDurationTextView.text = "--:--" - } - - binding.voiceMessageViewDurationTextView.visibility = VISIBLE - } - } + override fun onPlayerStart(player: AudioSlidePlayer) { + isPlaying = true + durationMS = player.duration } - override fun onPlayerStart(player: AudioSlidePlayer) { isPlaying = true } override fun onPlayerStop(player: AudioSlidePlayer) { isPlaying = false } override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, unused: Long) { @@ -109,6 +98,7 @@ class VoiceMessageView @JvmOverloads constructor( // As playback progress increases the remaining duration of the audio decreases val remainingDurationMS = durationMS - (progress * durationMS.toDouble()).roundToLong() + binding.voiceMessageViewDurationTextView.text = MediaUtil.getFormattedVoiceMessageDuration(remainingDurationMS) val layoutParams = binding.progressView.layoutParams as RelativeLayout.LayoutParams diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index d616d2c236..6c15b68c5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -394,10 +394,7 @@ public enum MediaType { switch (this) { case IMAGE: return new ImageSlide(context, uri, extractedFilename, dataSize, width, height, null); - - // Note: If we come through this path we will not yet have an AudioSlide duration so we set an interim placeholder value. - case AUDIO: return new AudioSlide(context, uri, extractedFilename, dataSize, false, "--:--"); - + case AUDIO: return new AudioSlide(context, uri, extractedFilename, dataSize, false, -1L); case VIDEO: return new VideoSlide(context, uri, extractedFilename, dataSize); case VCARD: case DOCUMENT: return new DocumentSlide(context, uri, extractedFilename, mimeType, dataSize); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 63a13ddb53..5610eb566d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -131,9 +131,10 @@ public class AttachmentDatabase extends Database { SIZE, FILE_NAME, THUMBNAIL, THUMBNAIL_ASPECT_RATIO, UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE, QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT, - CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL}; + CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL, + AUDIO_DURATION}; - private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION}; + private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES}; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " + MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " + @@ -197,17 +198,6 @@ public AttachmentDatabase(Context context, Provider databas } } - public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) - throws MmsException - { - SQLiteDatabase database = getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(TRANSFER_STATE, AttachmentState.FAILED.getValue()); - - database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); - notifyConversationListeners(DatabaseComponent.get(context).mmsDatabase().getThreadIdForMessage(mmsId)); - } - public @Nullable DatabaseAttachment getAttachment(@NonNull AttachmentId attachmentId) { SQLiteDatabase database = getReadableDatabase(); @@ -255,23 +245,6 @@ public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) } } - public @NonNull List getPendingAttachments() { - final SQLiteDatabase database = getReadableDatabase(); - final List attachments = new LinkedList<>(); - - Cursor cursor = null; - try { - cursor = database.query(TABLE_NAME, PROJECTION, TRANSFER_STATE + " = ?", new String[] {String.valueOf(AttachmentState.DOWNLOADING.getValue())}, null, null, null); - while (cursor != null && cursor.moveToNext()) { - attachments.addAll(getAttachment(cursor)); - } - } finally { - if (cursor != null) cursor.close(); - } - - return attachments; - } - public @NonNull List getAllAttachments() { SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; @@ -391,21 +364,6 @@ public void deleteAttachment(@NonNull AttachmentId id) { } } - @SuppressWarnings("ResultOfMethodCallIgnored") - void deleteAllAttachments() { - SQLiteDatabase database = getWritableDatabase(); - database.delete(TABLE_NAME, null, null); - - File attachmentsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); - File[] attachments = attachmentsDirectory.listFiles(); - - for (File attachment : attachments) { - attachment.delete(); - } - - notifyAttachmentListeners(); - } - private void deleteAttachmentsOnDisk(List mmsAttachmentInfos) { for (MmsAttachmentInfo info : mmsAttachmentInfos) { if (info.getDataFile() != null && !TextUtils.isEmpty(info.getDataFile())) { @@ -528,30 +486,6 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { return insertedAttachments; } - /** - * Insert attachments in database and return the IDs of the inserted attachments - * - * @param mmsId message ID - * @param attachments attachments to persist - * @return IDs of the persisted attachments - * @throws MmsException - */ - @NonNull List insertAttachments(long mmsId, @NonNull List attachments) - throws MmsException - { - Log.d(TAG, "insertParts(" + attachments.size() + ")"); - - List insertedAttachmentsIDs = new LinkedList<>(); - - for (Attachment attachment : attachments) { - AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote()); - insertedAttachmentsIDs.add(attachmentId.getRowId()); - Log.i(TAG, "Inserted attachment at ID: " + attachmentId); - } - - return insertedAttachmentsIDs; - } - public @NonNull Attachment updateAttachmentData(@NonNull Attachment attachment, @NonNull MediaStream mediaStream) throws MmsException @@ -604,7 +538,8 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { mediaStream.getHeight(), databaseAttachment.isQuote(), databaseAttachment.getCaption(), - databaseAttachment.getUrl()); + databaseAttachment.getUrl(), + databaseAttachment.getAudioDurationMs()); } public void markAttachmentUploaded(long messageId, Attachment attachment) { @@ -752,13 +687,15 @@ public List getAttachment(@NonNull Cursor cursor) { object.getInt(HEIGHT), object.getInt(QUOTE) == 1, object.getString(CAPTION), - "")); // TODO: Not sure if this will break something + "", // TODO: Not sure if this will break something + object.getLong(AUDIO_DURATION))); } } return new ArrayList<>(result); } else { int urlIndex = cursor.getColumnIndex(URL); + int audioDurationIndex = cursor.getColumnIndexOrThrow(AUDIO_DURATION); return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))), cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)), @@ -778,7 +715,9 @@ public List getAttachment(@NonNull Cursor cursor) { cursor.getInt(cursor.getColumnIndexOrThrow(HEIGHT)), cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1, cursor.getString(cursor.getColumnIndexOrThrow(CAPTION)), - urlIndex > 0 ? cursor.getString(urlIndex) : "")); + urlIndex > 0 ? cursor.getString(urlIndex) : "", + cursor.isNull(audioDurationIndex) ? -1L : cursor.getLong(audioDurationIndex)) + ); } } catch (JSONException e) { throw new AssertionError(e); @@ -818,6 +757,10 @@ private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean contentValues.put(QUOTE, quote); contentValues.put(CAPTION, attachment.getCaption()); contentValues.put(URL, attachment.getUrl()); + long audioDuration = attachment.getAudioDurationMs(); + if (audioDuration > 0) { + contentValues.put(AUDIO_DURATION, audioDuration); + } if (dataInfo != null) { contentValues.put(DATA, dataInfo.file.getAbsolutePath()); @@ -944,15 +887,6 @@ public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras e return alteredRows > 0; } - /** - * Updates audio extra columns for the "audio/*" mime type attachments only. - * @return true if the update operation was successful. - */ - @Synchronized - public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) { - return setAttachmentAudioExtras(extras, -1); // -1 for no update - } - @VisibleForTesting class ThumbnailFetchCallable implements Callable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java index 960e5b1da7..118b1cc9d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -43,6 +43,7 @@ public class MediaDatabase extends Database { + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index e67e6ba04f..f52070a90f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -51,7 +51,6 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId @@ -647,7 +646,6 @@ class MmsDatabase(context: Context, databaseHelper: Provider { if (threadId < 0 ) throw MmsException("No thread ID supplied!") if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) - val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) + val messageId = insertMessageOutbox( + retrieved, + threadId, + false, + serverTimestamp, + runThreadUpdate + ) if (messageId == -1L) { Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.") return Optional.absent() @@ -708,7 +712,6 @@ class MmsDatabase(context: Context, databaseHelper: Provider, linkPreviews: List, contentValues: ContentValues, - insertListener: InsertListener?, ): Long { val db = writableDatabase val partsDatabase = get(context).attachmentDatabase() @@ -870,7 +871,6 @@ class MmsDatabase(context: Context, databaseHelper: Provider insertMessageOutbox(long threadId, OutgoingTextMes if (threadId == -1) { threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(message.getRecipient()); } - long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, null, runThreadUpdate); + long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, runThreadUpdate); if (messageId == -1) { return Optional.absent(); } @@ -563,7 +563,7 @@ public Optional insertMessageOutbox(long threadId, OutgoingTextMes } public long insertMessageOutbox(long threadId, OutgoingTextMessage message, - boolean forceSms, long date, InsertListener insertListener, + boolean forceSms, long date, boolean runThreadUpdate) { long type = Types.BASE_SENDING_TYPE; @@ -597,9 +597,6 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, SQLiteDatabase db = getWritableDatabase(); long messageId = db.insert(TABLE_NAME, ADDRESS, contentValues); - if (insertListener != null) { - insertListener.onComplete(); - } if (runThreadUpdate) { DatabaseComponent.get(context).threadDatabase().update(threadId, true); @@ -890,8 +887,4 @@ public void close() { } } - public interface InsertListener { - public void onComplete(); - } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index d25d0cfdd2..cf2da74f53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -254,12 +254,6 @@ open class Storage @Inject constructor( return registrationID } - override fun persistAttachments(messageID: Long, attachments: List): List { - val database = attachmentDatabase - val databaseAttachments = attachments.mapNotNull { it.toSignalAttachment() } - return database.insertAttachments(messageID, databaseAttachments) - } - override fun getAttachmentsForMessage(mmsMessageId: Long): List { return attachmentDatabase.getAttachmentsForMessage(mmsMessageId) } @@ -872,7 +866,12 @@ open class Storage @Inject constructor( Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") return null } - val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + val infoMessageID = mmsDB.insertMessageOutbox( + infoMessage, + threadID, + false, + runThreadUpdate = true + ) mmsDB.markAsSent(infoMessageID, true) return infoMessageID } @@ -1030,7 +1029,12 @@ open class Storage @Inject constructor( val mmsSmsDB = mmsSmsDatabase // check for conflict here, not returning duplicate in case it's different if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null - val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + val infoMessageID = mmsDB.insertMessageOutbox( + infoMessage, + threadID, + false, + runThreadUpdate = true + ) mmsDB.markAsSent(infoMessageID, true) return infoMessageID } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt index 47fe00603e..23e89efa38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt @@ -35,7 +35,7 @@ class AudioSlide : Slide { override val thumbnailUri: Uri? get() = null - constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, voiceNote: Boolean, duration: String) + constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, voiceNote: Boolean, durationMills: Long) // Note: The `caption` field of `constructAttachmentFromUri` is repurposed to store the interim : super(context, constructAttachmentFromUri( @@ -47,12 +47,13 @@ class AudioSlide : Slide { 0, // height false, // hasThumbnail filename, - duration, // AudioSlides do not have captions, so we are re-purposing this field (in AudioSlides only) to store the interim audio duration displayed during upload. + null, voiceNote, - false) // quote + false, + durationMills) // quote ) - constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, contentType: String, voiceNote: Boolean, duration: String = "--:--") + constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, contentType: String, voiceNote: Boolean, durationMills: Long) : super(context, UriAttachment( uri, @@ -66,7 +67,8 @@ class AudioSlide : Slide { null, // fastPreflightId voiceNote, false, // quote - duration) // AudioSlides do not have captions, so we are re-purposing this field (in AudioSlides only) to store the interim audio duration displayed during upload. + null, + durationMills) ) constructor(context: Context, attachment: Attachment) : super(context, attachment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt index 090663fdf9..5361b10e5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt @@ -147,6 +147,7 @@ abstract class Slide(@JvmField protected val context: Context, protected val att companion object { @JvmStatic + @JvmOverloads protected fun constructAttachmentFromUri( context: Context, uri: Uri, @@ -158,7 +159,8 @@ abstract class Slide(@JvmField protected val context: Context, protected val att fileName: String?, caption: String?, voiceNote: Boolean, - quote: Boolean + quote: Boolean, + audioDurationMills: Long = -1L, ): Attachment { val resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime) val fastPreflightId = SECURE_RANDOM.nextLong().toString() @@ -175,7 +177,8 @@ abstract class Slide(@JvmField protected val context: Context, protected val att fastPreflightId, voiceNote, quote, - caption + caption, + audioDurationMills ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 31a368687c..b7051357e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -97,14 +97,14 @@ protected Void doInBackground(Void... params) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); + DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, true); } catch (MmsException e) { Log.w(TAG, e); } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true); + DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), true); } List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(replyThreadId, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 99798a7e0d..3144bd33f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -110,7 +110,7 @@ protected Void doInBackground(Void... params) { case GroupMessage: { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true), true)); + message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, true), true)); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -119,7 +119,7 @@ protected Void doInBackground(Void... params) { } case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); - message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true), false)); + message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), true), false)); MessageSender.send(message, address); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 8d05abd81e..8270b32c34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -4,18 +4,19 @@ import android.content.Context; import android.content.UriMatcher; import android.net.Uri; + import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.concurrent.SignalExecutors; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; -import org.session.libsignal.utilities.Log; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.concurrent.SignalExecutors; import java.io.ByteArrayInputStream; import java.io.File; @@ -26,10 +27,6 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - -import kotlin.Pair; -import kotlin.Result; /** * Allows for the creation and retrieval of blobs. @@ -177,6 +174,7 @@ public static boolean isAuthority(@NonNull Uri uri) { return URI_MATCHER.match(uri) == MATCH; } + @WorkerThread @NonNull private static CompletableFuture writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { @@ -262,6 +260,7 @@ protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { return new BlobSpec(data, id, storageType, mimeType, fileName, fileSize); } + /** * Create a blob that will exist for a single app session. An app session is defined as the * period from one {@link Application#onCreate()} to the next. diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index 47a54bc60d..de3636f502 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -54,9 +54,9 @@ class SignalAudioManager(private val context: Context, private var audioDevices: MutableSet = mutableSetOf() - private val soundPool: SoundPool = androidAudioManager.createSoundPool() - private val connectedSoundId = soundPool.load(context, R.raw.webrtc_completed, 1) - private val disconnectedSoundId = soundPool.load(context, R.raw.webrtc_disconnected, 1) + private val soundPool: SoundPool by lazy { androidAudioManager.createSoundPool() } + private val connectedSoundId by lazy { soundPool.load(context, R.raw.webrtc_completed, 1) } + private val disconnectedSoundId by lazy { soundPool.load(context, R.raw.webrtc_disconnected, 1) } private val incomingRinger = IncomingRinger(context) private val outgoingRinger = OutgoingRinger(context) diff --git a/app/src/main/res/layout/view_voice_message.xml b/app/src/main/res/layout/view_voice_message.xml index 666c3febce..cf2416322e 100644 --- a/app/src/main/res/layout/view_voice_message.xml +++ b/app/src/main/res/layout/view_voice_message.xml @@ -1,7 +1,7 @@ - Date: Thu, 29 May 2025 12:12:40 +1000 Subject: [PATCH 365/867] Version bump --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9fa281fb60..44317f6c22 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 409 -val canonicalVersionName = "1.24.0" +val canonicalVersionCode = 410 +val canonicalVersionName = "1.25.0" val postFixSize = 10 val abiPostFix = mapOf( From dbb25b16420c8e402192ffc9fcdaef332b9d1812 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 29 May 2025 16:14:47 +1000 Subject: [PATCH 366/867] Fixing clear icon size in textfield and adding clear to UCS dialogs --- .../settings/ConversationSettingsDialogs.kt | 5 ++++ .../securesms/ui/components/Text.kt | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 031fd577d6..fcba1d9cb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -101,6 +101,8 @@ fun ConversationSettingsDialogs( onChange = { updatedText -> sendCommand(UpdateNickname(updatedText)) }, + showClear = true, + singleLine = true, onContinue = { sendCommand(SetNickname) }, error = dialogsState.nicknameDialog.error, ) @@ -154,6 +156,8 @@ fun ConversationSettingsDialogs( onChange = { updatedText -> sendCommand(UpdateGroupName(updatedText)) }, + showClear = true, + singleLine = true, error = dialogsState.groupEditDialog.errorName, ) @@ -169,6 +173,7 @@ fun ConversationSettingsDialogs( onChange = { updatedText -> sendCommand(UpdateGroupDescription(updatedText)) }, + showClear = true, error = dialogsState.groupEditDialog.errorDescription, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 2c19fb2998..14e1ac6c0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -84,6 +84,20 @@ fun PreviewSessionOutlinedTextField() { showClear = true ) + SessionOutlinedTextField( + text = "text with clear", + placeholder = "", + showClear = true, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing) + ) + SessionOutlinedTextField( + text = "text with clear \ntest\ntest\ntest\ntest", + placeholder = "", + showClear = true, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing) + ) + + SessionOutlinedTextField( text = "", placeholder = "placeholder" @@ -160,7 +174,9 @@ fun SessionOutlinedTextField( color = if (enabled) LocalColors.current.text(isTextErrorColor) else LocalColors.current.textSecondary), cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), enabled = enabled, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = if (singleLine) ImeAction.Done else ImeAction.Default + ), keyboardActions = KeyboardActions( onDone = { onContinue() }, @@ -185,8 +201,7 @@ fun SessionOutlinedTextField( .padding(innerPadding), ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { Box( modifier = Modifier.weight(1f), @@ -203,7 +218,7 @@ fun SessionOutlinedTextField( ), modifier = Modifier.qaTag(R.string.qa_conversation_search_clear) .padding(start = LocalDimensions.current.smallSpacing) - .size(LocalDimensions.current.iconSmall) + .size(textStyle.fontSize.value.dp) .clickable { onChange("") } From cd7fe1919eeb0e41466d7011ac5dd532369c70d0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 29 May 2025 16:49:25 +1000 Subject: [PATCH 367/867] Limiting fullscreen avatars to singular custom images --- .../v2/settings/ConversationSettingsScreen.kt | 12 +++++++++++- .../org/thoughtcrime/securesms/util/AvatarUtils.kt | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt index 9918d26476..d4e317b73d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -146,7 +146,17 @@ fun ConversationSettings( sharedTransitionScope.rememberSharedContentState(key = "avatar"), animatedVisibilityScope = animatedContentScope ) - .clickable { showFullscreenAvatar() }, + .then( + if(data.avatarUIData.isSingleCustomAvatar()){ + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = LocalDimensions.current.iconXXLarge/2), + onClick = showFullscreenAvatar + ) + } else { + Modifier + } + ), size = LocalDimensions.current.iconXXLarge, maxSizeLoad = LocalDimensions.current.iconXXLarge, // make sure we load the right size data = data.avatarUIData, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt index 5d1220cd4a..51560a25d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt @@ -231,7 +231,14 @@ class AvatarUtils @Inject constructor( data class AvatarUIData( val elements: List, -) +){ + /** + * Helper function to determine if an avatar is composed of a single element, which is + * a custom photo. + * This is used for example to know when to display a fullscreen avatar on tap + */ + fun isSingleCustomAvatar() = elements.size == 1 && elements[0].contactPhoto != null +} data class AvatarUIElement( val name: String? = null, From 93ea8a8087bc2dcdc1d6e8b2de00d56b6ff611fb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 29 May 2025 17:00:06 +1000 Subject: [PATCH 368/867] Fixing expandable text's logic --- .../java/org/thoughtcrime/securesms/ui/Components.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 02f179e95e..b9b15e43c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -921,9 +921,9 @@ fun ExpandableText( val px = textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px maxHeight = with(density) { px.toDp() } }, - onTap = { - expanded = !expanded - } + onTap = if(showButton){ // only expand if there is enough text + { expanded = !expanded } + } else null ) } @@ -979,7 +979,7 @@ fun BaseExpandableText( expanded: Boolean = false, showScroll: Boolean = false, onTextMeasured: (TextLayoutResult) -> Unit = {}, - onTap: () -> Unit = {} + onTap: (() -> Unit)? = null ){ var textModifier: Modifier = Modifier if(qaTag != null) textModifier = textModifier.qaTag(qaTag) @@ -999,7 +999,9 @@ fun BaseExpandableText( } Column( - modifier = modifier.clickable { onTap() }, + modifier = modifier.then( + if(onTap != null) Modifier.clickable { onTap() } else Modifier + ), horizontalAlignment = Alignment.CenterHorizontally ) { Text( From 8980e3209eb91525972851d9e8cce88d38f0cfc5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 30 May 2025 10:17:59 +1000 Subject: [PATCH 369/867] Disabling 'Set' button when picking existing expiry mode --- .../conversation/disappearingmessages/ui/Adapter.kt | 1 + .../disappearingmessages/ui/DisappearingMessages.kt | 1 + .../conversation/disappearingmessages/ui/UiState.kt | 11 +++++++---- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt index 6443f9e2a7..1c9d75282c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -18,6 +18,7 @@ fun State.toUiState() = UiState( ), showGroupFooter = isGroup && isNewConfigEnabled, showSetButton = isSelfAdmin, + disableSetButton = persistedMode == expiryMode, subtitle = subtitle ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index 01b83d7d6e..0ae2336549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -116,6 +116,7 @@ fun DisappearingMessages( .qaTag(R.string.AccessibilityId_setButton) .align(Alignment.CenterHorizontally) .padding(bottom = LocalDimensions.current.spacing), + enabled = !state.disableSetButton, onClick = onSetClicked ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt index 0d2065e627..92959849d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -10,17 +10,20 @@ data class UiState( val cards: List = emptyList(), val showGroupFooter: Boolean = false, val showSetButton: Boolean = true, + val disableSetButton: Boolean = false, val subtitle: GetString? = null, ) { constructor( vararg cards: ExpiryOptionsCardData, showGroupFooter: Boolean = false, showSetButton: Boolean = true, + disableSetButton: Boolean = false, subtitle: GetString? = null, ): this( - cards.asList(), - showGroupFooter, - showSetButton, - subtitle, + cards = cards.asList(), + showGroupFooter = showGroupFooter, + showSetButton = showSetButton, + disableSetButton = disableSetButton, + subtitle = subtitle, ) } From 234d9d5485e2cced00eecd5a801aea503878cb8e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 30 May 2025 11:01:02 +1000 Subject: [PATCH 370/867] Fixed tests --- .../DisappearingMessagesViewModelTest.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index 7bf6879bd3..b5f687d72d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -95,6 +95,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { timeOption(ExpiryType.AFTER_SEND, 7.days), timeOption(ExpiryType.AFTER_SEND, 14.days) ), + disableSetButton = true, subtitle = GetString(R.string.disappearingMessagesDisappearAfterSendDescription) ) ) @@ -135,6 +136,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { timeOption(ExpiryType.AFTER_SEND, 7.days), timeOption(ExpiryType.AFTER_SEND, 14.days) ), + disableSetButton = true, showGroupFooter = true, subtitle = GetString(R.string.disappearingMessagesDisappearAfterSendDescription) ) @@ -178,6 +180,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { ), showGroupFooter = true, showSetButton = false, + disableSetButton = true, subtitle = GetString(R.string.disappearingMessagesDisappearAfterSendDescription) ) ) @@ -217,7 +220,8 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { typeOption(12.hours, ExpiryType.AFTER_READ), typeOption(1.days, ExpiryType.AFTER_SEND) ), - subtitle = GetString(R.string.disappearingMessagesDescription1) + subtitle = GetString(R.string.disappearingMessagesDescription1), + disableSetButton = true, ) ) } @@ -264,6 +268,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { timeOption(ExpiryType.AFTER_SEND, 7.days), timeOption(ExpiryType.AFTER_SEND, 14.days) ), + disableSetButton = true, subtitle = GetString(R.string.disappearingMessagesDescription1) ) ) @@ -311,6 +316,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { timeOption(ExpiryType.AFTER_SEND, 7.days), timeOption(ExpiryType.AFTER_SEND, 14.days) ), + disableSetButton = true, subtitle = GetString(R.string.disappearingMessagesDescription1) ) ) @@ -361,6 +367,7 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { timeOption(ExpiryType.AFTER_READ, 7.days), timeOption(ExpiryType.AFTER_READ, 14.days) ), + disableSetButton = true, subtitle = GetString(R.string.disappearingMessagesDescription1) ) ) From cb859f02a7d4251d45c3ae1256ecaa9902a74b0f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sun, 1 Jun 2025 14:45:46 +1000 Subject: [PATCH 371/867] Donate button Stll need real crowdin export --- .../securesms/preferences/SettingsActivity.kt | 48 +++++++++++-------- .../securesms/ui/theme/ThemeColors.kt | 6 +++ app/src/main/res/drawable/ic_heart.xml | 5 ++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 app/src/main/res/drawable/ic_heart.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 6bdf6b7b6c..d14ee01dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -102,6 +102,7 @@ import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LargeItemButton import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton @@ -117,6 +118,7 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.dangerButtonColors +import org.thoughtcrime.securesms.ui.theme.primaryButtonColors import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.applyCommonWindowInsetsOnViews import org.thoughtcrime.securesms.util.push @@ -172,6 +174,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { } } + private var showDonateDialog: Boolean by mutableStateOf(false) private var showAvatarDialog: Boolean by mutableStateOf(false) private var showAvatarPickerOptionCamera: Boolean by mutableStateOf(false) private var showAvatarPickerOptions: Boolean by mutableStateOf(false) @@ -200,12 +203,14 @@ class SettingsActivity : ScreenLockActionBarActivity() { // set the compose dialog content binding.composeLayout.setThemedContent { SettingsComposeContent( + showDonateDialog = showDonateDialog, showAvatarDialog = showAvatarDialog, startAvatarSelection = ::startAvatarSelection, saveAvatar = viewModel::saveAvatar, removeAvatar = viewModel::removeAvatar, showAvatarPickerOptions = showAvatarPickerOptions, showCamera = showAvatarPickerOptionCamera, + hideDonateDialog = { showDonateDialog = false }, onSheetDismissRequest = { showAvatarPickerOptions = false }, onGalleryPicked = { pickPhotoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) @@ -510,6 +515,18 @@ class SettingsActivity : ScreenLockActionBarActivity() { } Divider() + // Donate + //todo DONATE need crowdin string + LargeItemButton( + textId = R.string.sessionDonate, + icon = R.drawable.ic_heart, + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionPrivacy), + colors = primaryButtonColors() + ) { + showDonateDialog = true + } + Divider() + LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole, Modifier.qaTag(R.string.AccessibilityId_sessionPrivacy)) { push() } Divider() @@ -532,27 +549,10 @@ class SettingsActivity : ScreenLockActionBarActivity() { ) { sendInvitationToUseSession() } Divider() - // Add the token page option. - // Note: We can't do this all-in-one via `annotatedStringResource` because the font sizes vary. - val sessionNetworkAS = buildAnnotatedString { - // "Session Network" part styled with normal theme color - withStyle(style = SpanStyle(color = LocalColors.current.text)) { - append(NETWORK_NAME) - } - // " • New" part styled with theme accent color, small font size, and normal (not bold) weight - withStyle( - style = SpanStyle( - color = LocalColors.current.primaryText, - fontSize = LocalType.current.extraSmall.fontSize, - fontWeight = FontWeight.Normal - ) - ) { - append(" "+applicationContext.getString(R.string.sessionNew)) - } - } + // Network page LargeItemButton( modifier = Modifier.qaTag(R.string.qa_settings_item_session_network), - annotatedStringText = sessionNetworkAS, + text = NETWORK_NAME, icon = R.drawable.session_network_logo ) { push() } Divider() @@ -587,10 +587,12 @@ class SettingsActivity : ScreenLockActionBarActivity() { @Composable fun SettingsComposeContent( + showDonateDialog: Boolean, showAvatarDialog: Boolean, startAvatarSelection: ()->Unit, saveAvatar: ()->Unit, removeAvatar: ()->Unit, + hideDonateDialog: ()->Unit, showAvatarPickerOptions: Boolean, showCamera: Boolean, onSheetDismissRequest: () -> Unit, @@ -606,6 +608,14 @@ class SettingsActivity : ScreenLockActionBarActivity() { ) } + // donate confirmation + if(showDonateDialog){ + OpenURLAlertDialog( + url = "https://session.foundation/donate", + onDismissRequest = hideDonateDialog + ) + } + // bottom sheets with options for avatar: Gallery or photo if(showAvatarPickerOptions) { AvatarBottomSheet( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 593905db92..8ec8016e54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -98,6 +98,12 @@ fun dangerButtonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.danger ) +@Composable +fun primaryButtonColors() = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = LocalColors.current.primary +) + // Our themes data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = false diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000000..1266e00c76 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1686897a2..6aea04909b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -924,6 +924,7 @@ Notifications Permissions Privacy + Donate Recovery Password Settings Set From 391fcbe6cefb7a9bdba21a5ea218dadf5c131d2a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Sun, 1 Jun 2025 15:00:57 +1000 Subject: [PATCH 372/867] Fixed notification settings logic --- .../v2/settings/notification/NotificationSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt index 0759e3ff29..6cfb6b0fe7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -68,7 +68,7 @@ class NotificationSettingsViewModel @AssistedInject constructor( thread = it // update the user's current choice of notification - currentMutedUntil = it?.mutedUntil + currentMutedUntil = if(it?.isMuted == true) it.mutedUntil else null val hasMutedUntil = currentMutedUntil != null && currentMutedUntil!! > 0L currentOption = when{ From 273d1516e77fe2233839cc4f978422024d5de3e8 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Sun, 1 Jun 2025 23:17:42 +0000 Subject: [PATCH 373/867] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+cs+CZ/strings.xml | 2 -- app/src/main/res/values/strings.xml | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index a02a093e26..8531ee36c0 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -322,7 +322,6 @@ Počkejte prosím, než se skupina vytvoří... Aktualizace skupiny selhala Nemáte oprávnění k mazání zpráv ostatních - Opravdu chcete smazat {name} z vašich kontaktů?\n\nTím smažete vaše konverzace, včetně všech zpráv a příloh. Budoucí zprávy od {name} se zobrazí jako požadavek na zprávu. Opracdu chcete smazat konverzaci s {name}?\nTím trvale smažete všechny zprávy a přílohy. Smazat zprávu @@ -786,7 +785,6 @@ Podmínky služby Používáním této služby souhlasíte s našimi Podmínkami služby a Zásadami ochrany osobních údajů Trasa - {app_name} skrývá vaši IP adresu tím, že směruje vaše zprávy přes několik Service Nodes v decentralizované síti {app_name}. Toto je vaše aktuální trasa: Cíl Vstupní uzel Service Node diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1686897a2..bd2480ebb0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,6 +404,7 @@ Set Display Name Your Display Name is visible to users, groups and communities you interact with. Document + Donate Done Download Downloading... From 47a6d4603b53b533d8bb28a2f9effe52ed473079 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Sun, 1 Jun 2025 23:17:42 +0000 Subject: [PATCH 374/867] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+cs+CZ/strings.xml | 2 -- app/src/main/res/values/strings.xml | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index a02a093e26..8531ee36c0 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -322,7 +322,6 @@ Počkejte prosím, než se skupina vytvoří... Aktualizace skupiny selhala Nemáte oprávnění k mazání zpráv ostatních - Opravdu chcete smazat {name} z vašich kontaktů?\n\nTím smažete vaše konverzace, včetně všech zpráv a příloh. Budoucí zprávy od {name} se zobrazí jako požadavek na zprávu. Opracdu chcete smazat konverzaci s {name}?\nTím trvale smažete všechny zprávy a přílohy. Smazat zprávu @@ -786,7 +785,6 @@ Podmínky služby Používáním této služby souhlasíte s našimi Podmínkami služby a Zásadami ochrany osobních údajů Trasa - {app_name} skrývá vaši IP adresu tím, že směruje vaše zprávy přes několik Service Nodes v decentralizované síti {app_name}. Toto je vaše aktuální trasa: Cíl Vstupní uzel Service Node diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6aea04909b..7394c0589e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,6 +404,7 @@ Set Display Name Your Display Name is visible to users, groups and communities you interact with. Document + Donate Done Download Downloading... From d78654342045ec8fca68688b1736d3d65ba43bf1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 09:35:39 +1000 Subject: [PATCH 375/867] Proper crowdin string --- .../org/thoughtcrime/securesms/preferences/SettingsActivity.kt | 3 +-- app/src/main/res/values/strings.xml | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index d14ee01dda..05df0c839a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -516,9 +516,8 @@ class SettingsActivity : ScreenLockActionBarActivity() { Divider() // Donate - //todo DONATE need crowdin string LargeItemButton( - textId = R.string.sessionDonate, + textId = R.string.donate, icon = R.drawable.ic_heart, modifier = Modifier.qaTag(R.string.AccessibilityId_sessionPrivacy), colors = primaryButtonColors() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7394c0589e..bd2480ebb0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -925,7 +925,6 @@ Notifications Permissions Privacy - Donate Recovery Password Settings Set From 5a4782dba5b3afadd020f6493c93c30da1269d7a Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:21:50 +1000 Subject: [PATCH 376/867] Merge pull request #1214 from session-foundation/community-loading Improve community loading performance --- app/build.gradle.kts | 2 +- .../libsession/database/StorageProtocol.kt | 7 + .../messaging/jobs/BatchMessageReceiveJob.kt | 53 ++-- .../ReceivedMessageHandler.kt | 236 ++++++++++-------- .../pollers/OpenGroupPoller.kt | 30 ++- .../pollers/OpenGroupPollerManager.kt | 2 +- .../org/session/libsession/utilities/Util.kt | 6 - .../securesms/database/ReactionDatabase.kt | 69 +++-- .../securesms/database/SmsDatabase.java | 3 +- .../securesms/database/Storage.kt | 12 + .../database/helpers/SQLCipherOpenHelper.java | 8 +- .../securesms/groups/OpenGroupManager.kt | 6 + 12 files changed, 276 insertions(+), 158 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9fa281fb60..aabb44862f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,7 +24,7 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 409 +val canonicalVersionCode = 410 val canonicalVersionName = "1.24.0" val postFixSize = 10 diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 0d7df53089..9ad9767d1d 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -37,6 +37,7 @@ import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember @@ -259,6 +260,12 @@ interface StorageProtocol { * Add reaction to a specific message. This is preferable to the timestamp lookup. */ fun addReaction(messageId: MessageId, reaction: Reaction, messageSender: String, notifyUnread: Boolean) + + /** + * Add reactions into the database. If [replaceAll] is true, + * it will remove all existing reactions that belongs to the same message(s). + */ + fun addReactions(reactions: Map>, replaceAll: Boolean, notifyUnread: Boolean) fun removeReaction(emoji: String, messageTimestamp: Long, threadId: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) fun deleteReactions(messageId: MessageId) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 864b2ada6e..8425b0885d 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -6,16 +6,15 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import network.loki.messenger.libsession_util.ConfigBase -import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.messages.control.CallMessage -import org.session.libsession.messaging.messages.control.LegacyGroupControlMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.LegacyGroupControlMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.messages.control.SharedConfigurationMessage @@ -25,8 +24,9 @@ import org.session.libsession.messaging.messages.visible.ParsedMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.VisibleMessageHandlerContext +import org.session.libsession.messaging.sending_receiving.constructReactionRecords import org.session.libsession.messaging.sending_receiving.handle -import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions import org.session.libsession.messaging.sending_receiving.handleUnsendRequest import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data @@ -34,10 +34,9 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.UserConfigType import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord import kotlin.math.max data class MessageReceiveParameters( @@ -175,33 +174,37 @@ class BatchMessageReceiveJob( } // iterate over threads and persist them (persistence is the longest constant in the batch process operation) - fun processMessages(threadId: Long, messages: List) { + suspend fun processMessages(threadId: Long, messages: List) { // The LinkedHashMap should preserve insertion order val messageIds = linkedMapOf>() val myLastSeen = storage.getLastSeen(threadId) var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 + val handlerContext = VisibleMessageHandlerContext( + module = MessagingModuleConfiguration.shared, + threadId = threadId, + openGroupID = openGroupID, + ) + + val communityReactions = mutableMapOf>() + messages.forEach { (parameters, message, proto) -> try { when (message) { is VisibleMessage -> { val isUserBlindedSender = - message.sender == serverPublicKey?.let { - BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = storage.getUserED25519KeyPair()!! - .secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - ) - }?.let { - AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString - } + message.sender == handlerContext.userBlindedKey + if (message.sender == localUserPublicKey || isUserBlindedSender) { // use sent timestamp here since that is technically the last one we have newLastSeen = max(newLastSeen, message.sentTimestamp!!) } - val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, - threadId, + val messageId = MessageReceiver.handleVisibleMessage( + message = message, + proto = proto, + context = handlerContext, runThreadUpdate = false, - runProfileUpdate = true) + runProfileUpdate = true + ) if (messageId != null && message.reaction == null) { messageIds[messageId] = Pair( @@ -209,11 +212,13 @@ class BatchMessageReceiveJob( message.hasMention ) } + parameters.openGroupMessageServerID?.let { - MessageReceiver.handleOpenGroupReactions( - threadId, - it, - parameters.reactions + constructReactionRecords( + openGroupMessageServerID = it, + context = handlerContext, + reactions = parameters.reactions, + out = communityReactions ) } } @@ -255,6 +260,10 @@ class BatchMessageReceiveJob( } storage.updateThread(threadId, true) SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) + + if (communityReactions.isNotEmpty()) { + storage.addReactions(communityReactions, replaceAll = true, notifyUnread = false) + } } coroutineScope { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 82430c0749..95c46269dc 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -1,6 +1,8 @@ package org.session.libsession.messaging.sending_receiving +import android.content.Context import android.text.TextUtils +import dagger.Lazy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -9,8 +11,11 @@ import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob @@ -31,6 +36,7 @@ import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage @@ -68,6 +74,8 @@ import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.sskenvironment.ProfileManager import java.security.MessageDigest import java.security.SignatureException import java.util.LinkedList @@ -105,7 +113,9 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> handleMessageRequestResponse(message) is VisibleMessage -> handleVisibleMessage( - message, proto, openGroupID, threadId, + message = message, + proto = proto, + context = VisibleMessageHandlerContext(MessagingModuleConfiguration.shared, threadId, openGroupID), runThreadUpdate = true, runProfileUpdate = true ) @@ -349,66 +359,93 @@ private fun SignalServiceProtos.Content.ExpirationType.expiryMode(durationSecond } } ?: ExpiryMode.NONE +class VisibleMessageHandlerContext( + val context: Context, + val threadId: Long, + val openGroupID: String?, + val storage: StorageProtocol, + val profileManager: SSKEnvironment.ProfileManagerProtocol, + val groupManagerV2: GroupManagerV2, + val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + val messageDataProvider: MessageDataProvider, +) { + constructor(module: MessagingModuleConfiguration, threadId: Long, openGroupID: String?): + this( + context = module.context, + threadId = threadId, + openGroupID = openGroupID, + storage = module.storage, + profileManager = SSKEnvironment.shared.profileManager, + groupManagerV2 = module.groupManagerV2, + messageExpirationManager = SSKEnvironment.shared.messageExpirationManager, + messageDataProvider = module.messageDataProvider + ) + + val openGroup: OpenGroup? by lazy { + openGroupID?.let { storage.getOpenGroup(threadId) } + } + + val userBlindedKey: String? by lazy { + openGroup?.let { + val blindedKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + ) ?: return@let null + + AccountId( + IdPrefix.BLINDED, blindedKey.pubKey.data + ).hexString + } + } + + val userPublicKey: String? by lazy { + storage.getUserPublicKey() + } + + val threadRecipient: Recipient? by lazy { + storage.getRecipientForThread(threadId) + } +} + fun MessageReceiver.handleVisibleMessage( message: VisibleMessage, proto: SignalServiceProtos.Content, - openGroupID: String?, - threadId: Long, + context: VisibleMessageHandlerContext, runThreadUpdate: Boolean, runProfileUpdate: Boolean ): MessageId? { - val storage = MessagingModuleConfiguration.shared.storage - val context = MessagingModuleConfiguration.shared.context - val userPublicKey = storage.getUserPublicKey() + val userPublicKey = context.storage.getUserPublicKey() val messageSender: String? = message.sender // Do nothing if the message was outdated - if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return null } - - // Get or create thread - // FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet - // exist. This is intentional, but it's very non-obvious. - val threadID = storage.getThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID, createThread = true) - // Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread - ?: throw MessageReceiver.Error.NoThread - val threadRecipient = storage.getRecipientForThread(threadID) - val userBlindedKey = openGroupID?.let { - val openGroup = storage.getOpenGroup(threadID) ?: return@let null - val blindedKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, - serverPubKey = Hex.fromStringCondensed(openGroup.publicKey), - ) ?: return@let null - AccountId( - IdPrefix.BLINDED, blindedKey.pubKey.data - ).hexString - } + if (MessageReceiver.messageIsOutdated(message, context.threadId, context.openGroupID)) { return null } + // Update profile if needed - val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false) + val recipient = Recipient.from(context.context, Address.fromSerialized(messageSender!!), false) if (runProfileUpdate) { val profile = message.profile - val isUserBlindedSender = messageSender == userBlindedKey + val isUserBlindedSender = messageSender == context.userBlindedKey if (profile != null && userPublicKey != messageSender && !isUserBlindedSender) { - val profileManager = SSKEnvironment.shared.profileManager val name = profile.displayName!! if (name.isNotEmpty()) { - profileManager.setName(context, recipient, name) + context.profileManager.setName(context.context, recipient, name) } val newProfileKey = profile.profileKey - val needsProfilePicture = !AvatarHelper.avatarFileExists(context, Address.fromSerialized(messageSender)) + val needsProfilePicture = !AvatarHelper.avatarFileExists(context.context, Address.fromSerialized(messageSender)) val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfilePicture(context, recipient, profile.profilePictureURL, newProfileKey) - profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) + context.profileManager.setProfilePicture(context.context, recipient, profile.profilePictureURL, newProfileKey) + context.profileManager.setUnidentifiedAccessMode(context.context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) } else if (newProfileKey == null || newProfileKey.isEmpty() || profile.profilePictureURL.isNullOrEmpty()) { - profileManager.setProfilePicture(context, recipient, null, null) + context.profileManager.setProfilePicture(context.context, recipient, null, null) } } if (userPublicKey != messageSender && !isUserBlindedSender) { - storage.setBlocksCommunityMessageRequests(recipient, message.blocksMessageRequests) + context.storage.setBlocksCommunityMessageRequests(recipient, message.blocksMessageRequests) } // update the disappearing / legacy banner for the sender @@ -416,19 +453,19 @@ fun MessageReceiver.handleVisibleMessage( proto.dataMessage.expireTimer > 0 && !proto.hasExpirationType() -> Recipient.DisappearingState.LEGACY else -> Recipient.DisappearingState.UPDATED } - storage.updateDisappearingState( + context.storage.updateDisappearingState( messageSender, - threadID, + context.threadId, disappearingState ) } // Handle group invite response if new closed group - if (threadRecipient?.isGroupV2Recipient == true) { + if (context.threadRecipient?.isGroupV2Recipient == true) { GlobalScope.launch { try { MessagingModuleConfiguration.shared.groupManagerV2 .handleInviteResponse( - AccountId(threadRecipient.address.toString()), + AccountId(context.threadRecipient!!.address.toString()), AccountId(messageSender), approved = true ) @@ -443,7 +480,7 @@ fun MessageReceiver.handleVisibleMessage( if (message.quote != null && proto.dataMessage.hasQuote()) { val quote = proto.dataMessage.quote - val author = if (quote.author == userBlindedKey) { + val author = if (quote.author == context.userBlindedKey) { Address.fromSerialized(userPublicKey!!) } else { Address.fromSerialized(quote.author) @@ -482,23 +519,23 @@ fun MessageReceiver.handleVisibleMessage( cancelTypingIndicatorsIfNeeded(message.sender!!) // Parse reaction if needed - val threadIsGroup = threadRecipient?.isGroupOrCommunityRecipient == true + val threadIsGroup = context.threadRecipient?.isGroupOrCommunityRecipient == true message.reaction?.let { reaction -> if (reaction.react == true) { reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() reaction.dateSent = message.sentTimestamp ?: 0 reaction.dateReceived = message.receivedTimestamp ?: 0 - storage.addReaction( - threadId = threadId, + context.storage.addReaction( + threadId = context.threadId, reaction = reaction, messageSender = messageSender, notifyUnread = !threadIsGroup ) } else { - storage.removeReaction( + context.storage.removeReaction( emoji = reaction.emoji!!, messageTimestamp = reaction.timestamp!!, - threadId = threadId, + threadId = context.threadId, author = reaction.publicKey!!, notifyUnread = threadIsGroup ) @@ -507,25 +544,25 @@ fun MessageReceiver.handleVisibleMessage( // A user is mentioned if their public key is in the body of a message or one of their messages // was quoted val messageText = message.text - message.hasMention = listOf(userPublicKey, userBlindedKey) + message.hasMention = listOf(userPublicKey, context.userBlindedKey) .filterNotNull() .any { key -> messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") } // Persist the message - message.threadID = threadID + message.threadID = context.threadId // clean up the message - For example we do not want any expiration data on messages for communities if(message.openGroupServerMessageID != null){ message.expiryMode = ExpiryMode.NONE } - val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null + val messageID = context.storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, context.openGroupID, attachments, runThreadUpdate) ?: return null // Parse & persist attachments // Start attachment downloads if needed - if (messageID.mms && (threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey)) { - storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> + if (messageID.mms && (context.threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey)) { + context.storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> attachment.attachmentId?.let { id -> val downloadJob = AttachmentDownloadJob(id.rowId, messageID.id) JobQueue.shared.add(downloadJob) @@ -533,58 +570,61 @@ fun MessageReceiver.handleVisibleMessage( } } message.openGroupServerMessageID?.let { - storage.setOpenGroupServerMessageID( + context.storage.setOpenGroupServerMessageID( messageID = messageID, serverID = it, - threadID = threadID + threadID = context.threadId ) } - SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message) + context.messageExpirationManager.maybeStartExpiration(message) return messageID } return null } -fun MessageReceiver.handleOpenGroupReactions( - threadId: Long, +/** + * Constructs reaction records for a given open group message. + * + * If the open group message exists in our database, we'll construct a list of reaction records + * that is specified in the [reactions]. + * + * Note that this function does not know or check if the local message has any reactions, + * you'll be responsible for that. In simpler words, [out] only contains reactions that are given + * to this function, it will not include any existing reactions in the database. + * + * @param openGroupMessageServerID The server ID of this message + * @param context The context containing necessary data for processing reactions + * @param reactions A map of emoji to [OpenGroupApi.Reaction] objects, representing the reactions for the message + * @param out A mutable map that will be populated with [ReactionRecord]s, keyed by [MessageId] + */ +fun constructReactionRecords( openGroupMessageServerID: Long, - reactions: Map? + context: VisibleMessageHandlerContext, + reactions: Map?, + out: MutableMap> ) { if (reactions.isNullOrEmpty()) return - val storage = MessagingModuleConfiguration.shared.storage - val messageId = MessagingModuleConfiguration.shared.messageDataProvider.getMessageID(openGroupMessageServerID, threadId) ?: return - storage.deleteReactions(messageId) - val userPublicKey = storage.getUserPublicKey()!! - val openGroup = storage.getOpenGroup(threadId) - val blindedPublicKey = openGroup?.publicKey?.let { serverPublicKey -> - BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey), - ) - ?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } - } + val messageId = context.messageDataProvider.getMessageID(openGroupMessageServerID, context.threadId) ?: return + + val outList = out.getOrPut(messageId) { arrayListOf() } + for ((emoji, reaction) in reactions) { val pendingUserReaction = OpenGroupApi.pendingReactions - .filter { it.server == openGroup?.server && it.room == openGroup.room && it.messageId == openGroupMessageServerID && it.add } + .filter { it.server == context.openGroup?.server && it.room == context.openGroup?.room && it.messageId == openGroupMessageServerID && it.add } .sortedByDescending { it.seqNo } .any { it.emoji == emoji } - val shouldAddUserReaction = pendingUserReaction || reaction.you || reaction.reactors.contains(userPublicKey) - val reactorIds = reaction.reactors.filter { it != blindedPublicKey && it != userPublicKey } + val shouldAddUserReaction = pendingUserReaction || reaction.you || reaction.reactors.contains(context.userPublicKey) + val reactorIds = reaction.reactors.filter { it != context.userBlindedKey && it != context.userPublicKey } val count = if (reaction.you) reaction.count - 1 else reaction.count // Add the first reaction (with the count) reactorIds.firstOrNull()?.let { reactor -> - storage.addReaction( + outList += ReactionRecord( messageId = messageId, - reaction = Reaction( - publicKey = reactor, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = count, - index = reaction.index - ), - messageSender = reactor, - notifyUnread = false + author = reactor, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = count, + sortId = reaction.index, ) } @@ -592,35 +632,25 @@ fun MessageReceiver.handleOpenGroupReactions( val maxAllowed = if (shouldAddUserReaction) 4 else 5 val lastIndex = min(maxAllowed, reactorIds.size) reactorIds.slice(1 until lastIndex).map { reactor -> - storage.addReaction( + outList += ReactionRecord( messageId = messageId, - reaction = Reaction( - publicKey = reactor, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = 0, // Only want this on the first reaction - index = reaction.index - ), - messageSender = reactor, - notifyUnread = false + author = reactor, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = 0, // Only want this on the first reaction + sortId = reaction.index, ) } // Add the current user reaction (if applicable and not already included) if (shouldAddUserReaction) { - storage.addReaction( + outList += ReactionRecord( messageId = messageId, - reaction = Reaction( - publicKey = userPublicKey, - emoji = emoji, - react = true, - serverId = "$openGroupMessageServerID", - count = 1, - index = reaction.index - ), - messageSender = userPublicKey, - notifyUnread = false + author = context.userPublicKey!!, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = 1, + sortId = reaction.index, ) } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 7a061bd1b9..4afa237c7a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -4,6 +4,7 @@ import com.google.protobuf.ByteString import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -35,7 +36,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle -import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address @@ -169,6 +169,15 @@ class OpenGroupPoller @AssistedInject constructor( manualPollRequest.receiveAsFlow() ).first() + // We might have more than one manual poll request, collect them all now so + // they don't trigger unnecessary pollings + val extraTokens = buildList { + while (true) { + val nexToken = manualPollRequest.tryReceive().getOrNull() ?: break + add(nexToken) + } + } + mutableIsCaughtUp.value = false var delayDuration = POLL_INTERVAL_MILLS try { @@ -176,6 +185,7 @@ class OpenGroupPoller @AssistedInject constructor( pollOnce() mutableIsCaughtUp.value = true token?.trySend(Result.success(Unit)) + extraTokens.forEach { it.trySend(Result.success(Unit)) } } catch (e: Exception) { Log.e(TAG, "Error while polling open group messages", e) delayDuration = 2000L @@ -225,17 +235,27 @@ class OpenGroupPoller @AssistedInject constructor( } } } catch (e: Exception) { - updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, e) + if (e !is CancellationException) { + Log.e(TAG, "Error while polling open group messages", e) + updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, e) + } + throw e } } - suspend fun manualPollOnce() { + suspend fun requestPollOnceAndWait() { val token = Channel>() manualPollRequest.send(token) token.receive().getOrThrow() } + fun requestPollOnce() { + scope.launch { + manualPollRequest.send(Channel()) + } + } + private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) { if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) { if (!isPostCapabilitiesRetry) { @@ -347,10 +367,6 @@ class OpenGroupPoller @AssistedInject constructor( .setTimestamp(message.sentTimestamp) .build() envelopes.add(Triple( message.serverID, envelope, message.reactions)) - } else if (!message.reactions.isNullOrEmpty()) { - message.serverID?.let { - MessageReceiver.handleOpenGroupReactions(threadId, it, message.reactions) - } } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt index 7df532438c..87f15318bb 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -98,7 +98,7 @@ class OpenGroupPollerManager @Inject constructor( pollers.value.map { (server, handle) -> handle.pollerScope.launch { runCatching { - handle.poller.manualPollOnce() + handle.poller.requestPollOnceAndWait() }.onFailure { Log.e(TAG, "Error polling open group ${server}", it) } diff --git a/app/src/main/java/org/session/libsession/utilities/Util.kt b/app/src/main/java/org/session/libsession/utilities/Util.kt index ae08085769..5afa6c09bd 100644 --- a/app/src/main/java/org/session/libsession/utilities/Util.kt +++ b/app/src/main/java/org/session/libsession/utilities/Util.kt @@ -233,12 +233,6 @@ object Util { return utf8String.toByteArray(StandardCharsets.UTF_8) } - @JvmStatic - @SuppressLint("NewApi") - fun isDefaultSmsProvider(context: Context): Boolean { - return context.packageName == Telephony.Sms.getDefaultSmsPackage(context) - } - @JvmStatic @Throws(IOException::class) fun readFully(`in`: InputStream?, buffer: ByteArray) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index 63d9f98feb..d82796968b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -73,6 +73,9 @@ class ReactionDatabase(context: Context, helper: Provider) """ ) + @JvmField + val CREATE_MESSAGE_ID_MMS_INDEX = arrayOf("CREATE INDEX IF NOT EXISTS reaction_message_id_mms_idx ON $TABLE_NAME ($MESSAGE_ID, $IS_MMS)") + private fun readReaction(cursor: Cursor): ReactionRecord { return ReactionRecord( messageId = MessageId(CursorUtil.requireLong(cursor, MESSAGE_ID), CursorUtil.requireInt(cursor, IS_MMS) == 1), @@ -103,27 +106,63 @@ class ReactionDatabase(context: Context, helper: Provider) } fun addReaction(reaction: ReactionRecord, notifyUnread: Boolean) { + addReactions(mapOf(reaction.messageId to listOf(reaction)), replaceAll = false, notifyUnread) + } + + fun addReactions(reactionsByMessageId: Map>, replaceAll: Boolean, notifyUnread: Boolean) { + if (reactionsByMessageId.isEmpty()) return + + val values = ContentValues() writableDatabase.beginTransaction() try { - val values = ContentValues().apply { - put(MESSAGE_ID, reaction.messageId.id) - put(IS_MMS, reaction.messageId.mms) - put(EMOJI, reaction.emoji) - put(AUTHOR_ID, reaction.author) - put(SERVER_ID, reaction.serverId) - put(COUNT, reaction.count) - put(SORT_ID, reaction.sortId) - put(DATE_SENT, reaction.dateSent) - put(DATE_RECEIVED, reaction.dateReceived) + // Delete existing reactions for the same message IDs if replaceAll is true + if (replaceAll && reactionsByMessageId.isNotEmpty()) { + // We don't need to do parameteralized queries here as messageId and isMms are always + // integers/boolean, and hence no risk of SQL injection. + val whereClause = StringBuilder("($MESSAGE_ID, $IS_MMS) IN (") + for ((i, id) in reactionsByMessageId.keys.withIndex()) { + if (i > 0) { + whereClause.append(", ") + } + + whereClause + .append('(') + .append(id.id).append(',').append(id.mms) + .append(')') + } + whereClause.append(')') + + writableDatabase.delete(TABLE_NAME, whereClause.toString(), null) } - writableDatabase.insert(TABLE_NAME, null, values) + reactionsByMessageId + .asSequence() + .flatMap { it.value.asSequence() } + .forEach { reaction -> + values.apply { + put(MESSAGE_ID, reaction.messageId.id) + put(IS_MMS, reaction.messageId.mms) + put(EMOJI, reaction.emoji) + put(AUTHOR_ID, reaction.author) + put(SERVER_ID, reaction.serverId) + put(COUNT, reaction.count) + put(SORT_ID, reaction.sortId) + put(DATE_SENT, reaction.dateSent) + put(DATE_RECEIVED, reaction.dateReceived) + } - if (reaction.messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, reaction.messageId.id, true, false, notifyUnread) - } else { - DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, reaction.messageId.id, true, false, notifyUnread) + writableDatabase.insert(TABLE_NAME, null, values) + } + + for ((messageId, reactions) in reactionsByMessageId) { + val hasReactions = !replaceAll || reactions.isNotEmpty() + val isRemoval = replaceAll && reactions.isEmpty() + if (messageId.mms) { + DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions, isRemoval, notifyUnread) + } else { + DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions, isRemoval, notifyUnread) + } } writableDatabase.setTransactionSuccessful() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index dbc1d3ee25..e977f9f978 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -453,8 +453,7 @@ protected Optional insertMessageInbox(IncomingTextMessage message, groupRecipient = Recipient.from(context, message.getGroupId(), true); } - boolean unread = (Util.isDefaultSmsProvider(context) || - message.isSecureMessage() || message.isGroup() || message.isUnreadCallMessage()); + boolean unread = (message.isSecureMessage() || message.isGroup() || message.isUnreadCallMessage()); long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index cf2da74f53..10f4abf7a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -1808,6 +1808,18 @@ open class Storage @Inject constructor( ) } + override fun addReactions( + reactions: Map>, + replaceAll: Boolean, + notifyUnread: Boolean + ) { + reactionDatabase.addReactions( + reactionsByMessageId = reactions, + replaceAll = replaceAll, + notifyUnread = notifyUnread + ) + } + override fun removeReaction( emoji: String, messageTimestamp: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 853fc6775f..e6feb8794a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -100,9 +100,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV47 = 68; private static final int lokiV48 = 69; private static final int lokiV49 = 70; + private static final int lokiV50 = 71; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV49; + private static final int DATABASE_VERSION = lokiV50; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -229,6 +230,7 @@ public void onCreate(SQLiteDatabase db) { executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); + executeStatements(db, ReactionDatabase.CREATE_MESSAGE_ID_MMS_INDEX); db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); @@ -533,6 +535,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(LokiMessageDatabase.getUpdateErrorMessageTableCommand()); } + if (oldVersion < lokiV50) { + executeStatements(db, ReactionDatabase.CREATE_MESSAGE_ID_MMS_INDEX); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index e8000318c3..f46216678f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -11,6 +11,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY @@ -31,6 +32,7 @@ class OpenGroupManager @Inject constructor( private val threadDb: ThreadDatabase, private val configFactory: ConfigFactoryProtocol, private val groupMemberDatabase: GroupMemberDatabase, + private val pollerManager: OpenGroupPollerManager, ) { // flow holding information on write access for our current communities @@ -69,6 +71,10 @@ class OpenGroupManager @Inject constructor( pollInfo = info.toPollInfo(), createGroupIfMissingWithPublicKey = publicKey ) + + // If existing poller for the same server exist, we'll request a poll once now so new room + // can be polled immediately. + pollerManager.pollers.value[server]?.poller?.requestPollOnce() } fun delete(server: String, room: String, context: Context) { From 867408a7c209593b3314f6986c94e332d1e92ba8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 10:57:44 +1000 Subject: [PATCH 377/867] Adapt options to blocked state --- .../settings/ConversationSettingsViewModel.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 8f8f2cfb59..a73fc979e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -288,13 +288,21 @@ class ConversationSettingsViewModel @AssistedInject constructor( mainOptions.addAll(listOf( optionCopyAccountId, - optionSearch, - optionDisappearingMessage(disappearingSubtitle), - if(pinned) optionUnpin else optionPin, - optionNotifications(notificationIconRes, notificationSubtitle), - optionAttachments, + optionSearch )) + // these options are only for users who aren't blocked + if(!conversation.isBlocked) { + mainOptions.addAll(listOf( + optionDisappearingMessage(disappearingSubtitle), + if(pinned) optionUnpin else optionPin, + optionNotifications(notificationIconRes, notificationSubtitle), + )) + } + + // finally add attachments + mainOptions.add(optionAttachments) + dangerOptions.addAll(listOf( if(recipient?.isBlocked == true) optionUnblock else optionBlock, optionClearMessages, From 00937b4c55e00e70c7556476b37b959e7080dc3a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 11:03:52 +1000 Subject: [PATCH 378/867] Long pressing a message when blocked should show the unblock dialog --- .../conversation/v2/ConversationActivityV2.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index a529f61dcf..e8a7ca17cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -377,10 +377,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, handleSwipeToReply(message) }, onItemLongPress = { message, position, view -> - if (!viewModel.isMessageRequestThread) { - showConversationReaction(message, view) - } else { - selectMessage(message, position) + // long pressing message for blocked users should show unblock dialog + if(viewModel.recipient?.isBlocked == true) unblock() + else { + if (!viewModel.isMessageRequestThread) { + showConversationReaction(message, view) + } else { + selectMessage(message, position) + } } }, onDeselect = { message, position -> From 020ceffe27b68995f82e025efc084886c484d1e3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 11:50:41 +1000 Subject: [PATCH 379/867] QA-1714 - Showing the appropirate messaging when messaging a blinded user who hasn't yet approved us --- .../securesms/conversation/v2/ConversationActivityV2.kt | 5 +++-- .../securesms/conversation/v2/ConversationViewModel.kt | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index e8a7ca17cc..befc9fe43c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1408,8 +1408,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, .format() } - // 10n1 and groups - recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> { + // 10n1 and groups and blinded 1on1 + recipient.isCommunityInboxRecipient || recipient.isCommunityOutboxRecipient || + recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> { Phrase.from(applicationContext, R.string.groupNoMessages) .put(GROUP_NAME_KEY, recipient.name) .format() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 0df32c8882..3910267010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -112,7 +112,10 @@ class ConversationViewModel( ) : ViewModel() { val showSendAfterApprovalText: Boolean - get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false + get() = recipient?.run { + // if the contact is a 1on1 or a blinded 1on1 that doesn't block requests - and is not the current user - and has not yet approved us + (getBlindedRecipient(recipient)?.blocksCommunityMessageRequests == false || isContactRecipient) && !isLocalNumber && !hasApprovedMe() + } ?: false private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow get() = _uiState From 1bd1bd8036fdaccac538f75a0b93b836b8ec4976 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 15:13:06 +1000 Subject: [PATCH 380/867] SES-3870 - keep microphone while backgrounded --- app/src/main/AndroidManifest.xml | 3 ++- .../thoughtcrime/securesms/service/CallForegroundService.kt | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33598c7736..995057f4de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,7 @@ + @@ -349,7 +350,7 @@ = 30) ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL else 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } ) return } catch (e: IllegalStateException) { From ae11eaaf939c714511da1f8af00aae0bed714f01 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 16:08:02 +1000 Subject: [PATCH 381/867] SES-1838 - Animated webp broke because of the transform --- .../securesms/conversation/v2/utilities/ThumbnailView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 4d4e21d5f3..1df19790f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -178,7 +178,7 @@ open class ThumbnailView @JvmOverloads constructor( .diskCacheStrategy(DiskCacheStrategy.NONE) .overrideDimensions() .transition(DrawableTransitionOptions.withCrossFade()) - .transform(CenterCrop()) + .optionalTransform(CenterCrop()) .missingThumbnailPicture(slide.isInProgress, errorDrawable) private fun buildPlaceholderGlideRequest( From da1fc64e455eaa31dcdb9b7817cbf85a08b56941 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Jun 2025 16:35:59 +1000 Subject: [PATCH 382/867] SES-3710 - edge to edge message request --- .../messagerequests/MessageRequestsActivity.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 5fa8a50ff7..004c8a7050 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -3,8 +3,11 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Intent import android.database.Cursor import android.os.Bundle +import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import com.bumptech.glide.Glide @@ -23,6 +26,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.push @AndroidEntryPoint @@ -40,6 +44,9 @@ class MessageRequestsActivity : ScreenLockActionBarActivity(), ConversationClick MessageRequestsAdapter(context = this, cursor = null, dateUtils = dateUtils, listener = this) } + override val applyDefaultWindowInsets: Boolean + get() = false + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) binding = ActivityMessageRequestsBinding.inflate(layoutInflater) @@ -52,6 +59,10 @@ class MessageRequestsActivity : ScreenLockActionBarActivity(), ConversationClick binding.recyclerView.adapter = adapter binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() } + + binding.root.applySafeInsetsPaddings( + applyBottom = false, + ) } override fun onResume() { From 2b10b67b0cf6cbd7d2e5c162766cf90f04491ffa Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Jun 2025 09:48:46 +1000 Subject: [PATCH 383/867] Clea up on unused files and resources --- .../securesms/ScreenLockActivity.kt | 3 - .../components/ControllableTabLayout.java | 41 -- .../components/HidingLinearLayout.java | 76 --- .../components/NestedScrollableHost.kt | 112 ---- .../components/RecentPhotoViewRail.java | 164 ----- .../components/camera/CameraSurfaceView.java | 33 - .../components/camera/CameraUtils.java | 106 ---- .../components/camera/CameraView.java | 587 ------------------ .../main/res/animator/appbar_elevation.xml | 13 - .../res/drawable/compose_background_dark.xml | 16 - ...on_item_sent_indicator_text_shape_dark.xml | 18 - .../conversation_list_divider_shape_dark.xml | 9 - app/src/main/res/values/arrays.xml | 128 ---- app/src/main/res/values/attrs.xml | 4 - libsession/src/main/res/values/arrays.xml | 117 ---- 15 files changed, 1427 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java delete mode 100644 app/src/main/res/animator/appbar_elevation.xml delete mode 100644 app/src/main/res/drawable/compose_background_dark.xml delete mode 100644 app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml delete mode 100644 app/src/main/res/drawable/conversation_list_divider_shape_dark.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt index 281258e158..1c5ba1e8b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt @@ -43,7 +43,6 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.setScree import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockTimeout import org.session.libsession.utilities.ThemeUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.components.AnimatingToggle import org.thoughtcrime.securesms.crypto.BiometricSecretProvider import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.service.KeyCachingService.KeySetBinder @@ -52,7 +51,6 @@ class ScreenLockActivity : BaseActionBarActivity() { private val TAG: String = ScreenLockActivity::class.java.simpleName private lateinit var fingerprintPrompt: ImageView - private lateinit var visibilityToggle: AnimatingToggle private var biometricPrompt: BiometricPrompt? = null private var promptInfo: BiometricPrompt.PromptInfo? = null @@ -308,7 +306,6 @@ class ScreenLockActivity : BaseActionBarActivity() { .put(APP_NAME_KEY, getString(R.string.app_name)) .format().toString() - visibilityToggle = findViewById(R.id.button_toggle) fingerprintPrompt = findViewById(R.id.fingerprint_auth_container) fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java deleted file mode 100644 index 969945621f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import com.google.android.material.tabs.TabLayout; -import android.util.AttributeSet; -import android.view.View; - -import java.util.List; - -/** - * An implementation of {@link TabLayout} that disables taps when the view is disabled. - */ -public class ControllableTabLayout extends TabLayout { - - private List touchables; - - public ControllableTabLayout(Context context) { - super(context); - } - - public ControllableTabLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public ControllableTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setEnabled(boolean enabled) { - if (isEnabled() && !enabled) { - touchables = getTouchables(); - } - - for (View touchable : touchables) { - touchable.setClickable(enabled); - } - - super.setEnabled(enabled); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java deleted file mode 100644 index bdb7c2fdf0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.view.animation.ScaleAnimation; -import android.widget.LinearLayout; - -import androidx.interpolator.view.animation.FastOutSlowInInterpolator; - -public class HidingLinearLayout extends LinearLayout { - - public HidingLinearLayout(Context context) { - super(context); - } - - public HidingLinearLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void hide() { - if (!isEnabled() || getVisibility() == GONE) return; - - AnimationSet animation = new AnimationSet(true); - animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); - animation.addAnimation(new AlphaAnimation(1, 0)); - animation.setDuration(100); - - animation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - setVisibility(GONE); - } - }); - - animateWith(animation); - } - - public void show() { - if (!isEnabled() || getVisibility() == VISIBLE) return; - - setVisibility(VISIBLE); - - AnimationSet animation = new AnimationSet(true); - animation.addAnimation(new ScaleAnimation(0.5f, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); - animation.addAnimation(new AlphaAnimation(0, 1)); - animation.setDuration(100); - - animateWith(animation); - } - - private void animateWith(Animation animation) { - animation.setDuration(150); - animation.setInterpolator(new FastOutSlowInInterpolator()); - startAnimation(animation); - } - - public void disable() { - setVisibility(GONE); - setEnabled(false); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt deleted file mode 100644 index ef27c307c7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.thoughtcrime.securesms.components - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.widget.FrameLayout -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL -import kotlin.math.absoluteValue -import kotlin.math.sign - -/** - * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem - * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as - * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. - * - * This solution has limitations when using multiple levels of nested scrollable elements - * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). - */ -class NestedScrollableHost : FrameLayout { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - private var touchSlop = 0 - private var initialX = 0f - private var initialY = 0f - private val parentViewPager: ViewPager2? - get() { - var v: View? = parent as? View - while (v != null && v !is ViewPager2) { - v = v.parent as? View - } - return v as? ViewPager2 - } - - private val child: View? get() = if (childCount > 0) getChildAt(0) else null - - init { - touchSlop = ViewConfiguration.get(context).scaledTouchSlop - } - - private fun canChildScroll(orientation: Int, delta: Float): Boolean { - val direction = -delta.sign.toInt() - return when (orientation) { - 0 -> child?.canScrollHorizontally(direction) ?: false - 1 -> child?.canScrollVertically(direction) ?: false - else -> throw IllegalArgumentException() - } - } - - override fun onInterceptTouchEvent(e: MotionEvent): Boolean { - handleInterceptTouchEvent(e) - return super.onInterceptTouchEvent(e) - } - - private fun handleInterceptTouchEvent(e: MotionEvent) { - val orientation = parentViewPager?.orientation ?: return - - // Early return if child can't scroll in same direction as parent - if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) { - return - } - - if (e.action == MotionEvent.ACTION_DOWN) { - initialX = e.x - initialY = e.y - parent.requestDisallowInterceptTouchEvent(true) - } else if (e.action == MotionEvent.ACTION_MOVE) { - val dx = e.x - initialX - val dy = e.y - initialY - val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL - - // assuming ViewPager2 touch-slop is 2x touch-slop of child - val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f - val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f - - if (scaledDx > touchSlop || scaledDy > touchSlop) { - if (isVpHorizontal == (scaledDy > scaledDx)) { - // Gesture is perpendicular, allow all parents to intercept - parent.requestDisallowInterceptTouchEvent(false) - } else { - // Gesture is parallel, query child if movement in that direction is possible - if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { - // Child can scroll, disallow all parents to intercept - parent.requestDisallowInterceptTouchEvent(true) - } else { - // Child cannot scroll, allow all parents to intercept - parent.requestDisallowInterceptTouchEvent(false) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java deleted file mode 100644 index 98bce61010..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java +++ /dev/null @@ -1,164 +0,0 @@ -package org.thoughtcrime.securesms.components; - - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.Key; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.signature.MediaStoreSignature; - -import org.session.libsession.utilities.ViewUtil; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; -import org.thoughtcrime.securesms.database.loaders.RecentPhotosLoader; - -import network.loki.messenger.R; - -public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.LoaderCallbacks { - - @NonNull private final RecyclerView recyclerView; - @Nullable private OnItemClickedListener listener; - - public RecentPhotoViewRail(Context context) { - this(context, null); - } - - public RecentPhotoViewRail(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RecentPhotoViewRail(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - inflate(context, R.layout.recent_photo_view, this); - - this.recyclerView = ViewUtil.findById(this, R.id.photo_list); - this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); - this.recyclerView.setItemAnimator(new DefaultItemAnimator()); - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.listener = listener; - - if (this.recyclerView.getAdapter() != null) { - ((RecentPhotoAdapter)this.recyclerView.getAdapter()).setListener(listener); - } - } - - @Override - public @NonNull Loader onCreateLoader(int id, Bundle args) { - return new RecentPhotosLoader(getContext()); - } - - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - this.recyclerView.setAdapter(new RecentPhotoAdapter(getContext(), data, RecentPhotosLoader.BASE_URL, listener)); - } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); - } - - private static class RecentPhotoAdapter extends CursorRecyclerViewAdapter { - - @SuppressWarnings("unused") - private static final String TAG = RecentPhotoAdapter.class.getSimpleName(); - - @NonNull private final Uri baseUri; - @Nullable private OnItemClickedListener clickedListener; - - private RecentPhotoAdapter(@NonNull Context context, @NonNull Cursor cursor, @NonNull Uri baseUri, @Nullable OnItemClickedListener listener) { - super(context, cursor); - this.baseUri = baseUri; - this.clickedListener = listener; - } - - @Override - public RecentPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { - View itemView = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.recent_photo_view_item, parent, false); - - return new RecentPhotoViewHolder(itemView); - } - - @Override - public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { - viewHolder.imageView.setImageDrawable(null); - - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); - long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); - long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); - String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); - String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID)); - int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); - long size = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE)); - int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); - int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); - - final Uri uri = Uri.withAppendedPath(baseUri, Long.toString(id)); - - Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); - - Glide.with(getContext().getApplicationContext()) - .load(uri) - .signature(signature) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(viewHolder.imageView); - - viewHolder.imageView.setOnClickListener(v -> { - if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height, size); - }); - - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getWidthColumn(int orientation) { - if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.WIDTH; - else return MediaStore.Images.ImageColumns.HEIGHT; - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getHeightColumn(int orientation) { - if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.HEIGHT; - else return MediaStore.Images.ImageColumns.WIDTH; - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.clickedListener = listener; - } - - static class RecentPhotoViewHolder extends RecyclerView.ViewHolder { - - ImageView imageView; - - RecentPhotoViewHolder(View itemView) { - super(itemView); - - this.imageView = ViewUtil.findById(itemView, R.id.thumbnail); - } - } - } - - public interface OnItemClickedListener { - void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java deleted file mode 100644 index 7a991eae5c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.thoughtcrime.securesms.components.camera; - -import android.content.Context; -import android.view.SurfaceHolder; -import android.view.SurfaceView; - -public class CameraSurfaceView extends SurfaceView implements SurfaceHolder.Callback { - private boolean ready; - - @SuppressWarnings("deprecation") - public CameraSurfaceView(Context context) { - super(context); - getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); - getHolder().addCallback(this); - } - - public boolean isReady() { - return ready; - } - - @Override - public void surfaceCreated(SurfaceHolder holder) { - ready = true; - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - ready = false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java deleted file mode 100644 index 1aea994a98..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.thoughtcrime.securesms.components.camera; - -import android.app.Activity; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.hardware.Camera.Parameters; -import android.hardware.Camera.Size; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.DisplayMetrics; -import org.session.libsignal.utilities.Log; -import android.view.Surface; - -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedList; -import java.util.List; - -@SuppressWarnings("deprecation") -public class CameraUtils { - private static final String TAG = CameraUtils.class.getSimpleName(); - /* - * modified from: https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java - */ - public static @Nullable Size getPreferredPreviewSize(int displayOrientation, - int width, - int height, - @NonNull Parameters parameters) { - final int targetWidth = displayOrientation % 180 == 90 ? height : width; - final int targetHeight = displayOrientation % 180 == 90 ? width : height; - final double targetRatio = (double) targetWidth / targetHeight; - - Log.d(TAG, String.format("getPreferredPreviewSize(%d, %d, %d) -> target %dx%d, AR %.02f", - displayOrientation, width, height, - targetWidth, targetHeight, targetRatio)); - - List sizes = parameters.getSupportedPreviewSizes(); - List ideals = new LinkedList<>(); - List bigEnough = new LinkedList<>(); - - for (Size size : sizes) { - Log.d(TAG, String.format(" %dx%d (%.02f)", size.width, size.height, (float)size.width / size.height)); - - if (size.height == size.width * targetRatio && size.height >= targetHeight && size.width >= targetWidth) { - ideals.add(size); - Log.d(TAG, " (ideal ratio)"); - } else if (size.width >= targetWidth && size.height >= targetHeight) { - bigEnough.add(size); - Log.d(TAG, " (good size, suboptimal ratio)"); - } - } - - if (!ideals.isEmpty()) return Collections.min(ideals, new AreaComparator()); - else if (!bigEnough.isEmpty()) return Collections.min(bigEnough, new AspectRatioComparator(targetRatio)); - else return Collections.max(sizes, new AreaComparator()); - } - - // based on - // http://developer.android.com/reference/android/hardware/Camera.html#setDisplayOrientation(int) - // and http://stackoverflow.com/a/10383164/115145 - public static int getCameraDisplayOrientation(@NonNull Activity activity, - @NonNull CameraInfo info) - { - int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); - int degrees = 0; - DisplayMetrics dm = new DisplayMetrics(); - - activity.getWindowManager().getDefaultDisplay().getMetrics(dm); - - switch (rotation) { - case Surface.ROTATION_0: degrees = 0; break; - case Surface.ROTATION_90: degrees = 90; break; - case Surface.ROTATION_180: degrees = 180; break; - case Surface.ROTATION_270: degrees = 270; break; - } - - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - return (360 - ((info.orientation + degrees) % 360)) % 360; - } else { - return (info.orientation - degrees + 360) % 360; - } - } - - private static class AreaComparator implements Comparator { - @Override - public int compare(Size lhs, Size rhs) { - return Long.signum(lhs.width * lhs.height - rhs.width * rhs.height); - } - } - - private static class AspectRatioComparator extends AreaComparator { - private final double target; - public AspectRatioComparator(double target) { - this.target = target; - } - - @Override - public int compare(Size lhs, Size rhs) { - final double lhsDiff = Math.abs(target - (double) lhs.width / lhs.height); - final double rhsDiff = Math.abs(target - (double) rhs.width / rhs.height); - if (lhsDiff < rhsDiff) return -1; - else if (lhsDiff > rhsDiff) return 1; - else return super.compare(lhs, rhs); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java deleted file mode 100644 index 5b04e39289..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java +++ /dev/null @@ -1,587 +0,0 @@ -/*** - Copyright (c) 2013-2014 CommonsWare, LLC - Portions Copyright (C) 2007 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); you may - not use this file except in compliance with the License. You may obtain - a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -package org.thoughtcrime.securesms.components.camera; - -import android.app.Activity; -import android.content.Context; -import android.content.pm.ActivityInfo; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.Rect; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.hardware.Camera.Parameters; -import android.hardware.Camera.Size; -import android.os.AsyncTask; -import android.os.Build; -import android.util.AttributeSet; -import android.view.OrientationEventListener; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.util.BitmapUtil; - -import java.io.IOException; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -@SuppressWarnings("deprecation") -public class CameraView extends ViewGroup { - private static final String TAG = CameraView.class.getSimpleName(); - - private final CameraSurfaceView surface; - private final OnOrientationChange onOrientationChange; - - private volatile Optional camera = Optional.absent(); - private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK; - private volatile int displayOrientation = -1; - - private @NonNull State state = State.PAUSED; - private @Nullable Size previewSize; - private @NonNull List listeners = Collections.synchronizedList(new LinkedList()); - private int outputOrientation = -1; - - public CameraView(Context context) { - this(context, null); - } - - public CameraView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public CameraView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - setBackgroundColor(Color.BLACK); - - if (attrs != null) { - TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CameraView); - int camera = typedArray.getInt(R.styleable.CameraView_camera, -1); - - if (camera != -1) cameraId = camera; - else if (isMultiCamera()) cameraId = TextSecurePreferences.getDirectCaptureCameraId(context); - - typedArray.recycle(); - } - - surface = new CameraSurfaceView(getContext()); - onOrientationChange = new OnOrientationChange(context.getApplicationContext()); - addView(surface); - } - - public void onResume() { - if (state != State.PAUSED) return; - state = State.RESUMED; - Log.i(TAG, "onResume() queued"); - enqueueTask(new SerialAsyncTask() { - @Override - protected - @Nullable - Void onRunBackground() { - try { - long openStartMillis = System.currentTimeMillis(); - camera = Optional.fromNullable(Camera.open(cameraId)); - Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms"); - synchronized (CameraView.this) { - CameraView.this.notifyAll(); - } - if (camera.isPresent()) onCameraReady(camera.get()); - } catch (Exception e) { - Log.w(TAG, e); - } - return null; - } - - @Override - protected void onPostMain(Void avoid) { - if (!camera.isPresent()) { - Log.w(TAG, "tried to open camera but got null"); - for (CameraViewListener listener : listeners) { - listener.onCameraFail(); - } - return; - } - - if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { - onOrientationChange.enable(); - } - Log.i(TAG, "onResume() completed"); - } - }); - } - - public void onPause() { - if (state == State.PAUSED) return; - state = State.PAUSED; - Log.i(TAG, "onPause() queued"); - - enqueueTask(new SerialAsyncTask() { - private Optional cameraToDestroy; - - @Override - protected void onPreMain() { - cameraToDestroy = camera; - camera = Optional.absent(); - } - - @Override - protected Void onRunBackground() { - if (cameraToDestroy.isPresent()) { - try { - stopPreview(); - cameraToDestroy.get().setPreviewCallback(null); - cameraToDestroy.get().release(); - Log.w(TAG, "released old camera instance"); - } catch (Exception e) { - Log.w(TAG, e); - } - } - return null; - } - - @Override protected void onPostMain(Void avoid) { - onOrientationChange.disable(); - displayOrientation = -1; - outputOrientation = -1; - removeView(surface); - addView(surface); - Log.i(TAG, "onPause() completed"); - } - }); - - for (CameraViewListener listener : listeners) { - listener.onCameraStop(); - } - } - - public boolean isStarted() { - return state != State.PAUSED; - } - - @SuppressWarnings("SuspiciousNameCombination") - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - final int width = r - l; - final int height = b - t; - final int previewWidth; - final int previewHeight; - - if (camera.isPresent() && previewSize != null) { - if (displayOrientation == 90 || displayOrientation == 270) { - previewWidth = previewSize.height; - previewHeight = previewSize.width; - } else { - previewWidth = previewSize.width; - previewHeight = previewSize.height; - } - } else { - previewWidth = width; - previewHeight = height; - } - - if (previewHeight == 0 || previewWidth == 0) { - Log.w(TAG, "skipping layout due to zero-width/height preview size"); - return; - } - - if (width * previewHeight > height * previewWidth) { - final int scaledChildHeight = previewHeight * width / previewWidth; - surface.layout(0, (height - scaledChildHeight) / 2, width, (height + scaledChildHeight) / 2); - } else { - final int scaledChildWidth = previewWidth * height / previewHeight; - surface.layout((width - scaledChildWidth) / 2, 0, (width + scaledChildWidth) / 2, height); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - Log.i(TAG, "onSizeChanged(" + oldw + "x" + oldh + " -> " + w + "x" + h + ")"); - super.onSizeChanged(w, h, oldw, oldh); - if (camera.isPresent()) startPreview(camera.get().getParameters()); - } - - public void addListener(@NonNull CameraViewListener listener) { - listeners.add(listener); - } - - public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) { - enqueueTask(new PostInitializationTask() { - @Override - protected void onPostMain(Void avoid) { - if (camera.isPresent()) { - camera.get().setPreviewCallback(new Camera.PreviewCallback() { - @Override - public void onPreviewFrame(byte[] data, Camera camera) { - if (!CameraView.this.camera.isPresent()) { - return; - } - - final int rotation = getCameraPictureOrientation(); - final Size previewSize = camera.getParameters().getPreviewSize(); - if (data != null) { - previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation)); - } - } - }); - } - } - }); - } - - public boolean isMultiCamera() { - return Camera.getNumberOfCameras() > 1; - } - - private void onCameraReady(final @NonNull Camera camera) { - final Parameters parameters = camera.getParameters(); - - parameters.setRecordingHint(true); - final List focusModes = parameters.getSupportedFocusModes(); - if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { - parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); - } else if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { - parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); - } - - displayOrientation = CameraUtils.getCameraDisplayOrientation(getActivity(), getCameraInfo()); - camera.setDisplayOrientation(displayOrientation); - camera.setParameters(parameters); - enqueueTask(new PostInitializationTask() { - @Override - protected Void onRunBackground() { - try { - camera.setPreviewDisplay(surface.getHolder()); - startPreview(parameters); - } catch (Exception e) { - Log.w(TAG, "couldn't set preview display", e); - } - return null; - } - }); - } - - private void startPreview(final @NonNull Parameters parameters) { - if (this.camera.isPresent()) { - try { - final Camera camera = this.camera.get(); - final Size preferredPreviewSize = getPreferredPreviewSize(parameters); - - if (preferredPreviewSize != null && !parameters.getPreviewSize().equals(preferredPreviewSize)) { - Log.i(TAG, "starting preview with size " + preferredPreviewSize.width + "x" + preferredPreviewSize.height); - if (state == State.ACTIVE) stopPreview(); - previewSize = preferredPreviewSize; - parameters.setPreviewSize(preferredPreviewSize.width, preferredPreviewSize.height); - camera.setParameters(parameters); - } else { - previewSize = parameters.getPreviewSize(); - } - long previewStartMillis = System.currentTimeMillis(); - camera.startPreview(); - Log.i(TAG, "camera.startPreview() -> " + (System.currentTimeMillis() - previewStartMillis) + "ms"); - state = State.ACTIVE; - Util.runOnMain(new Runnable() { - @Override - public void run() { - requestLayout(); - for (CameraViewListener listener : listeners) { - listener.onCameraStart(); - } - } - }); - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - private void stopPreview() { - if (camera.isPresent()) { - try { - camera.get().stopPreview(); - state = State.RESUMED; - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - - private Size getPreferredPreviewSize(@NonNull Parameters parameters) { - return CameraUtils.getPreferredPreviewSize(displayOrientation, - getMeasuredWidth(), - getMeasuredHeight(), - parameters); - } - - private int getCameraPictureOrientation() { - if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { - outputOrientation = getCameraPictureRotation(getActivity().getWindowManager() - .getDefaultDisplay() - .getOrientation()); - } else if (getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT) { - outputOrientation = (360 - displayOrientation) % 360; - } else { - outputOrientation = displayOrientation; - } - - return outputOrientation; - } - - // https://github.com/signalapp/Signal-Android/issues/4715 - private boolean isTroublemaker() { - return getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT && - "JWR66Y".equals(Build.DISPLAY) && - "yakju".equals(Build.PRODUCT); - } - - private @NonNull CameraInfo getCameraInfo() { - final CameraInfo info = new Camera.CameraInfo(); - Camera.getCameraInfo(cameraId, info); - return info; - } - - // XXX this sucks - private Activity getActivity() { - return (Activity)getContext(); - } - - public int getCameraPictureRotation(int orientation) { - final CameraInfo info = getCameraInfo(); - final int rotation; - - orientation = (orientation + 45) / 90 * 90; - - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - rotation = (info.orientation - orientation + 360) % 360; - } else { - rotation = (info.orientation + orientation) % 360; - } - - return rotation; - } - - private class OnOrientationChange extends OrientationEventListener { - public OnOrientationChange(Context context) { - super(context); - disable(); - } - - @Override - public void onOrientationChanged(int orientation) { - if (camera.isPresent() && orientation != ORIENTATION_UNKNOWN) { - int newOutputOrientation = getCameraPictureRotation(orientation); - - if (newOutputOrientation != outputOrientation) { - outputOrientation = newOutputOrientation; - - Camera.Parameters params = camera.get().getParameters(); - - params.setRotation(outputOrientation); - - try { - camera.get().setParameters(params); - } - catch (Exception e) { - Log.e(TAG, "Exception updating camera parameters in orientation change", e); - } - } - } - } - } - - public void takePicture(final Rect previewRect) { - if (!camera.isPresent() || camera.get().getParameters() == null) { - Log.w(TAG, "camera not in capture-ready state"); - return; - } - - camera.get().setOneShotPreviewCallback(new Camera.PreviewCallback() { - @Override - public void onPreviewFrame(byte[] data, final Camera camera) { - final int rotation = getCameraPictureOrientation(); - final Size previewSize = camera.getParameters().getPreviewSize(); - final Rect croppingRect = getCroppedRect(previewSize, previewRect, rotation); - - Log.i(TAG, "previewSize: " + previewSize.width + "x" + previewSize.height); - Log.i(TAG, "data bytes: " + data.length); - Log.i(TAG, "previewFormat: " + camera.getParameters().getPreviewFormat()); - Log.i(TAG, "croppingRect: " + croppingRect.toString()); - Log.i(TAG, "rotation: " + rotation); - new CaptureTask(previewSize, rotation, croppingRect).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data); - } - }); - } - - private Rect getCroppedRect(Size cameraPreviewSize, Rect visibleRect, int rotation) { - final int previewWidth = cameraPreviewSize.width; - final int previewHeight = cameraPreviewSize.height; - - if (rotation % 180 > 0) rotateRect(visibleRect); - - float scale = (float) previewWidth / visibleRect.width(); - if (visibleRect.height() * scale > previewHeight) { - scale = (float) previewHeight / visibleRect.height(); - } - final float newWidth = visibleRect.width() * scale; - final float newHeight = visibleRect.height() * scale; - final float centerX = (isTroublemaker()) ? previewWidth - newWidth / 2 : previewWidth / 2; - final float centerY = previewHeight / 2; - - visibleRect.set((int) (centerX - newWidth / 2), - (int) (centerY - newHeight / 2), - (int) (centerX + newWidth / 2), - (int) (centerY + newHeight / 2)); - - if (rotation % 180 > 0) rotateRect(visibleRect); - return visibleRect; - } - - @SuppressWarnings("SuspiciousNameCombination") - private void rotateRect(Rect rect) { - rect.set(rect.top, rect.left, rect.bottom, rect.right); - } - - private void enqueueTask(SerialAsyncTask job) { - AsyncTask.SERIAL_EXECUTOR.execute(job); - } - - public static abstract class SerialAsyncTask implements Runnable { - - @Override - public final void run() { - if (!onWait()) { - Log.w(TAG, "skipping task, preconditions not met in onWait()"); - return; - } - - Util.runOnMainSync(this::onPreMain); - final Result result = onRunBackground(); - Util.runOnMainSync(() -> onPostMain(result)); - } - - protected boolean onWait() { return true; } - protected void onPreMain() {} - protected Result onRunBackground() { return null; } - protected void onPostMain(Result result) {} - } - - private abstract class PostInitializationTask extends SerialAsyncTask { - @Override protected boolean onWait() { - synchronized (CameraView.this) { - if (!camera.isPresent()) { - return false; - } - while (getMeasuredHeight() <= 0 || getMeasuredWidth() <= 0 || !surface.isReady()) { - Log.i(TAG, String.format("waiting. surface ready? %s", surface.isReady())); - Util.wait(CameraView.this, 0); - } - return true; - } - } - } - - private class CaptureTask extends AsyncTask { - private final Size previewSize; - private final int rotation; - private final Rect croppingRect; - - public CaptureTask(Size previewSize, int rotation, Rect croppingRect) { - this.previewSize = previewSize; - this.rotation = rotation; - this.croppingRect = croppingRect; - } - - @Override - protected byte[] doInBackground(byte[]... params) { - final byte[] data = params[0]; - try { - return BitmapUtil.createFromNV21(data, - previewSize.width, - previewSize.height, - rotation, - croppingRect, - cameraId == CameraInfo.CAMERA_FACING_FRONT); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - @Override - protected void onPostExecute(byte[] imageBytes) { - if (imageBytes != null) { - for (CameraViewListener listener : listeners) { - listener.onImageCapture(imageBytes); - } - } - } - } - - private static class PreconditionsNotMetException extends Exception {} - - public interface CameraViewListener { - void onImageCapture(@NonNull final byte[] imageBytes); - void onCameraFail(); - void onCameraStart(); - void onCameraStop(); - } - - public interface PreviewCallback { - void onPreviewFrame(@NonNull PreviewFrame frame); - } - - public static class PreviewFrame { - private final @NonNull byte[] data; - private final int width; - private final int height; - private final int orientation; - - private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) { - this.data = data; - this.width = width; - this.height = height; - this.orientation = orientation; - } - - public @NonNull byte[] getData() { - return data; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public int getOrientation() { - return orientation; - } - } - - private enum State { - PAUSED, RESUMED, ACTIVE - } -} diff --git a/app/src/main/res/animator/appbar_elevation.xml b/app/src/main/res/animator/appbar_elevation.xml deleted file mode 100644 index 7a7f123d25..0000000000 --- a/app/src/main/res/animator/appbar_elevation.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_background_dark.xml b/app/src/main/res/drawable/compose_background_dark.xml deleted file mode 100644 index 56859db153..0000000000 --- a/app/src/main/res/drawable/compose_background_dark.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml b/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml deleted file mode 100644 index d02a391561..0000000000 --- a/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml b/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml deleted file mode 100644 index 425c926052..0000000000 --- a/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9637bc3a70..abeb6fe7a9 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,129 +1,6 @@ - - @string/theDefault - English - Arabic العربية - Azərbaycan - Bulgarian български - Burmese ဗမာစကား - Català - Čeština - Chinese (Simplified) 中文 (简体) - Chinese (Traditional) 中文 (繁體) - Cymraeg - Dansk - Deutsch - Eesti - Español - Esperanto - Euskara - Français - Gaeilge - Galego - Greek ελληνικά - Hebrew עברית - Hindi हिंदी - Hrvatski - Indonesia - Italiano - Japanese 日本語 - Khmer ភាសាខ្មែរ - Kiswahili - Korean 한국어 - Kurdí - Lietuvių - Luganda - Magyar - Macedonian македонски јазик - Nederlands - Norsk (bokmål) - Norsk (nynorsk) - Persian فارسی - Polski - Português - Português do Brasil - Quechua - Română - Russian Pусский - Serbian српски - Shqip - Slovenščina - Slovenský - Suomi - Svenska - Telugu తెలుగు - Thai ภาษาไทย - Türkçe - Ukrainian Українська - Vietnamese Tiếng Việt - - - - zz - en - ar - az - bg - my - ca - cs - zh_CN - zh_TW - cy - da - de - et - es - eo - eu - fr - ga - gl - el - iw - hi - hr - in - it - ja - km - sw - ko - ku - lt - lg - hu - mk - nl - nb - nn - fa - pl - pt - pt_BR - qu_EC - ro - ru - sr - sq - sl - sk - fi - sv - te - th - tr - uk - vi - - - - default - custom - - @string/notificationsContentShowNameAndContent @string/notificationsContentShowNameOnly @@ -148,9 +25,4 @@ #000000 - - @string/notificationsAllMessages - @string/notificationsMentionsOnly - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 52cc05d918..cad40ab9c9 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -129,10 +129,6 @@ - - - - diff --git a/libsession/src/main/res/values/arrays.xml b/libsession/src/main/res/values/arrays.xml index 957b176565..fd188b1eb1 100644 --- a/libsession/src/main/res/values/arrays.xml +++ b/libsession/src/main/res/values/arrays.xml @@ -1,123 +1,6 @@ - - English - Arabic العربية - Azərbaycan - Bulgarian български - Burmese ဗမာစကား - Català - Čeština - Chinese (Simplified) 中文 (简体) - Chinese (Traditional) 中文 (繁體) - Cymraeg - Dansk - Deutsch - Eesti - Español - Esperanto - Euskara - Français - Gaeilge - Galego - Greek ελληνικά - Hebrew עברית - Hindi हिंदी - Hrvatski - Indonesia - Italiano - Japanese 日本語 - Khmer ភាសាខ្មែរ - Kiswahili - Korean 한국어 - Kurdí - Lietuvių - Luganda - Magyar - Macedonian македонски јазик - Nederlands - Norsk (bokmål) - Norsk (nynorsk) - Persian فارسی - Polski - Português - Português do Brasil - Quechua - Română - Russian Pусский - Serbian српски - Shqip - Slovenščina - Slovenský - Suomi - Svenska - Telugu తెలుగు - Thai ภาษาไทย - Türkçe - Ukrainian Українська - Vietnamese Tiếng Việt - - - - zz - en - ar - az - bg - my - ca - cs - zh_CN - zh_TW - cy - da - de - et - es - eo - eu - fr - ga - gl - el - iw - hi - hr - in - it - ja - km - sw - ko - ku - lt - lg - hu - mk - nl - nb - nn - fa - pl - pt - pt_BR - qu_EC - ro - ru - sr - sq - sl - sk - fi - sv - te - th - tr - uk - vi - - default custom From c1f474c9e822fb202bfbbb46ccd92e2fa9af8f0e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Jun 2025 10:35:43 +1000 Subject: [PATCH 384/867] More unused classes --- .../libsession/database/CallDataProvider.kt | 7 -- .../NonSuccessfulResponseCodeException.java | 16 --- .../exceptions/PushNetworkException.java | 21 ---- .../messages/SignalServiceContent.java | 103 ------------------ .../messages/SignalServiceReceiptMessage.java | 42 ------- .../messages/SignalServiceTypingMessage.java | 34 ------ .../libsignal/streams/StreamDetails.java | 28 ----- .../securesms/database/DatabaseFactory.java | 33 ------ .../database/loaders/RecentPhotosLoader.java | 48 -------- .../database/loaders/ThreadMediaLoader.java | 37 ------- .../thoughtcrime/securesms/home/HomeLoader.kt | 18 --- .../thoughtcrime/securesms/util/Trimmer.java | 63 ----------- 12 files changed, 450 deletions(-) delete mode 100644 app/src/main/java/org/session/libsession/database/CallDataProvider.kt delete mode 100644 app/src/main/java/org/session/libsignal/exceptions/NonSuccessfulResponseCodeException.java delete mode 100644 app/src/main/java/org/session/libsignal/exceptions/PushNetworkException.java delete mode 100644 app/src/main/java/org/session/libsignal/messages/SignalServiceContent.java delete mode 100644 app/src/main/java/org/session/libsignal/messages/SignalServiceReceiptMessage.java delete mode 100644 app/src/main/java/org/session/libsignal/messages/SignalServiceTypingMessage.java delete mode 100644 app/src/main/java/org/session/libsignal/streams/StreamDetails.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java diff --git a/app/src/main/java/org/session/libsession/database/CallDataProvider.kt b/app/src/main/java/org/session/libsession/database/CallDataProvider.kt deleted file mode 100644 index 8ca27b50db..0000000000 --- a/app/src/main/java/org/session/libsession/database/CallDataProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.session.libsession.database - -interface CallDataProvider { - // answer/offer for call by UUID - // recipient info for call by UUID - -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsignal/exceptions/NonSuccessfulResponseCodeException.java b/app/src/main/java/org/session/libsignal/exceptions/NonSuccessfulResponseCodeException.java deleted file mode 100644 index 8e3262ef48..0000000000 --- a/app/src/main/java/org/session/libsignal/exceptions/NonSuccessfulResponseCodeException.java +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (C) 2014-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package org.session.libsignal.exceptions; - -import java.io.IOException; - -public class NonSuccessfulResponseCodeException extends IOException { - - public NonSuccessfulResponseCodeException(String s) { - super(s); - } -} diff --git a/app/src/main/java/org/session/libsignal/exceptions/PushNetworkException.java b/app/src/main/java/org/session/libsignal/exceptions/PushNetworkException.java deleted file mode 100644 index 2b41fe8405..0000000000 --- a/app/src/main/java/org/session/libsignal/exceptions/PushNetworkException.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (C) 2014-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package org.session.libsignal.exceptions; - -import java.io.IOException; - -public class PushNetworkException extends IOException { - - public PushNetworkException(Exception exception) { - super(exception); - } - - public PushNetworkException(String s) { - super(s); - } - -} diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceContent.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceContent.java deleted file mode 100644 index fe3a0a9df7..0000000000 --- a/app/src/main/java/org/session/libsignal/messages/SignalServiceContent.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2014-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package org.session.libsignal.messages; - -import org.session.libsignal.utilities.guava.Optional; -import org.session.libsignal.protos.SignalServiceProtos; - -public class SignalServiceContent { - private final String sender; - private final int senderDevice; - private final long timestamp; - private final boolean needsReceipt; - - // Loki - private Optional message; - private final Optional readMessage; - private final Optional typingMessage; - - // Loki - public Optional configurationMessageProto = Optional.absent(); - public Optional senderDisplayName = Optional.absent(); - public Optional senderProfilePictureURL = Optional.absent(); - - public SignalServiceContent(SignalServiceDataMessage message, String sender, int senderDevice, long timestamp, boolean needsReceipt) { - this.sender = sender; - this.senderDevice = senderDevice; - this.timestamp = timestamp; - this.needsReceipt = needsReceipt; - this.message = Optional.fromNullable(message); - this.readMessage = Optional.absent(); - this.typingMessage = Optional.absent(); - } - - public SignalServiceContent(SignalServiceReceiptMessage receiptMessage, String sender, int senderDevice, long timestamp) { - this.sender = sender; - this.senderDevice = senderDevice; - this.timestamp = timestamp; - this.needsReceipt = false; - this.message = Optional.absent(); - this.readMessage = Optional.of(receiptMessage); - this.typingMessage = Optional.absent(); - } - - public SignalServiceContent(SignalServiceTypingMessage typingMessage, String sender, int senderDevice, long timestamp) { - this.sender = sender; - this.senderDevice = senderDevice; - this.timestamp = timestamp; - this.needsReceipt = false; - this.message = Optional.absent(); - this.readMessage = Optional.absent(); - this.typingMessage = Optional.of(typingMessage); - } - - public SignalServiceContent(SignalServiceProtos.Content configurationMessageProto, String sender, int senderDevice, long timestamp) { - this.sender = sender; - this.senderDevice = senderDevice; - this.timestamp = timestamp; - this.needsReceipt = false; - this.message = Optional.absent(); - this.readMessage = Optional.absent(); - this.typingMessage = Optional.absent(); - this.configurationMessageProto = Optional.fromNullable(configurationMessageProto); - } - - public Optional getDataMessage() { - return message; - } - - public void setDataMessage(SignalServiceDataMessage message) { this.message = Optional.fromNullable(message); } - - public Optional getReceiptMessage() { - return readMessage; - } - - public Optional getTypingMessage() { - return typingMessage; - } - - public String getSender() { - return sender; - } - - public int getSenderDevice() { - return senderDevice; - } - - public long getTimestamp() { - return timestamp; - } - - public boolean isNeedsReceipt() { - return needsReceipt; - } - - // Loki - public void setSenderDisplayName(String displayName) { senderDisplayName = Optional.fromNullable(displayName); } - - public void setSenderProfilePictureURL(String url) { senderProfilePictureURL = Optional.fromNullable(url); } -} diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceReceiptMessage.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceReceiptMessage.java deleted file mode 100644 index 77371ab9ed..0000000000 --- a/app/src/main/java/org/session/libsignal/messages/SignalServiceReceiptMessage.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.session.libsignal.messages; - -import java.util.List; - -public class SignalServiceReceiptMessage { - - public enum Type { - UNKNOWN, DELIVERY, READ - } - - private final Type type; - private final List timestamps; - private final long when; - - public SignalServiceReceiptMessage(Type type, List timestamps, long when) { - this.type = type; - this.timestamps = timestamps; - this.when = when; - } - - public Type getType() { - return type; - } - - public List getTimestamps() { - return timestamps; - } - - public long getWhen() { - return when; - } - - public boolean isDeliveryReceipt() { - return type == Type.DELIVERY; - } - - public boolean isReadReceipt() { - return type == Type.READ; - } - - public int getTTL() { return 0; } -} diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceTypingMessage.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceTypingMessage.java deleted file mode 100644 index 579b09437b..0000000000 --- a/app/src/main/java/org/session/libsignal/messages/SignalServiceTypingMessage.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.session.libsignal.messages; - -public class SignalServiceTypingMessage { - - public enum Action { - UNKNOWN, STARTED, STOPPED - } - - private final Action action; - private final long timestamp; - - public SignalServiceTypingMessage(Action action, long timestamp) { - this.action = action; - this.timestamp = timestamp; - } - - public Action getAction() { - return action; - } - - public long getTimestamp() { - return timestamp; - } - - public boolean isTypingStarted() { - return action == Action.STARTED; - } - - public boolean isTypingStopped() { - return action == Action.STOPPED; - } - - public int getTTL() { return 0; } -} diff --git a/app/src/main/java/org/session/libsignal/streams/StreamDetails.java b/app/src/main/java/org/session/libsignal/streams/StreamDetails.java deleted file mode 100644 index 529e16beec..0000000000 --- a/app/src/main/java/org/session/libsignal/streams/StreamDetails.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.session.libsignal.streams; - -import java.io.InputStream; - -public class StreamDetails { - - private final InputStream stream; - private final String contentType; - private final long length; - - public StreamDetails(InputStream stream, String contentType, long length) { - this.stream = stream; - this.contentType = contentType; - this.length = length; - } - - public InputStream getStream() { - return stream; - } - - public String getContentType() { - return contentType; - } - - public long getLength() { - return length; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java deleted file mode 100644 index 76fa8c5c0b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ /dev/null @@ -1,33 +0,0 @@ - -/* - * Copyright (C) 2018 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.database; - -import android.content.Context; - -import net.zetetic.database.sqlcipher.SQLiteDatabase; - -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -public class DatabaseFactory { - public static void upgradeRestored(Context context, SQLiteDatabase database){ - SQLCipherOpenHelper databaseHelper = DatabaseComponent.get(context).openHelper(); - databaseHelper.onUpgrade(database, database.getVersion(), -1); - databaseHelper.markCurrent(database); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java deleted file mode 100644 index 21ed07ac66..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.database.loaders; - - -import android.Manifest; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.MediaStore; -import androidx.loader.content.CursorLoader; - -import org.thoughtcrime.securesms.permissions.Permissions; - -public class RecentPhotosLoader extends CursorLoader { - - public static Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - - private static final String[] PROJECTION = new String[] { - MediaStore.Images.ImageColumns._ID, - MediaStore.Images.ImageColumns.DATE_TAKEN, - MediaStore.Images.ImageColumns.DATE_MODIFIED, - MediaStore.Images.ImageColumns.ORIENTATION, - MediaStore.Images.ImageColumns.MIME_TYPE, - MediaStore.Images.ImageColumns.BUCKET_ID, - MediaStore.Images.ImageColumns.SIZE, - MediaStore.Images.ImageColumns.WIDTH, - MediaStore.Images.ImageColumns.HEIGHT - }; - - private final Context context; - - public RecentPhotosLoader(Context context) { - super(context); - this.context = context.getApplicationContext(); - } - - @Override - public Cursor loadInBackground() { - if (Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { - return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - PROJECTION, null, null, - MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); - } else { - return null; - } - } - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java deleted file mode 100644 index 3f5c108356..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.thoughtcrime.securesms.database.loaders; - - -import android.content.Context; -import android.database.Cursor; - -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.util.AbstractCursorLoader; - -public class ThreadMediaLoader extends AbstractCursorLoader { - - private final Address address; - private final boolean gallery; - - public ThreadMediaLoader(@NonNull Context context, @NonNull Address address, boolean gallery) { - super(context); - this.address = address; - this.gallery = gallery; - } - - @Override - public Cursor getCursor() { - long threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(Recipient.from(getContext(), address, true)); - - if (gallery) return DatabaseComponent.get(context).mediaDatabase().getGalleryMediaForThread(threadId); - else return DatabaseComponent.get(context).mediaDatabase().getDocumentMediaForThread(threadId); - } - - public Address getAddress() { - return address; - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt deleted file mode 100644 index 6935fb24a1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.content.Context -import android.database.Cursor -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.AbstractCursorLoader - -class HomeLoader(context: Context, val onNewCursor: (Cursor?) -> Unit) : AbstractCursorLoader(context) { - - override fun getCursor(): Cursor { - return DatabaseComponent.get(context).threadDatabase().approvedConversationList - } - - override fun deliverResult(newCursor: Cursor?) { - super.deliverResult(newCursor) - onNewCursor(newCursor) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java deleted file mode 100644 index 6707a078c8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.app.ProgressDialog; -import android.content.Context; -import android.os.AsyncTask; -import android.widget.Toast; - -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -import network.loki.messenger.R; - -public class Trimmer { - - public static void trimAllThreads(Context context, int threadLengthLimit) { - new TrimmingProgressTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadLengthLimit); - } - - private static class TrimmingProgressTask extends AsyncTask implements ThreadDatabase.ProgressListener { - private ProgressDialog progressDialog; - private Context context; - - public TrimmingProgressTask(Context context) { - this.context = context; - } - - @Override - protected void onPreExecute() { - progressDialog = new ProgressDialog(context); - progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); - progressDialog.setCancelable(false); - progressDialog.setIndeterminate(false); - progressDialog.setTitle(R.string.deleting); - progressDialog.setMessage(context.getString(R.string.deleting)); - progressDialog.setMax(100); - progressDialog.show(); - } - - @Override - protected Void doInBackground(Integer... params) { - DatabaseComponent.get(context).threadDatabase().trimAllThreads(params[0], this); - return null; - } - - @Override - protected void onProgressUpdate(Integer... progress) { - double count = progress[1]; - double index = progress[0]; - - progressDialog.setProgress((int)Math.round((index / count) * 100.0)); - } - - @Override - protected void onPostExecute(Void result) { - progressDialog.dismiss(); - } - - @Override - public void onProgress(int complete, int total) { - this.publishProgress(complete, total); - } - } -} From 0443934036659d425c439d96958b9a79f561a209 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Jun 2025 11:02:39 +1000 Subject: [PATCH 385/867] Updated donation URL --- .../org/thoughtcrime/securesms/preferences/SettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 05df0c839a..63ef19f23a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -610,7 +610,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { // donate confirmation if(showDonateDialog){ OpenURLAlertDialog( - url = "https://session.foundation/donate", + url = "https://session.foundation/donate#app", onDismissRequest = hideDonateDialog ) } From 5d32b1017f2f2447d4e39b01a93efbb4b51ea07f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Jun 2025 14:54:27 +1000 Subject: [PATCH 386/867] First steps to rebuild the camera fragment with camerax instead of the deprecated camera1 --- .../securesms/mediasend/Camera1Fragment.java | 346 ------------------ .../securesms/mediasend/CameraXFragment.kt | 209 +++++++++++ .../securesms/mediasend/MediaSendActivity.kt | 35 +- ...mera_fragment.xml => camerax_fragment.xml} | 38 +- 4 files changed, 249 insertions(+), 379 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt rename app/src/main/res/layout/{camera_fragment.xml => camerax_fragment.xml} (53%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java deleted file mode 100644 index 8005794653..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ /dev/null @@ -1,346 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.annotation.SuppressLint; - -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.lifecycle.ViewModelProvider; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.Matrix; -import android.graphics.Point; -import android.graphics.PointF; -import android.graphics.SurfaceTexture; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.Display; -import android.view.GestureDetector; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.RotateAnimation; -import android.widget.Button; -import android.widget.ImageButton; - -import com.bumptech.glide.load.MultiTransformation; -import com.bumptech.glide.load.Transformation; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.request.target.SimpleTarget; -import com.bumptech.glide.request.transition.Transition; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; -import org.session.libsignal.utilities.Log; -import com.bumptech.glide.Glide; -import org.session.libsession.utilities.ServiceUtil; -import org.thoughtcrime.securesms.util.Stopwatch; -import org.session.libsession.utilities.TextSecurePreferences; -import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - -import java.io.ByteArrayOutputStream; - -@AndroidEntryPoint -public class Camera1Fragment extends Fragment implements TextureView.SurfaceTextureListener, - Camera1Controller.EventListener -{ - - private static final String TAG = Camera1Fragment.class.getSimpleName(); - - private TextureView cameraPreview; - private ViewGroup controlsContainer; - private View cameraCloseButton; - private ImageButton flipButton; - private Button captureButton; - private Camera1Controller camera; - private Controller controller; - private OrderEnforcer orderEnforcer; - private Camera1Controller.Properties properties; - private MediaSendViewModel viewModel; - - public static Camera1Fragment newInstance() { - return new Camera1Fragment(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement the Controller interface."); - } - - WindowManager windowManager = ServiceUtil.getWindowManager(getActivity()); - Display display = windowManager.getDefaultDisplay(); - Point displaySize = new Point(); - - display.getSize(displaySize); - - controller = (Controller) getActivity(); - camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this); - orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.camera_fragment, container, false); - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - cameraPreview = view.findViewById(R.id.camera_preview); - controlsContainer = view.findViewById(R.id.camera_controls_container); - cameraCloseButton = view.findViewById(R.id.camera_close_button); - - ViewUtilitiesKt.applySafeInsetsPaddings(view.findViewById(R.id.camera_controls_safe_area)); - - onOrientationChanged(getResources().getConfiguration().orientation); - - cameraPreview.setSurfaceTextureListener(this); - - GestureDetector gestureDetector = new GestureDetector(flipGestureListener); - cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); - - cameraCloseButton.setOnClickListener(v -> requireActivity().onBackPressed()); - } - - @Override - public void onResume() { - super.onResume(); - viewModel.onCameraStarted(); - camera.initialize(); - - if (cameraPreview.isAvailable()) { - orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); - } - - if (properties != null) { - orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); - } - - orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> { - camera.linkSurface(cameraPreview.getSurfaceTexture()); - camera.setScreenRotation(controller.getDisplayRotation()); - }); - - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - - // Enter fullscreen mode - WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()) - .hide(WindowInsetsCompat.Type.systemBars()); - } - - @Override - public void onPause() { - super.onPause(); - camera.release(); - orderEnforcer.reset(); - - // Exit fullscreen mode - WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()) - .show(WindowInsetsCompat.Type.systemBars()); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onOrientationChanged(newConfig.orientation); - } - - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - Log.d(TAG, "onSurfaceTextureAvailable"); - orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> camera.setScreenRotation(controller.getDisplayRotation())); - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - return false; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - } - - @Override - public void onPropertiesAvailable(@NonNull Camera1Controller.Properties properties) { - Log.d(TAG, "Got camera properties: " + properties); - this.properties = properties; - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); - } - - @Override - public void onCameraUnavailable() { - controller.onCameraError(); - } - - @SuppressLint("ClickableViewAccessibility") - private void initControls() { - flipButton = getView().findViewById(R.id.camera_flip_button); - captureButton = getView().findViewById(R.id.camera_capture_button); - - captureButton.setOnTouchListener((v, event) -> { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - Animation shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink); - shrinkAnimation.setFillAfter(true); - shrinkAnimation.setFillEnabled(true); - captureButton.startAnimation(shrinkAnimation); - onCaptureClicked(); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_OUTSIDE: - Animation growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow); - growAnimation.setFillAfter(true); - growAnimation.setFillEnabled(true); - captureButton.startAnimation(growAnimation); - captureButton.setEnabled(false); - break; - } - return true; - }); - - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, () -> { - if (properties.getCameraCount() > 1) { - flipButton.setVisibility(properties.getCameraCount() > 1 ? View.VISIBLE : View.GONE); - flipButton.setOnClickListener(v -> { - int newCameraId = camera.flip(); - TextSecurePreferences.setDirectCaptureCameraId(getContext(), newCameraId); - - Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(200); - animation.setInterpolator(new DecelerateInterpolator()); - flipButton.startAnimation(animation); - }); - } else { - flipButton.setVisibility(View.GONE); - } - }); - } - - private void onCaptureClicked() { - orderEnforcer.reset(); - - Stopwatch fastCaptureTimer = new Stopwatch("Capture"); - - camera.capture((jpegData, frontFacing) -> { - fastCaptureTimer.split("captured"); - - Transformation transformation = frontFacing ? new MultiTransformation<>(new CenterCrop(), new FlipTransformation()) - : new CenterCrop(); - - Glide.with(this) - .asBitmap() - .load(jpegData) - .transform(transformation) - .override(cameraPreview.getWidth(), cameraPreview.getHeight()) - .into(new SimpleTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - fastCaptureTimer.split("transform"); - - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - resource.compress(Bitmap.CompressFormat.JPEG, 80, stream); - fastCaptureTimer.split("compressed"); - - byte[] data = stream.toByteArray(); - fastCaptureTimer.split("bytes"); - fastCaptureTimer.stop(TAG); - - controller.onImageCaptured(data, resource.getWidth(), resource.getHeight()); - } - - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - controller.onCameraError(); - } - }); - }); - } - - private PointF getScaleTransform(float viewWidth, float viewHeight, int cameraWidth, int cameraHeight) { - float camWidth = isPortrait() ? Math.min(cameraWidth, cameraHeight) : Math.max(cameraWidth, cameraHeight); - float camHeight = isPortrait() ? Math.max(cameraWidth, cameraHeight) : Math.min(cameraWidth, cameraHeight); - - float scaleX = 1; - float scaleY = 1; - - if ((camWidth / viewWidth) > (camHeight / viewHeight)) { - float targetWidth = viewHeight * (camWidth / camHeight); - scaleX = targetWidth / viewWidth; - } else { - float targetHeight = viewWidth * (camHeight / camWidth); - scaleY = targetHeight / viewHeight; - } - - return new PointF(scaleX, scaleY); - } - - private void onOrientationChanged(int orientation) { - int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait - : R.layout.camera_controls_landscape; - - controlsContainer.removeAllViews(); - controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); - initControls(); - } - - private void updatePreviewScale() { - PointF scale = getScaleTransform(cameraPreview.getWidth(), cameraPreview.getHeight(), properties.getPreviewWidth(), properties.getPreviewHeight()); - Matrix matrix = new Matrix(); - - float camWidth = isPortrait() ? Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()); - float camHeight = isPortrait() ? Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()); - - matrix.setScale(scale.x, scale.y); - matrix.postTranslate((camWidth - (camWidth * scale.x)) / 2, (camHeight - (camHeight * scale.y)) / 2); - cameraPreview.setTransform(matrix); - } - - private boolean isPortrait() { - return getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; - } - - private final GestureDetector.OnGestureListener flipGestureListener = new GestureDetector.SimpleOnGestureListener() { - @Override - public boolean onDown(MotionEvent e) { - return true; - } - - @Override - public boolean onDoubleTap(MotionEvent e) { - flipButton.performClick(); - return true; - } - }; - - public interface Controller { - void onCameraError(); - void onImageCaptured(@NonNull byte[] data, int width, int height); - int getDisplayRotation(); - } - - private enum Stage { - SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt new file mode 100644 index 0000000000..2bd19ca5fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import network.loki.messenger.databinding.CameraxFragmentBinding +import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.util.setSafeOnClickListener +import java.io.IOException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class CameraXFragment : Fragment() { + + interface Controller { + fun onImageCaptured(imageUri: Uri, width: Int, height: Int) + fun onCameraError() + } + + private var _binding: CameraxFragmentBinding? = null + private val binding get() = _binding!! + + private var controller: Controller? = null + + private var imageCapture: ImageCapture? = null + private var cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA //todo CAM use text prefs and save it back to there when flipping + private lateinit var cameraExecutor: ExecutorService + + companion object { + private const val TAG = "CameraXFragment" + private const val REQUEST_CODE_PERMISSIONS = 10 + private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = CameraxFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + cameraExecutor = Executors.newSingleThreadExecutor() + + if (allPermissionsGranted()) { + startCamera() + } else { + ActivityCompat.requestPermissions( + requireActivity(), REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS + ) + } + + //todo CAM handle orientation change + binding.cameraCaptureButton.setSafeOnClickListener { takePhoto() } //todo CAM optimise layout + binding.cameraFlipButton.setSafeOnClickListener { flipCamera() } //todo CAM hideif only one camera + binding.cameraCloseButton.setSafeOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is Controller) { + controller = context + } else { + throw RuntimeException("$context must implement CameraXFragment.Controller") + } + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission( + requireContext(), it + ) == PackageManager.PERMISSION_GRANTED + } + + private fun startCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + + cameraProviderFuture.addListener({ + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(binding.previewView.surfaceProvider) + } + + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + viewLifecycleOwner, cameraSelector, preview, imageCapture + ) + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + } + + }, ContextCompat.getMainExecutor(requireContext())) + } + + private fun takePhoto() { + val imageCapture = imageCapture ?: return + + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, System.currentTimeMillis()) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/TempCameraX") + } + } + + val outputOptions = ImageCapture.OutputFileOptions + .Builder( + requireContext().contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ).build() + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(requireContext()), + object : ImageCapture.OnImageSavedCallback { + + override fun onError(exc: ImageCaptureException) { + Log.e(TAG, "Photo capture failed: ${exc.message}", exc) + controller?.onCameraError() + } + + override fun onImageSaved(result: ImageCapture.OutputFileResults) { + val tempUri = result.savedUri ?: run { + controller?.onCameraError(); return + } + + cameraExecutor.execute { wrapInBlobAndReturn(tempUri) } + } + } + ) + } + + private fun wrapInBlobAndReturn(tempUri: Uri) { + try { + val resolver = requireContext().contentResolver + val size = resolver.openAssetFileDescriptor(tempUri, "r")?.use { it.length } ?: -1L + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(tempUri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + + val width = bounds.outWidth + val height = bounds.outHeight + + val input = resolver.openInputStream(tempUri) ?: throw IOException("open failed") + val blobUri = BlobProvider.getInstance() + .forData(input, size) + .withMimeType(MediaTypes.IMAGE_JPEG) + .createForSingleSessionOnDisk(requireContext()) { e: IOException? -> + org.session.libsignal.utilities.Log.w(TAG, "Failed to write to disk.", e) + } + .get() + + resolver.delete(tempUri, null, null) + + controller?.onImageCaptured( + blobUri, + width, + height + ) + } catch (t: Throwable) { + Log.e(TAG, "wrapInBlob failed", t) + controller?.onCameraError() + } + } + + private fun flipCamera() { + cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) + CameraSelector.DEFAULT_FRONT_CAMERA + else + CameraSelector.DEFAULT_BACK_CAMERA + + startCamera() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + cameraExecutor.shutdown() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 3c6be77dab..9afc00a877 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend import android.Manifest import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.View import android.view.animation.AccelerateDecelerateInterpolator @@ -46,8 +47,7 @@ import java.io.IOException @AndroidEntryPoint class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller, MediaSendFragment.Controller, - ImageEditorFragment.Controller, - Camera1Fragment.Controller { + ImageEditorFragment.Controller, CameraXFragment.Controller{ private var recipient: Recipient? = null private val viewModel: MediaSendViewModel by viewModels() @@ -86,7 +86,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme val isCamera = intent.getBooleanExtra(KEY_IS_CAMERA, false) if (isCamera) { - val fragment: Fragment = Camera1Fragment.newInstance() + val fragment: Fragment = CameraXFragment() supportFragmentManager.beginTransaction() .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) .commit() @@ -235,31 +235,18 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme finish() } - override fun onImageCaptured(data: ByteArray, width: Int, height: Int) { + override fun onImageCaptured(imageUri: Uri, width: Int, height: Int) { Log.i(TAG, "Camera image captured.") SimpleTask.run(lifecycle, { try { - val uri = BlobProvider.getInstance() - .forData(data) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk( - this - ) { e: IOException? -> - Log.w( - TAG, - "Failed to write to disk.", - e - ) - }.get() - return@run Media( - uri, + imageUri, constructPhotoFilename(this), MediaTypes.IMAGE_JPEG, System.currentTimeMillis(), width, height, - data.size.toLong(), + BlobProvider.getFileSize(imageUri) ?: 0L, Media.ALL_MEDIA_BUCKET_ID, null ) @@ -278,10 +265,6 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme }) } - override fun getDisplayRotation(): Int { - return windowManager.defaultDisplay.rotation - } - private fun initializeCountButtonObserver() { viewModel.getCountButtonState().observe( this @@ -405,12 +388,12 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .execute() } - private val orCreateCameraFragment: Camera1Fragment + private val orCreateCameraFragment: CameraXFragment get() { val fragment = - supportFragmentManager.findFragmentByTag(TAG_CAMERA) as Camera1Fragment? + supportFragmentManager.findFragmentByTag(TAG_CAMERA) as CameraXFragment? - return fragment ?: Camera1Fragment.newInstance() + return fragment ?: CameraXFragment() } private fun animateButtonVisibility(button: View, oldVisibility: Int, newVisibility: Int) { diff --git a/app/src/main/res/layout/camera_fragment.xml b/app/src/main/res/layout/camerax_fragment.xml similarity index 53% rename from app/src/main/res/layout/camera_fragment.xml rename to app/src/main/res/layout/camerax_fragment.xml index 072cb3aa61..318c24cc9d 100644 --- a/app/src/main/res/layout/camera_fragment.xml +++ b/app/src/main/res/layout/camerax_fragment.xml @@ -1,13 +1,14 @@ - @@ -16,11 +17,33 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:layout_marginBottom="48dp" + android:layout_gravity="bottom"> + +

P|lwkiiyX5$*R5CJhAS$47$(>T{Kj= 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