Compare commits

...

103 Commits

Author SHA1 Message Date
Cody Henthorne 3e92fca26d Bump version to 8.15.0 2026-06-10 15:51:38 -04:00
Cody Henthorne 526928ce0a Update baseline profile. 2026-06-10 15:40:12 -04:00
Cody Henthorne 2ea2f561ae Update translations and other static files. 2026-06-10 15:29:25 -04:00
Cody Henthorne c88f048049 Ensure unregistered contacts update storage service if previously registered. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 204a233235 Some improvements to apkdiff. 2026-06-10 15:20:00 -04:00
BarbossHack 2e4abd8ed3 Switch to semantic resources.arsc comparison in apkdiff.
Closes signalapp/Signal-Android#14828
2026-06-10 15:20:00 -04:00
Greyson Parrelli a2a0b11c98 Improve validation on launcher alias intents. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 96f893652b Improve receipt message validations. 2026-06-10 15:20:00 -04:00
Greyson Parrelli d4924d2a13 Ignore START_ROUTE in exported settings activity when launched by another app. 2026-06-10 15:20:00 -04:00
Greyson Parrelli fa6b512cfc Improve link preview validations. 2026-06-10 15:20:00 -04:00
Alex Hart d447af36ba Surface retryable failure for slow donations config load. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 41260f37c9 Fix bug where background connection would drop in forceWebsocket mode.
Relates to #14793
2026-06-10 15:20:00 -04:00
Greyson Parrelli 8950f7f7f9 Simplify media session permission handling. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 14916d068f Allow all group call participants to remote mute, not just admins. 2026-06-10 15:20:00 -04:00
emir-signal 33022baaa2 Update to RingRTC v2.69.3
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-10 15:20:00 -04:00
Alex Hart 4cdd1f70ac Fix contact search list flickering on query change. 2026-06-10 15:20:00 -04:00
Alex Hart 9e3ee16e65 Fix dead toolbar navigation arrow in ShareActivity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-10 15:20:00 -04:00
Michelle Tang bf912e14d9 Turn on KT. 2026-06-10 15:20:00 -04:00
Cody Henthorne 56f1a9e0ec Clear SVR auth tokens when disabling the PIN. 2026-06-10 15:20:00 -04:00
Cody Henthorne e468156c4c Verify multiple APNG lengths to prevent bad input from crashing. 2026-06-10 15:20:00 -04:00
Alex Hart 029b91066f Add warnings for phishing. 2026-06-10 15:20:00 -04:00
Alex Hart 5909a1b92a Migrate MultiselectForward to Jetpack Compose. 2026-06-09 17:21:48 -04:00
Cody Henthorne 9478cdf049 Reset SAS for device transfer on reconnect and hard abort if disconnected during transfer. 2026-06-09 17:21:48 -04:00
Michelle Tang 6d260ab63d Update announcement checks. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4bc11fcf0d Rotate IndividuaSendJobV2 remote config. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 33619fe463 Fix IndividualSendJobV2 edit message timestamps. 2026-06-09 17:21:48 -04:00
Cody Henthorne 2b17868797 Skip create remote key copy if duplicate is same attachment. 2026-06-09 17:21:48 -04:00
Alex Hart 582a464a52 Fix screen share video being center-cropped. 2026-06-09 17:21:48 -04:00
Cody Henthorne 812a858761 Reject attachments with unrecognized CDN numbers instead of crashing. 2026-06-09 17:21:48 -04:00
Alex Hart 6ea96795cb Fix detail pane state getting stuck after cancelled predictive back gesture.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:48 -04:00
Michelle Tang 70ab0baa3c Fix broken calling test. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5041057bab Update shouldIngore for edit and decryption error messages. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 6210d1b397 Improve proxy configuration. 2026-06-09 17:21:48 -04:00
Greyson Parrelli f1accae295 Fix bidi balancing character. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5f6d20453c Improve incoming payment validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli d33385d1b2 Improve group story reply and reaction validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 3d05bc3471 Fix rules with group story replies and timers. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0514a5c6c8 Add a hard cap of stickers per pack manifest. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 798ba11e62 Validate style body ranges have a start and length to prevent message processing crash. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 848a61787b Improve device transfer wifi validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli ccfbb27695 Improve body range validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 26b1d3a0f8 Improve username link storage service validation. 2026-06-09 17:21:48 -04:00
Greyson Parrelli c5785c086e Strip the full set of Unicode bidi controls from attachment filenames. 2026-06-09 17:21:48 -04:00
Cody Henthorne 6aeb145024 Require Play Services send permission for SMS retriever broadcasts. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 7e8c6228d8 Improve CDN mismatch reconciliation query. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4c08b94b88 Reduce Recipient usage in group-related jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 135bc6e560 Convert a batch of androidTest db tests to unit tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0ebeb5aa92 Restrict S3 downloads to ReleaseChannel. 2026-06-09 17:21:48 -04:00
Greyson Parrelli aaa7a18190 Remove Robolectric from some tests with light mocking. 2026-06-09 17:21:48 -04:00
Greyson Parrelli bfc1c4ebfa Removed unnecessary Robolectric annotation from some tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli b4cf59f9c2 Reduce Recipient usage in some jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 15e7b30fa1 Add tests for IndividualSendJobV2. 2026-06-09 17:21:47 -04:00
Cody Henthorne a5359e05a3 Convert TransferControlView rendering to compose. 2026-06-09 17:21:47 -04:00
Michelle Tang 73557ae72a Add jitter to KT weekly check. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2505049e39 Allow WebSocketDrainer to run longer if network returns while waiting. 2026-06-09 17:21:47 -04:00
Cody Henthorne a4ae6581ef Improve website apk update flow. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6399a2d899 Avoid taking a transaction in RemappedRecordTables. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 56af57db9e Un-export FCM service. 2026-06-09 17:21:47 -04:00
Greyson Parrelli ddf0de52b1 Log transaction waits as separate issues. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 925e2c1705 Fix potential recursive call. 2026-06-09 17:21:47 -04:00
Greyson Parrelli cb719dff1a Reduce logging verbosity. 2026-06-09 17:21:47 -04:00
Cody Henthorne f2fd3e63c8 Prevent accepted group message request from resetting when restored from storage. 2026-06-09 17:21:47 -04:00
Michelle Tang 8560ab0515 Expire calls from call log. 2026-06-09 17:21:47 -04:00
Michelle Tang d586eff80b Expire group calls. 2026-06-09 17:21:47 -04:00
Alex Hart 27ddd62d7a Keep user in gift flow after payment error instead of finishing the activity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 38f31528ff Fix mob stringification in backups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Greyson Parrelli 335fcd72f3 Don't flag regV5 PIN entry as a password. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 118231a328 Attempt to restore AccountRecord in regV5. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 754dd15c94 Add device transfer flow to regV5. 2026-06-09 17:21:47 -04:00
Cody Henthorne 566c2d5838 Always scroll to the top of the conversation header when in message request state. 2026-06-09 17:21:47 -04:00
Greyson Parrelli eae894152c Add a new ci task with faster lint. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 53c4069c64 Track perf issues in a table, add internal viewer. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 65082893db Add index for scheduled message queries. 2026-06-09 17:21:47 -04:00
ArseniiS 595364b522 Convert HelpFragment to Compose.
Co-authored-by: Alex Hart <alex@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 7cce504f16 Fix unread divider placement and scroll-to-unread on conversation open. 2026-06-09 17:21:47 -04:00
Alex Hart 337afb11db Add screen size check for link and sync. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6027d58fb5 Fix various lint issues. 2026-06-09 17:21:47 -04:00
jeffrey-signal 132eaa5c70 Adjust tablet preview dimensions to better align with the current WindowBreakpoint config. 2026-06-09 17:21:47 -04:00
jeffrey-signal ba3e15ea6d RegV5 text style and padding fixes. 2026-06-09 17:21:47 -04:00
jeffrey-signal ceee5f714d Adjust RegV5 window insets so screens draw under system bars and handle display cutouts. 2026-06-09 17:21:47 -04:00
jeffrey-signal 4c942f39b0 Elevate RegistrationScaffold footer surface when content scrolls underneath. 2026-06-09 17:21:47 -04:00
jeffrey-signal f3a5bba3f2 Enable TwoPaneRegistrationScaffold panes to scroll independently. 2026-06-09 17:21:47 -04:00
jeffrey-signal 8af7606e3f Use TopAppBar on all RegV5 screens. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2adf84a895 Bump version to 8.14.3 2026-06-09 17:06:45 -04:00
Cody Henthorne 30ed0aa11a Update baseline profile. 2026-06-09 17:01:17 -04:00
Cody Henthorne ec9ae9e3b1 Update translations and other static files. 2026-06-09 16:48:06 -04:00
Cody Henthorne 6a23896077 Fix view state restore crash from LinkPreviewView sharing an id with its ViewStub. 2026-06-09 16:22:57 -04:00
Greyson Parrelli f5a1d79eb5 Ensure we don't run the SVR migration for unregistered users. 2026-06-09 16:20:28 -04:00
Alex Hart 4f0f0938d8 Bump version to 8.14.2 2026-06-05 16:38:39 -03:00
Alex Hart 0136971963 Update translations and other static files. 2026-06-05 16:25:51 -03:00
Michelle Tang f810d731dd Turn off KT. 2026-06-05 14:32:07 -04:00
Cody Henthorne 7c7c364fef Fix sending quoted voice notes in 1:1 chats via IndividualSendJobV2.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-05 14:28:16 -04:00
Alex Hart aa9591211b Bump version to 8.14.1 2026-06-04 16:21:46 -03:00
Alex Hart bbd48547e5 Update translations and other static files. 2026-06-04 16:08:30 -03:00
Cody Henthorne 757b521744 Add additional logging around message request interactions. 2026-06-04 13:59:47 -04:00
Cody Henthorne a6311c87c1 Cleanup bad notified state in background instead of during db migration. 2026-06-04 13:55:38 -04:00
Cody Henthorne 045bd9287b Fix incorrect quote and link preview compose layout. 2026-06-04 11:30:37 -04:00
Cody Henthorne f1a72dd01a Use CDN number instead of parsing identifier for attachment remote id. 2026-06-04 11:22:14 -04:00
Cody Henthorne af4d0a0ef0 Fix illegal session state crashes in receipt send flows. 2026-06-04 11:21:46 -04:00
Greyson Parrelli 7dcaa933f2 Rotate IndividuaSendJobV2 remote config. 2026-06-04 00:36:04 -04:00
Greyson Parrelli 2c88945e6b Fix sending messages to self when your session is deleted. 2026-06-04 00:35:40 -04:00
Greyson Parrelli f9b9ce6c14 Fix character swapping during backup restore. 2026-06-03 23:57:04 -04:00
Michelle Tang 1d8fbad17e Add additional unauthorized KT check. 2026-06-03 17:14:40 -04:00
572 changed files with 32123 additions and 13917 deletions
+3 -1
View File
@@ -41,13 +41,15 @@ jobs:
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew qa
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
- name: Archive reports for failed build
if: ${{ failure() }}
+2 -2
View File
@@ -27,8 +27,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1700
val canonicalVersionName = "8.14.0"
val canonicalVersionCode = 1704
val canonicalVersionName = "8.15.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -0,0 +1,393 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isLessThan
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
* unread message and scrolls there rather than opening at the bottom.
*
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
* callbacks instead.
*/
@RunWith(AndroidJUnit4::class)
class UnreadDividerInstrumentationTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@Test
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val totalUnread = 50
val oldestSentTime = 1000L
var oldestUnreadId = -1L
for (i in 0 until totalUnread) {
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
if (i == 0) {
oldestUnreadId = id
}
}
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
}
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
}
}
@Test
fun fullyReadConversationOpensAtBottomWithoutDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
}
SignalDatabase.threads.setRead(threadId)
// Precondition: nothing is unread, so there should be no divider.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
}
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
// below if we'd opened at the top).
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
}
}
@Test
fun outgoingMessageNewerThanUnreadClearsDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
val outgoing = OutgoingMessage.text(
threadRecipient = Recipient.resolved(recipientId),
body = "my reply",
expiresIn = 0,
sentTimeMillis = 1003L
)
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
// newer outgoing message clearing it.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
launch(recipientId).use { launched ->
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
}
assertThat(cleared).isEqualTo(true)
}
}
@Test
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
// asserting on it verifies the number the user sees updating as they scroll.
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
runOnMain {
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
}
// Scrolling through the thread drains the unread count to 0.
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
}
}
}
@Test
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
runOnMain {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
}
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
val stableCount = awaitStableUnreadCount(threadId)
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
}
assertThat(stableCount).isEqualTo(newestVisiblePosition)
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
assertThat(stableCount).isGreaterThan(0)
assertThat(stableCount).isLessThan(total)
}
}
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
val deadline = System.currentTimeMillis() + timeoutMs
var last = Int.MIN_VALUE
var stableSince = System.currentTimeMillis()
while (System.currentTimeMillis() < deadline) {
val current = SignalDatabase.messages.getUnreadCount(threadId)
if (current == last) {
if (System.currentTimeMillis() - stableSince >= 500) {
return current
}
} else {
last = current
stableSince = System.currentTimeMillis()
}
Thread.sleep(100)
}
throw AssertionError("Unread count never stabilized (last observed = $last)")
}
private data class BottomObserved(
val unreadState: ConversationItemDecorations.UnreadState,
val newestBottom: Int,
val recyclerHeight: Int
)
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = from,
sentTimeMillis = time,
serverTimeMillis = time,
receivedTimeMillis = time,
body = body
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
private data class Observed(
val unreadCount: Int,
val firstUnreadId: Long,
val firstUnreadTop: Int,
val recyclerHeight: Int
)
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
for (i in 0 until itemDecorationCount) {
val decoration = getItemDecorationAt(i)
if (decoration is ConversationItemDecorations) {
return decoration
}
}
return null
}
private fun runOnMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
}
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var value: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
if (value != null) {
return value!!
}
Thread.sleep(pollMs)
}
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun launch(recipientId: RecipientId): Launched {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
if (f is ConversationFragment) {
conversationFragments.add(f)
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
if (f is ConversationFragment) {
conversationFragments.remove(f)
}
}
}
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities.add(activity)
if (activity is MainActivity) {
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
}
}
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
allActivities.remove(activity)
}
}
app.registerActivityLifecycleCallbacks(activityCallbacks)
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
val intent = Intent(harness.context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
// Application#startActivity from a non-Activity context requires a new task.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
app.startActivity(intent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw AssertionError("MainActivity did not reach RESUMED within 15s")
}
return Launched(conversationFragments, app, activityCallbacks, allActivities)
}
private class Launched(
private val conversationFragments: List<ConversationFragment>,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
override fun close() {
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
}
@@ -431,7 +431,7 @@ class CallTableTest {
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(1L, call?.timestamp)
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class InAppPaymentTableTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
}
@Test
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
SignalDatabase.inAppPayments.update(
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
)
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
}
}
@@ -1,174 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.UuidUtil
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import java.time.DayOfWeek
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class NotificationProfileTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var alice: RecipientId
private lateinit var profile1: NotificationProfile
@Before
fun setUp() {
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
profile1 = NotificationProfile(
id = 1,
name = "profile1",
emoji = "",
createdAt = 1000L,
schedule = NotificationProfileSchedule(id = 1),
allowedMembers = setOf(alice),
notificationProfileId = NotificationProfileId.generate(),
deletedTimestampMs = 0,
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
}
@Test
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
allowAllMentions = false,
allowAllCalls = true,
scheduleEnabled = false,
scheduleStartTime = 900,
scheduleEndTime = 1700,
scheduleDaysEnabled = emptyList(),
deletedAtTimestampMs = 0
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
assertEquals(listOf(profile1), actualProfiles)
}
@Test
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
}
@Test
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
assertThat(deletedProfile.schedule.enabled).isFalse()
assertThat(deletedProfile.schedule.start).isEqualTo(900)
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
}
@Test
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
SignalDatabase.notificationProfiles.createProfile(
name = "Profile1",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
)
SignalDatabase.notificationProfiles.createProfile(
name = "Profile2",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 2000L
)
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
deletedAtTimestampMs = 1000L
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
}
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
}
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.signalAci
import org.whispersystems.signalservice.api.storage.signalPni
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
assertThat(messages.first().isIdentityDefault).isTrue()
}
@Test
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
val aci = ACI.from(UUID.randomUUID())
val pni = PNI.from(UUID.randomUUID())
val e164 = "+13334445555"
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
SignalDatabase.recipients.markRegistered(id, aci)
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(originalStorageId).isNotNull()
// Sanity: the record it currently publishes is whole + registered.
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(before.signalAci).isEqualTo(aci)
assertThat(before.signalPni).isEqualTo(pni)
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
SignalDatabase.recipients.markUnregistered(id)
// THEN its storageId rotates
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(updatedStorageId).isNotNull()
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
// THEN the published record is now ACI-only + unregistered
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(after.signalAci).isEqualTo(aci)
assertThat(after.signalPni).isNull()
assertThat(after.e164.nullIfBlank()).isNull()
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
val byPni = SignalDatabase.recipients.getByPni(pni).get()
assertThat(byPni).isNotEqualTo(id)
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
assertThat(pniRecord.signalAci).isNull()
assertThat(pniRecord.signalPni).isEqualTo(pni)
}
}
@@ -1,120 +0,0 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.SqlUtil
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
@RunWith(AndroidJUnit4::class)
class MyStoryMigrationTest {
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
@Test
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
// GIVEN
assertValidMyStoryExists()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
deleteMyStory()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId("0000-0000")
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId(UUID.randomUUID().toString())
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
private fun setMyStoryDistributionId(serializedId: String) {
SignalDatabase.rawDatabase.update(
DistributionListTables.LIST_TABLE_NAME,
contentValuesOf(
DistributionListTables.DISTRIBUTION_ID to serializedId
),
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun deleteMyStory() {
SignalDatabase.rawDatabase.delete(
DistributionListTables.LIST_TABLE_NAME,
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun assertValidMyStoryExists() {
SignalDatabase.rawDatabase.query(
DistributionListTables.LIST_TABLE_NAME,
SqlUtil.COUNT,
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
null,
null,
null
).use {
if (it.moveToNext()) {
val count = it.getInt(0)
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
} else {
fail("assertValidMyStoryExists: Query did not produce a count.")
}
}
}
private fun runMigration() {
V151_MyStoryMigration.migrate(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
0,
1
)
}
}
@@ -146,7 +146,7 @@ object TestMessages {
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -170,7 +170,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"audio/aac",
null,
Optional.empty(),
+1 -1
View File
@@ -1371,7 +1371,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
private var afterScroll: (() -> Unit)? = null
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
private var alignTopPosition: Int = RecyclerView.NO_POSITION
private var alignTopInset: Int = 0
private var alignTopCorrected: Boolean = false
override fun supportsPredictiveItemAnimations(): Boolean {
return false
}
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
*/
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = RecyclerView.NO_POSITION
super.scrollToPositionWithOffset(position, offset)
}
/**
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
* below the top of the recycler. [afterScroll] fires once the alignment settles.
*/
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = position
alignTopInset = topInset
alignTopCorrected = false
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
super.scrollToPositionWithOffset(position, height - topInset)
}
/**
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
* the base implementation clears the request as if it was never made.
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
} else {
scrollToPosition(pendingScrollPosition)
}
} else {
afterScroll?.invoke()
afterScroll = null
return
}
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
// requested inset, then let the next layout settle before notifying via afterScroll.
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
val target = findViewByPosition(alignTopPosition)
if (target != null) {
alignTopCorrected = true
if (getDecoratedTop(target) != alignTopInset) {
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
return
}
}
}
afterScroll?.invoke()
afterScroll = null
alignTopPosition = RecyclerView.NO_POSITION
}
companion object {
@@ -278,7 +278,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
CheckKeyTransparencyJob.enqueueIfNecessary(true);
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
@@ -512,8 +512,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
if (RemoteConfig.internalUser()) {
Tracer.getInstance().setMaxBufferSize(35_000);
}
SQLiteDatabase.setSlowWriteLoggingEnabled(RemoteConfig.slowDatabaseNotifications());
}
private void initializePeriodicTasks() {
@@ -1052,6 +1052,13 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
if (!isTrustedConversationIntent(intent)) {
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
intent.action = null
setIntent(intent)
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
@@ -1059,6 +1066,14 @@ class MainActivity :
}
}
/**
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
* respecting various intent attributes.
*/
private fun isTrustedConversationIntent(intent: Intent): Boolean {
return intent.component?.className == MainActivity::class.java.name
}
private fun handleGroupLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
@@ -61,21 +61,36 @@ object ApkUpdateInstaller {
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
try {
installApk(context, downloadId, userInitiated)
context
.getDownloadManager()
.openDownloadedFile(downloadId)
.use { parcelFileDescriptor ->
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
stream.channel.position(0)
installApk(context, downloadId, stream, userInitiated)
}
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -88,17 +103,13 @@ object ApkUpdateInstaller {
}
@Throws(IOException::class, SecurityException::class)
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
if (apkInputStream == null) {
Log.w(TAG, "Could not open download APK input stream!")
return
}
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// Reject the session if the APK's declared package name doesn't match ours.
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -133,15 +144,6 @@ object ApkUpdateInstaller {
session.commit(installerPendingIntent.intentSender)
}
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
var attachmentWidth = attachment.width
var attachmentHeight = attachment.height
@@ -41,12 +41,16 @@ enum class Cdn(private val value: Int) {
}
fun fromCdnNumber(cdnNumber: Int): Cdn {
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
}
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
return when (cdnNumber) {
-1 -> S3
0 -> CDN_0
2 -> CDN_2
3 -> CDN_3
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
else -> null
}
}
}
@@ -5,6 +5,7 @@ import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.blurhash.BlurHash
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.InvalidMessageStructureException
@@ -76,6 +77,8 @@ class PointerAttachment : Attachment {
override val thumbnailUri: Uri? = null
companion object {
private val TAG = Log.tag(PointerAttachment::class)
@JvmStatic
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
if (!pointers.isPresent) {
@@ -102,6 +105,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdnNumber = pointer.get().asPointer().cdnNumber
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
return Optional.of(
@@ -110,7 +120,7 @@ class PointerAttachment : Attachment {
transferState = transferState,
size = pointer.get().asPointer().size.orElse(0).toLong(),
fileName = pointer.get().asPointer().fileName.orElse(null),
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
cdn = cdn,
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
iv = null,
@@ -145,7 +155,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
if (cdn == Cdn.S3) {
return Optional.empty()
}
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
@@ -852,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
PaymentNotification()
} else {
PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
note = payment.note.takeUnless { it.isEmpty() },
transactionDetails = payment.toRemoteTransactionDetails()
)
@@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
@@ -85,7 +84,7 @@ import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigInteger
import java.math.BigDecimal
import java.sql.SQLException
import java.util.Optional
import java.util.UUID
@@ -1064,8 +1063,8 @@ class ChatItemArchiveImporter(
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
val amount = tryParseCryptoValue(paymentNotification.amountMob)
val fee = tryParseCryptoValue(paymentNotification.feeMob)
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
put(
MessageTable.MESSAGE_EXTRAS,
MessageExtras(
@@ -1119,26 +1118,15 @@ class ChatItemArchiveImporter(
return null
}
val amountCryptoValue = tryParseCryptoValue(this)
return if (amountCryptoValue != null) {
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
} else {
return try {
Money.mobileCoin(BigDecimal(this))
} catch (e: NumberFormatException) {
null
} catch (e: ArithmeticException) {
null
}
}
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
if (bigIntegerString == null) {
return null
}
val amount = try {
BigInteger(bigIntegerString).toString()
} catch (e: NumberFormatException) {
return null
}
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -145,6 +151,39 @@ fun MessageBackupsKeyRecordScreen(
RecordScreenBackHandler()
}
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
if (displayRecoveryKeyCopyWarning) {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.GotItClick -> {
onCopyToClipboardClick(backupKeyString)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.PasteKeyClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
}
}
ModalBottomSheet(
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = events
)
}
}
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -227,7 +266,13 @@ fun MessageBackupsKeyRecordScreen(
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import org.signal.core.models.AccountEntropyPool
/**
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
*
* We scan anywhere within the text and try to match the key in as many forms as possible:
* upper/lowercase, with or without grouping spaces, and with or without the display characters
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
*/
object RecoveryKeyDetector {
/**
* @param text the text to scan
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
*/
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
return false
}
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
return normalized.contains(recoveryKey.value)
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
* user explicitly confirms via that warning.
*
* Must be called once the [host]'s view has been created, as it registers a fragment result
* listener scoped to the host's view lifecycle.
*
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
* this to suppress that behavior while the warning is up.
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
*/
fun ComposeText.guardAgainstRecoveryKeyPaste(
host: Fragment,
onWarningShown: () -> Unit = {},
onWarningDismissed: () -> Unit = {}
) {
var pendingPaste: CharSequence? = null
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
pendingPaste?.let { insertText(it) }
}
pendingPaste = null
onWarningDismissed()
}
setOnPasteListener { pasteText ->
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
pendingPaste = pasteText
onWarningShown()
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
true
} else {
false
}
}
}
@@ -0,0 +1,100 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
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.fragment.app.setFragmentResult
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeFullScreenDialogFragment
/**
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
* attempts to paste their recovery key into the input field.
*
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
* every dismissal path (paste, decline, or cancel) to restore its own state.
*/
class RecoveryKeyPasteWarningFragment : ComposeFullScreenDialogFragment() {
companion object {
const val REQUEST_KEY = "recovery_key_request"
}
private var shouldPaste = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).apply {
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
window?.setWindowAnimations(0)
}
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(REQUEST_KEY, shouldPaste)
}
)
super.onDismiss(dialog)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun DialogContent() {
var isDisplayingFinalWarningDialog by remember { mutableStateOf(false) }
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.GotItClick -> error("Not supported for paste")
RecoveryKeyWarningSheetEvent.LearnMoreClick -> error("Not supported for paste")
RecoveryKeyWarningSheetEvent.PasteKeyClick -> {
shouldPaste = true
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
isDisplayingFinalWarningDialog = true
}
}
}
if (isDisplayingFinalWarningDialog) {
RecoveryKeyWarningDialog(
events = eventHandler
)
} else {
ModalBottomSheet(
onDismissRequest = { dismissAllowingStateLoss() },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = eventHandler
)
}
}
}
}
@@ -0,0 +1,185 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun RecoveryKeyWarningSheetContent(
clipStage: ClipStage,
events: (RecoveryKeyWarningSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp, bottom = 16.dp)
.size(80.dp)
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
.padding(20.dp)
)
Text(
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
val signalWillNeverMessageYou = stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you)
val recoveryKeyWarningBody = stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond)
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(signalWillNeverMessageYou)
}
append(" ")
append(recoveryKeyWarningBody)
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 75.dp)
)
when (clipStage) {
ClipStage.COPY -> CopyActionButtons(events = events)
ClipStage.PASTE -> PasteActionButtons(events = events)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
@Composable
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.GotItClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
}
}
@Composable
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
}
}
@Composable
fun RecoveryKeyWarningDialog(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
val bodyIntro = stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone)
val bodyEmphasis = stringResource(R.string.RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key)
val bodyOutro = stringResource(R.string.RecoveryKeyWarningDialog__never_respond_to_a_chat)
Dialogs.SimpleAlertDialog(
title = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_recovery_key)),
body = buildAnnotatedString {
append(bodyIntro)
append(" ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(bodyEmphasis)
}
append(" ")
append(bodyOutro)
},
confirm = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__paste_key)),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__dont_share)),
onConfirm = { events(RecoveryKeyWarningSheetEvent.PasteKeyClick) },
onDeny = { events(RecoveryKeyWarningSheetEvent.DoNotShareClick) }
)
}
enum class ClipStage {
COPY,
PASTE
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentCopyPreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentPastePreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningDialogPreview() {
Previews.Preview {
RecoveryKeyWarningDialog(events = {})
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
sealed interface RecoveryKeyWarningSheetEvent {
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
data object PasteKeyClick : RecoveryKeyWarningSheetEvent
data object GotItClick : RecoveryKeyWarningSheetEvent
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
}
@@ -11,6 +11,7 @@ import org.signal.archive.proto.FilePointer
import org.signal.core.util.Base64
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.signal.libsignal.usernames.BaseUsernameException
@@ -32,6 +33,8 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.util.Optional
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
private const val TAG = "ArchiveConverter"
/**
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
@@ -58,10 +61,16 @@ fun FilePointer?.toLocalAttachment(
return when (attachmentType) {
AttachmentType.ARCHIVE -> {
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return null
}
ArchivedAttachment(
contentType = contentType,
size = locatorInfo.size.toLong(),
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
cdn = cdnNumber,
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
key = locatorInfo.key.toByteArray(),
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
@@ -87,7 +96,7 @@ fun FilePointer?.toLocalAttachment(
AttachmentType.TRANSIT -> {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
contentType = contentType,
key = locatorInfo.key.toByteArray(),
size = Optional.ofNullable(locatorInfo.size),
@@ -306,7 +306,5 @@ class GiftFlowConfirmationFragment :
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
override fun exitCheckoutFlow() = Unit
}
@@ -4,7 +4,6 @@ import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.findNavController
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import kotlin.getValue
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by activityViewModel {
GiftFlowViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
R.id.fragment_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
multiShareArgs = emptyList(),
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
forceDisableAddMessage = true,
selectSingleRecipient = true
)
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun exitFlow() = Unit
override fun navigateUp() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
@@ -21,6 +21,7 @@ import com.bumptech.glide.RequestManager;
import org.signal.core.ui.view.Stub;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
@@ -263,7 +264,7 @@ public class AlbumThumbnailView extends FrameLayout {
}
private void showSlides(@NonNull RequestManager requestManager, @NonNull List<Slide> slides) {
boolean showControls = TransferControlView.containsPlayableSlides(slides);
boolean showControls = TransferControls.containsPlayableSlides(slides);
setSlide(requestManager, slides.get(0), R.id.album_cell_1, showControls);
setSlide(requestManager, slides.get(1), R.id.album_cell_2, showControls);
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.content.ClipData;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
@@ -19,6 +20,7 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
@@ -26,6 +28,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import org.signal.core.util.ServiceUtil;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -68,6 +71,7 @@ public class ComposeText extends EmojiEditText {
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
@Nullable private StylingChangedListener stylingChangedListener;
@Nullable private OnPasteListener onPasteListener;
public ComposeText(Context context) {
super(context);
@@ -213,6 +217,41 @@ public class ComposeText extends EmojiEditText {
stylingChangedListener = listener;
}
public void setOnPasteListener(@Nullable OnPasteListener listener) {
onPasteListener = listener;
}
/**
* Inserts the given text at the current selection (replacing any selected text), as if pasted.
* This goes directly through the underlying {@link Editable}, so it does not pass through the
* {@link OnPasteListener}. Used to complete a paste the listener previously intercepted, replaying
* the exact text that was intercepted rather than re-reading the clipboard — the intercepted text
* may have come from an IME suggestion (e.g. the keyboard's clipboard chip) that is not the
* current clipboard contents.
*/
public void insertText(@NonNull CharSequence text) {
Editable editable = getText();
if (editable == null) {
return;
}
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
int start;
int end;
if (selectionStart < 0 || selectionEnd < 0) {
start = editable.length();
end = editable.length();
} else {
start = Math.min(selectionStart, selectionEnd);
end = Math.max(selectionStart, selectionEnd);
}
editable.replace(start, end, text);
setSelection(start + text.length());
}
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@@ -242,7 +281,19 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
return inputConnection;
if (inputConnection == null) {
return null;
}
return new InputConnectionWrapper(inputConnection, true) {
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
if (onPasteListener != null && text != null && onPasteListener.onPaste(text)) {
return true;
}
return super.commitText(text, newCursorPosition);
}
};
}
public boolean hasMentions() {
@@ -479,6 +530,20 @@ public class ComposeText extends EmojiEditText {
return true;
}
@Override
public boolean onTextContextMenuItem(int id) {
if ((id == android.R.id.paste || id == android.R.id.pasteAsPlainText) && onPasteListener != null) {
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
CharSequence pasteText = clipData != null && clipData.getItemCount() > 0 ? clipData.getItemAt(0).coerceToText(getContext()) : null;
if (onPasteListener.onPaste(pasteText)) {
return true;
}
}
return super.onTextContextMenuItem(id);
}
/**
* Return true if we think the user may be inputting a time.
*/
@@ -576,4 +641,15 @@ public class ComposeText extends EmojiEditText {
public interface StylingChangedListener {
void onStylingChanged();
}
public interface OnPasteListener {
/**
* Invoked before a paste is applied to the field, giving an observer the chance to intercept it.
*
* @param pasteText the text currently on the clipboard, or {@code null} if it could not be read
* @return true to consume the paste (the listener will handle it, e.g. by prompting the user),
* or false to let the paste proceed normally
*/
boolean onPaste(@Nullable CharSequence pasteText);
}
}
@@ -153,8 +153,8 @@ public class InputPanel extends ConstraintLayout
this.composeContainer = findViewById(R.id.compose_bubble);
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
this.quoteViewStub = new Stub<>(findViewById(R.id.quote_view_stub));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
this.quoteViewStub = new Stub<>(findViewById(R.id.quote_view));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview));
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.composeTextContainer = findViewById(R.id.embedded_text_editor_container);
@@ -4,8 +4,6 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -43,9 +41,6 @@ import okhttp3.HttpUrl;
*/
public class LinkPreviewView extends FrameLayout {
private static final String STATE_ROOT = "linkPreviewView.state.root";
private static final String STATE_STATE = "linkPreviewView.state.state";
private static final int TYPE_CONVERSATION = 0;
private static final int TYPE_COMPOSE = 1;
@@ -114,30 +109,6 @@ public class LinkPreviewView extends FrameLayout {
setWillNotDraw(false);
}
@Override
protected @NonNull Parcelable onSaveInstanceState() {
Parcelable root = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_ROOT, root);
bundle.putParcelable(STATE_STATE, thumbnailState);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Parcelable root = ((Bundle) state).getParcelable(STATE_ROOT);
thumbnailState = ((Bundle) state).getParcelable(STATE_STATE);
thumbnailState.applyState(thumbnail);
super.onRestoreInstanceState(root);
} else {
super.onRestoreInstanceState(state);
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
@@ -251,7 +222,7 @@ public class LinkPreviewView extends FrameLayout {
thumbnailState.applyState(thumbnail);
} else {
cornerMask.setRadii(topStart, topEnd, 0, 0);
thumbnailState.copy(
thumbnailState = thumbnailState.copy(
topStart,
defaultRadius,
defaultRadius,
@@ -5,7 +5,6 @@ import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
@@ -69,7 +68,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@RequiresApi(api = 21)
public SharedContactView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
@@ -37,7 +37,7 @@ class SignalProgressDialog private constructor(
var progress: Int
get() = progressBar.progress
set(value) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
set(value) = if (Build.VERSION.SDK_INT >= 24) {
progressBar.setProgress(value, true)
} else {
progressBar.setProgress(value)
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.glide.targets.GlideBitmapListeningTarget;
import org.thoughtcrime.securesms.glide.targets.GlideDrawableListeningTarget;
@@ -384,7 +385,7 @@ public class ThumbnailView extends FrameLayout {
}
transferControlViewStub.get().setSlides(List.of(slide));
}
int transferState = TransferControlView.getTransferState(List.of(slide));
int transferState = TransferControls.getTransferState(List.of(slide));
boolean isOffloadedImage = (transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType())) && AttachmentUtil.isRestoreOnOpenPermitted(getContext(), slide.asAttachment());
if (!showControls ||
@@ -48,7 +48,7 @@ class SystemEmojiDrawable(emoji: CharSequence) : Drawable() {
companion object {
private val textPaint: TextPaint = TextPaint()
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= 23) {
StaticLayout.Builder.obtain(emoji, 0, emoji.length, textPaint, Int.MAX_VALUE).build()
} else {
@Suppress("DEPRECATION")
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.registration;
import android.content.Context;
import android.graphics.PorterDuff;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
@@ -49,7 +48,7 @@ public class VerificationPinKeyboard extends FrameLayout {
initialize();
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@RequiresApi(api = 21)
public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
@@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.components.settings.app
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Process
import androidx.navigation.NavDirections
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
@@ -34,6 +37,8 @@ private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_crea
class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
private val TAG = Log.tag(AppSettingsActivity::class)
private var wasConfigurationUpdated = false
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
@@ -48,6 +53,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
} else if (Build.VERSION.SDK_INT >= 34 && getLaunchedFromUid() != Process.myUid()) {
Log.w(TAG, "Settings was launched by an external process. Ignoring starting route.")
null
} else {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
@@ -256,6 +256,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("App Issues"),
summary = DSLSettingsText.from("View recorded app issues, like slow reads and writes."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalIssuesFragment())
}
)
switchPref(
title = DSLSettingsText.from("Disable internal user flag"),
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
@@ -267,6 +275,50 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(
title = DSLSettingsText.from("SQLite Playground"),
summary = DSLSettingsText.from("Run raw SQLite queries."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSqlitePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Backup Playground"),
summary = DSLSettingsText.from("Test backup import/export."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Storage Service Playground"),
summary = DSLSettingsText.from("Test and view storage service stuff."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("SVR Playground"),
summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("App UI"))
switchPref(
@@ -315,50 +367,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(
title = DSLSettingsText.from("SQLite Playground"),
summary = DSLSettingsText.from("Run raw SQLite queries."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSqlitePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Backup Playground"),
summary = DSLSettingsText.from("Test backup import/export."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Storage Service Playground"),
summary = DSLSettingsText.from("Test and view storage service stuff."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("SVR Playground"),
summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))
clickPref(
@@ -438,8 +446,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from("Run self-check key transparency"),
summary = DSLSettingsText.from("Automatically enqueues a job to run KT against yourself without waiting for the elapsed time."),
onClick = {
SignalStore.misc.lastKeyTransparencyTime = 0
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = false)
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = false, force = true)
}
)
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.ComposeFragment
class InternalIssuesFragment : ComposeFragment() {
private val viewModel: InternalIssuesViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.onEvent(InternalIssuesScreenEvent.Load)
}
InternalIssuesScreen(
state = state,
onEvent = viewModel::onEvent,
onBack = { findNavController().popBackStack() }
)
}
}
@@ -0,0 +1,344 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InternalIssuesScreen(
state: InternalIssuesState,
onEvent: (InternalIssuesScreenEvent) -> Unit = {},
onBack: () -> Unit = {}
) {
var showFilterSheet by remember { mutableStateOf(false) }
var showSortSheet by remember { mutableStateOf(false) }
var showClearDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = "App Issues",
onNavigationClick = onBack,
navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {},
actions = {
if (state.names.isNotEmpty()) {
IconButton(onClick = { showFilterSheet = true }) {
Icon(painter = painterResource(R.drawable.symbol_filter_24), contentDescription = "Filter")
}
IconButton(onClick = { showSortSheet = true }) {
Icon(painter = painterResource(R.drawable.symbol_list_bullet_24), contentDescription = "Sort")
}
IconButton(onClick = { showClearDialog = true }) {
Icon(painter = SignalIcons.Trash.painter, contentDescription = "Clear")
}
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Rows.RadioListRow(
text = "Notification priority threshold",
labels = IssuePriority.entries.map { it.label }.toTypedArray(),
values = IssuePriority.entries.map { it.name }.toTypedArray(),
selectedValue = state.notificationPriority.name,
onSelected = { onEvent(InternalIssuesScreenEvent.SetNotificationPriority(IssuePriority.valueOf(it))) }
)
HorizontalDivider()
if (!state.loading && state.issues.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = if (state.nameFilter != null) "No issues match this filter." else "No issues recorded.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
items(state.issues, key = { it.id }) { issue ->
IssueRow(
issue = issue,
expanded = state.expandedIds.contains(issue.id),
onClick = { onEvent(InternalIssuesScreenEvent.ToggleExpanded(issue.id)) }
)
HorizontalDivider()
}
}
}
}
}
if (showFilterSheet) {
ModalBottomSheet(
onDismissRequest = { showFilterSheet = false },
sheetState = rememberModalBottomSheetState()
) {
SheetTitle("Filter by name")
SelectionRow(
text = "All",
selected = state.nameFilter == null,
onClick = {
onEvent(InternalIssuesScreenEvent.SetNameFilter(null))
showFilterSheet = false
}
)
state.names.forEach { name ->
SelectionRow(
text = name,
selected = state.nameFilter == name,
onClick = {
onEvent(InternalIssuesScreenEvent.SetNameFilter(name))
showFilterSheet = false
}
)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
if (showSortSheet) {
ModalBottomSheet(
onDismissRequest = { showSortSheet = false },
sheetState = rememberModalBottomSheetState()
) {
SheetTitle("Sort by")
IssueSortOrder.entries.forEach { order ->
SelectionRow(
text = order.label,
selected = state.sortOrder == order,
onClick = {
onEvent(InternalIssuesScreenEvent.SetSortOrder(order))
showSortSheet = false
}
)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
if (showClearDialog) {
Dialogs.SimpleAlertDialog(
title = "Clear all issues?",
body = "This will permanently delete all recorded app issues.",
confirm = "Clear",
dismiss = "Cancel",
onConfirm = { onEvent(InternalIssuesScreenEvent.ClearAll) },
onDismiss = { showClearDialog = false }
)
}
}
@Composable
private fun SheetTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
)
}
@Composable
private fun SelectionRow(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (selected) {
Icon(
painter = SignalIcons.Check.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun IssueRow(
issue: IssueRecord,
expanded: Boolean,
onClick: () -> Unit
) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(issue.toCopyText()))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = issue.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = issue.priority.label,
style = MaterialTheme.typography.labelMedium,
color = priorityColor(issue.priority),
fontWeight = FontWeight.Bold
)
}
Text(
text = buildString {
append(formatTimestamp(issue.createdAt))
append(" • v")
append(issue.version)
issue.duration?.let { append("${it}ms") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = issue.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 2
)
if (expanded && !issue.stackTrace.isNullOrBlank()) {
Text(
text = issue.stackTrace,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
private fun IssueRecord.toCopyText(): String {
return buildString {
append(name)
append(" (")
append(priority.label)
append(")\n")
append(formatTimestamp(createdAt))
append(" • v")
append(version)
duration?.let { append("${it}ms") }
append("\n")
append(description)
if (!stackTrace.isNullOrBlank()) {
append("\n")
append(stackTrace)
}
}
}
@Composable
private fun priorityColor(priority: IssuePriority): Color {
return when (priority) {
IssuePriority.HIGH -> MaterialTheme.colorScheme.error
IssuePriority.MEDIUM -> MaterialTheme.colorScheme.tertiary
IssuePriority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
private fun formatTimestamp(time: Long): String {
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(time))
}
@DayNightPreviews
@Composable
private fun InternalIssuesScreenPreview() {
Previews.Preview {
InternalIssuesScreen(
state = InternalIssuesState(
loading = false,
names = listOf("Slow Database Read", "Slow Database Write"),
issues = listOf(
IssueRecord(1, System.currentTimeMillis(), "7.42.1", "Slow Database Write", "Took 812ms. query=transaction hold", "java.lang.Throwable\n\tat Foo.bar(Foo.java:1)", IssuePriority.HIGH, 812),
IssueRecord(2, System.currentTimeMillis(), "7.42.1", "Slow Database Read", "Took 1043ms. query=SELECT * FROM message", null, IssuePriority.LOW, 1043)
)
)
)
}
}
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
data class InternalIssuesState(
val loading: Boolean = true,
val issues: List<IssueRecord> = emptyList(),
val names: List<String> = emptyList(),
val nameFilter: String? = null,
val sortOrder: IssueSortOrder = IssueSortOrder.CREATED_DESC,
val expandedIds: Set<Long> = emptySet(),
val notificationPriority: IssuePriority = IssuePriority.HIGH
)
enum class IssueSortOrder(val label: String) {
CREATED_DESC("Newest first"),
CREATED_ASC("Oldest first"),
DURATION_DESC("Longest duration"),
DURATION_ASC("Shortest duration"),
PRIORITY_DESC("Highest priority"),
PRIORITY_ASC("Lowest priority")
}
sealed interface InternalIssuesScreenEvent {
data object Load : InternalIssuesScreenEvent
data object ClearAll : InternalIssuesScreenEvent
data class ToggleExpanded(val id: Long) : InternalIssuesScreenEvent
data class SetNotificationPriority(val priority: IssuePriority) : InternalIssuesScreenEvent
data class SetNameFilter(val name: String?) : InternalIssuesScreenEvent
data class SetSortOrder(val order: IssueSortOrder) : InternalIssuesScreenEvent
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
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 org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
import org.thoughtcrime.securesms.keyvalue.SignalStore
class InternalIssuesViewModel(application: Application) : AndroidViewModel(application) {
private val _state = MutableStateFlow(InternalIssuesState())
val state: StateFlow<InternalIssuesState> = _state.asStateFlow()
private val issues = LogDatabase.getInstance(application).issues
private var allIssues: List<IssueRecord> = emptyList()
fun onEvent(event: InternalIssuesScreenEvent) {
when (event) {
InternalIssuesScreenEvent.Load -> load()
InternalIssuesScreenEvent.ClearAll -> clearAll()
is InternalIssuesScreenEvent.ToggleExpanded -> toggleExpanded(event.id)
is InternalIssuesScreenEvent.SetNotificationPriority -> setNotificationPriority(event.priority)
is InternalIssuesScreenEvent.SetNameFilter -> _state.update { it.copy(nameFilter = event.name).withVisibleIssues() }
is InternalIssuesScreenEvent.SetSortOrder -> _state.update { it.copy(sortOrder = event.order).withVisibleIssues() }
}
}
private fun load() {
viewModelScope.launch {
allIssues = withContext(Dispatchers.IO) { issues.getRecent() }
_state.update { it.copy(loading = false, notificationPriority = SignalStore.internal.issueNotificationPriority).withVisibleIssues() }
}
}
private fun setNotificationPriority(priority: IssuePriority) {
SignalStore.internal.issueNotificationPriority = priority
_state.update { it.copy(notificationPriority = priority) }
}
private fun clearAll() {
viewModelScope.launch {
withContext(Dispatchers.IO) { issues.clear() }
allIssues = emptyList()
_state.update { it.copy(nameFilter = null, expandedIds = emptySet()).withVisibleIssues() }
}
}
private fun toggleExpanded(id: Long) {
_state.update {
val expanded = if (it.expandedIds.contains(id)) it.expandedIds - id else it.expandedIds + id
it.copy(expandedIds = expanded)
}
}
private fun InternalIssuesState.withVisibleIssues(): InternalIssuesState {
val visible = allIssues
.filter { nameFilter == null || it.name == nameFilter }
.sortedWith(sortOrder.comparator())
return copy(issues = visible, names = allIssues.map { it.name }.distinct().sorted())
}
private fun IssueSortOrder.comparator(): Comparator<IssueRecord> {
return when (this) {
IssueSortOrder.CREATED_DESC -> compareByDescending { it.createdAt }
IssueSortOrder.CREATED_ASC -> compareBy { it.createdAt }
IssueSortOrder.DURATION_DESC -> compareByDescending { it.duration ?: Long.MIN_VALUE }
IssueSortOrder.DURATION_ASC -> compareBy { it.duration ?: Long.MAX_VALUE }
IssueSortOrder.PRIORITY_DESC -> compareByDescending { it.priority.value }
IssueSortOrder.PRIORITY_ASC -> compareBy { it.priority.value }
}
}
}
@@ -70,6 +70,12 @@ object InAppPaymentsRepository {
private const val JOB_PREFIX = "InAppPayments__"
private val TAG = Log.tag(InAppPaymentsRepository::class.java)
/**
* Upper bound on how long we'll wait for the donations configuration before surfacing a retryable
* failure rather than leaving the user on an indefinite loading spinner (e.g. on a slow VPN).
*/
const val DONATIONS_CONFIGURATION_TIMEOUT_SECONDS = 30L
private val backupExpirationTimeout = 30.days
private val backupExpirationDeletion = 60.days
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.Currency
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Shared one-time payment methods that apply to both Stripe and PayPal payments.
@@ -77,6 +78,7 @@ object OneTimeInAppPaymentRepository {
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { config ->
config.getBoostAmounts().mapValues { (_, value) ->
@@ -97,6 +99,7 @@ object OneTimeInAppPaymentRepository {
.getDonationsConfiguration(Locale.getDefault())
}
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { it.getBoostBadges().first() }
}
@@ -107,8 +110,9 @@ object OneTimeInAppPaymentRepository {
*/
fun getMinimumDonationAmounts(): Single<Map<Currency, FiatMoney>> {
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { it.getMinimumDonationAmounts() }
}
@@ -39,6 +39,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.math.BigDecimal
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -109,6 +110,7 @@ object RecurringInAppPaymentRepository {
return Single
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { config ->
config.getSubscriptionLevels().map { (level, levelConfig) ->
@@ -18,6 +18,7 @@ import org.signal.core.util.money.PlatformCurrencyUtil
import org.signal.core.util.orNull
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
@@ -265,15 +266,6 @@ class DonateToSignalViewModel(
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
}
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getBoostBadge().subscribeBy(
onSuccess = { badge ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
},
onError = {
Log.w(TAG, "Could not load boost badge", it)
}
)
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getMinimumDonationAmounts().subscribeBy(
onSuccess = { amountMap ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(minimumDonationAmounts = amountMap)) }
@@ -283,10 +275,14 @@ class DonateToSignalViewModel(
}
)
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeInAppPaymentRepository.getBoosts().toObservable()
val boostsAndBadge: Observable<Pair<Map<Currency, List<Boost>>, Badge>> = Single.zip(
oneTimeInAppPaymentRepository.getBoosts(),
oneTimeInAppPaymentRepository.getBoostBadge()
) { boosts, badge -> boosts to badge }.toObservable()
val oneTimeCurrency: Observable<Currency> = SignalStore.inAppPayments.observableOneTimeCurrency
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
oneTimeDonationDisposables += Observable.combineLatest(boostsAndBadge, oneTimeCurrency) { (boostMap, badge), currency ->
val boostList = if (currency in boostMap) {
boostMap[currency]!!
} else {
@@ -294,12 +290,13 @@ class DonateToSignalViewModel(
listOf()
}
Triple(boostList, currency, boostMap.keys)
OneTimeConfiguration(boostList, badge, currency, boostMap.keys)
}.subscribeBy(
onNext = { (boostList, currency, availableCurrencies) ->
onNext = { (boostList, badge, currency, availableCurrencies) ->
store.update { state ->
state.copy(
oneTimeDonationState = state.oneTimeDonationState.copy(
badge = badge,
boosts = boostList,
selectedBoost = null,
selectedCurrency = currency,
@@ -321,6 +318,13 @@ class DonateToSignalViewModel(
)
}
private data class OneTimeConfiguration(
val boosts: List<Boost>,
val badge: Badge,
val currency: Currency,
val availableCurrencies: Set<Currency>
)
private fun initializeMonthlyDonationState(subscriptionsRepository: RecurringInAppPaymentRepository) {
monitorLevelUpdateProcessing()
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.C
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
@@ -79,10 +80,6 @@ sealed class ConversationSettingsViewModel(
} ?: emptyList()
}
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
store.update(sharedMedia) { mediaRecords, state ->
if (!cleared) {
state.copy(
@@ -101,6 +98,17 @@ sealed class ConversationSettingsViewModel(
sharedMediaUpdateTrigger.postValue(Unit)
}
fun observeConversationForCallUpdates(threadId: Long) {
disposable += RxDatabaseObserver.conversation(threadId)
.toObservable()
.switchMapSingle { repository.getCallEvents(callMessageIds) }
.subscribe { callRecords ->
store.update { state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
}
}
fun onReportSpam(): Maybe<Unit> {
return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) {
messageRequestRepository.reportSpamMessageRequest(store.state.recipient.id, store.state.threadId)
@@ -218,6 +226,7 @@ sealed class ConversationSettingsViewModel(
store.update { state ->
state.copy(threadId = threadId)
}
observeConversationForCallUpdates(threadId)
}
if (recipientId != Recipient.self().id) {
@@ -344,6 +353,7 @@ sealed class ConversationSettingsViewModel(
store.update { state ->
state.copy(threadId = threadId)
}
observeConversationForCallUpdates(threadId)
}
store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state ->
@@ -47,7 +47,7 @@ object CallPreference {
}
private fun presentTimer(messageRecord: MessageRecord) {
if (messageRecord.expiresIn > 0) {
if (messageRecord.expiresIn > 0 && messageRecord.expireStarted > 0) {
binding.callTimer.visible = true
binding.callTimer.setPercentComplete(0f)
@@ -5,55 +5,70 @@
package org.thoughtcrime.securesms.components.transfercontrols
import android.content.Context
import android.os.Build
import android.text.StaticLayout
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.updateLayoutParams
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ByteSize
import org.signal.core.util.ThrottledDebouncer
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.components.RecyclerViewParentTransitionController
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.databinding.TransferControlsViewBinding
import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.UUID
import kotlin.math.ceil
import kotlin.math.roundToInt
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
private val uuid = UUID.randomUUID().toString()
private val binding: TransferControlsViewBinding
/**
* Displays the start/cancel/progress controls that overlay an attachment thumbnail.
*/
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AbstractComposeView(context, attrs, defStyleAttr) {
companion object {
private val TAG = Log.tag(TransferControlView::class.java)
/** Flip to true locally to trace a single view's render transitions and ignored progress events. */
private const val VERBOSE_DEVELOPMENT_LOGGING = false
}
private var state = TransferControlViewState()
private val progressUpdateDebouncer: ThrottledDebouncer = ThrottledDebouncer(100)
private var mode: Mode = Mode.GONE
/** Throttled observable flow of [state] */
private var renderState by mutableStateOf<TransferControlsRenderState>(TransferControlsRenderState.Gone)
private val progressUpdateDebouncer = ThrottledDebouncer(100)
/** Per-instance id so a single recycled view can be isolated in logcat when [VERBOSE_DEVELOPMENT_LOGGING] is on. */
private val viewId by lazy { UUID.randomUUID().toString().take(8) }
init {
tag = uuid
binding = TransferControlsViewBinding.inflate(LayoutInflater.from(context), this)
visibility = GONE
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
isLongClickable = false
addOnAttachStateChangeListener(RecyclerViewParentTransitionController(child = this))
}
@Composable
override fun Content() {
SignalTheme {
TransferControls(
state = renderState,
onStartClick = { state.startTransferClickListener?.onClick(this) },
onCancelClick = { state.cancelTransferClickedListener?.onClick(this) },
onPlayClick = { state.instantPlaybackClickListener?.onClick(this) }
)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
@@ -64,466 +79,34 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
EventBus.getDefault().unregister(this)
}
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
val newState = stateFactory.invoke(state)
val oldMode = deriveMode(state)
val newMode = deriveMode(newState)
if ((newState != state || oldMode != newMode) && !(oldMode == Mode.GONE && newMode == Mode.GONE)) {
progressUpdateDebouncer.publish {
applyState(newState)
}
}
state = newState
}
fun isGone(): Boolean {
return mode == Mode.GONE
return TransferControls.deriveRenderState(state) is TransferControlsRenderState.Gone
}
private fun applyState(currentState: TransferControlViewState) {
val mode = deriveMode(currentState)
verboseLog("New state applying, mode = $mode")
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
val newState = stateFactory(state)
children.forEach {
it.clearAnimation()
}
val oldRender = TransferControls.deriveRenderState(state)
val newRender = TransferControls.deriveRenderState(newState)
state = newState
when (mode) {
Mode.PENDING_GALLERY -> displayPendingGallery(currentState)
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> displayPendingGalleryWithPlayable(currentState)
Mode.PENDING_SINGLE_ITEM -> displayPendingSingleItem(currentState)
Mode.PENDING_VIDEO_PLAYABLE -> displayPendingPlayableVideo(currentState)
Mode.DOWNLOADING_GALLERY -> displayDownloadingGallery(currentState)
Mode.DOWNLOADING_SINGLE_ITEM -> displayDownloadingSingleItem(currentState)
Mode.DOWNLOADING_VIDEO_PLAYABLE -> displayDownloadingPlayableVideo(currentState)
Mode.UPLOADING_GALLERY -> displayUploadingGallery(currentState)
Mode.UPLOADING_SINGLE_ITEM -> displayUploadingSingleItem(currentState)
Mode.RETRY_DOWNLOADING -> displayRetry(currentState, false)
Mode.RETRY_UPLOADING -> displayRetry(currentState, true)
Mode.GONE -> displayChildrenAsGone()
}
this.mode = mode
}
private fun deriveMode(currentState: TransferControlViewState): Mode {
if (currentState.slides.isEmpty()) {
verboseLog("Setting empty slide deck to GONE")
return Mode.GONE
}
if (currentState.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
verboseLog("Setting slide deck that's finished to GONE\n\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
if (currentState.isVisible) {
if (currentState.slides.size == 1) {
val slide = currentState.slides.first()
if (slide.hasVideo()) {
if (currentState.isUpload) {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
Mode.UPLOADING_SINGLE_ITEM
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
Mode.PENDING_SINGLE_ITEM
}
else -> {
Mode.RETRY_UPLOADING
}
}
} else {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
if (currentState.playableWhileDownloading) {
Mode.DOWNLOADING_VIDEO_PLAYABLE
} else {
Mode.DOWNLOADING_SINGLE_ITEM
}
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_DOWNLOADING
}
else -> {
if (currentState.playableWhileDownloading) {
Mode.PENDING_VIDEO_PLAYABLE
} else {
Mode.PENDING_SINGLE_ITEM
}
}
}
}
} else {
return if (currentState.isUpload) {
when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_UPLOADING
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
Mode.PENDING_SINGLE_ITEM
}
else -> {
Mode.UPLOADING_SINGLE_ITEM
}
}
} else {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
Mode.DOWNLOADING_SINGLE_ITEM
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_DOWNLOADING
}
else -> {
Mode.PENDING_SINGLE_ITEM
}
}
}
}
} else {
when (getTransferState(currentState.slides)) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
return if (currentState.isUpload) {
Mode.UPLOADING_GALLERY
} else {
Mode.DOWNLOADING_GALLERY
}
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
return if (containsPlayableSlides(currentState.slides)) {
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE
} else {
Mode.PENDING_GALLERY
}
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
return if (currentState.isUpload) {
Mode.RETRY_UPLOADING
} else {
Mode.RETRY_DOWNLOADING
}
}
AttachmentTable.TRANSFER_PROGRESS_DONE -> {
verboseLog("[Case 2] Setting slide deck that's finished to GONE\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
if (oldRender != newRender) {
verboseLog { "render $oldRender -> $newRender slides=[${slidesAsLogString(newState.slides)}]" }
progressUpdateDebouncer.publish {
renderState = newRender
if (newRender !is TransferControlsRenderState.Gone) {
visibility = VISIBLE
}
}
} else {
verboseLog("Setting slide deck to GONE because isVisible is false:\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
Log.i(TAG, "[$uuid] Hit default mode case, this should not happen.")
return Mode.GONE
}
private fun displayPendingGallery(currentState: TransferControlViewState) {
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(
currentState,
listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground),
listOf(binding.secondaryProgressView, binding.playVideoButton)
)
binding.primaryProgressView.setStopped(false)
showAllViews(
playVideoButton = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.primaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.primaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-PRIMARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(PRIMARY_TEXT_OFFSET_DP).toFloat()
}
setSecondaryDetailsText(currentState)
}
private fun displayPendingGalleryWithPlayable(currentState: TransferControlViewState) {
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
super.setClickable(false)
binding.secondaryProgressView.isClickable = currentState.showSecondaryText
binding.secondaryProgressView.isFocusable = currentState.showSecondaryText
binding.secondaryDetailsText.isClickable = currentState.showSecondaryText
binding.secondaryDetailsText.isFocusable = currentState.showSecondaryText
binding.secondaryBackground.isClickable = currentState.showSecondaryText
binding.secondaryBackground.isFocusable = currentState.showSecondaryText
binding.primaryProgressView.isClickable = false
binding.primaryProgressView.isFocusable = false
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryProgressView = currentState.showSecondaryText,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryProgressView.setStopped(false)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayPendingSingleItem(currentState: TransferControlViewState) {
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
binding.primaryProgressView.setStopped(false)
showAllViews(
playVideoButton = false,
primaryDetailsText = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayPendingPlayableVideo(currentState: TransferControlViewState) {
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
applyFocusableAndClickable(
currentState,
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground, binding.playVideoButton),
listOf(binding.primaryProgressView)
)
binding.secondaryProgressView.setStopped(false)
showAllViews(
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText,
secondaryProgressView = currentState.showSecondaryText
)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayDownloadingGallery(currentState: TransferControlViewState) {
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.secondaryProgressView.setProgress(progress)
} else {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
binding.secondaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayDownloadingSingleItem(currentState: TransferControlViewState) {
binding.primaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryDetailsText = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.primaryProgressView.setProgress(progress)
} else {
binding.primaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayDownloadingPlayableVideo(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView, binding.playVideoButton), listOf(binding.primaryProgressView))
showAllViews(
primaryDetailsText = false,
secondaryProgressView = currentState.showSecondaryText,
secondaryDetailsText = currentState.showSecondaryText
)
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.secondaryProgressView.setProgress(progress)
} else {
binding.secondaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayUploadingSingleItem(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
binding.secondaryProgressView.setProgress(progress)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayUploadingGallery(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false
)
val progress = calculateProgress(currentState)
binding.secondaryProgressView.setProgress(progress)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayRetry(currentState: TransferControlViewState, isUploading: Boolean) {
if (currentState.startTransferClickListener == null) {
Log.w(TAG, "No click listener set for retry!")
}
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(
currentState,
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground),
listOf(binding.primaryProgressView, binding.playVideoButton)
)
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryProgressView.setStopped(isUploading)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayChildrenAsGone() {
children.forEach {
if (it.visible && it.animation == null) {
ViewUtil.fadeOut(it, 250)
}
}
}
/**
* Shows all views by defaults, but allows individual views to be overridden to not be shown.
*
* @param root
* @param playVideoButton
* @param primaryProgressView
* @param primaryDetailsText
* @param secondaryProgressView
* @param secondaryDetailsText
*/
private fun showAllViews(
root: Boolean = true,
playVideoButton: Boolean = true,
primaryProgressView: Boolean = true,
primaryDetailsText: Boolean = true,
secondaryProgressView: Boolean = true,
secondaryDetailsText: Boolean = true
) {
this.visible = root
binding.playVideoButton.visible = playVideoButton
binding.primaryProgressView.visibility = if (primaryProgressView) View.VISIBLE else View.INVISIBLE
binding.primaryDetailsText.visible = primaryDetailsText
binding.primaryBackground.visible = primaryProgressView || primaryDetailsText || playVideoButton
binding.secondaryProgressView.visible = secondaryProgressView
binding.secondaryDetailsText.visible = secondaryDetailsText
binding.secondaryBackground.visible = secondaryProgressView || secondaryDetailsText
val textPadding = if (secondaryProgressView) {
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin)
} else {
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_parent_to_textview_margin)
}
ViewUtil.setPaddingStart(binding.secondaryDetailsText, textPadding)
if (ViewUtil.isLtr(binding.secondaryDetailsText)) {
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).leftMargin = textPadding
} else {
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).rightMargin = textPadding
}
}
private fun applyFocusableAndClickable(currentState: TransferControlViewState, activeViews: List<View>, inactiveViews: List<View>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusIntDef = if (currentState.isFocusable) View.FOCUSABLE else View.NOT_FOCUSABLE
activeViews.forEach { it.focusable = focusIntDef }
inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE }
}
activeViews.forEach { it.isClickable = currentState.isClickable }
inactiveViews.forEach {
it.setOnClickListener(null)
it.isClickable = false
}
}
override fun setFocusable(focusable: Boolean) {
super.setFocusable(false)
verboseLog("setFocusable update: $focusable")
updateState { it.copy(isFocusable = focusable) }
}
override fun setClickable(clickable: Boolean) {
super.setClickable(false)
verboseLog("setClickable update: $clickable")
updateState { it.copy(isClickable = clickable) }
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventAsync(event: PartProgressEvent) {
val attachment = event.attachment
updateState {
verboseLog("onEventAsync update")
if (!it.networkProgress.containsKey(attachment)) {
verboseLog("onEventAsync update ignored")
verboseLog { "Ignoring progress event for an attachment not in this view's slide set (likely a recycled view). ts=${attachment.uploadTimestamp}" }
return@updateState it
}
@@ -536,7 +119,6 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
} else if (updateEvent.completed < 0.bytes) {
mutableMap.remove(attachment)
}
verboseLog("onEventAsync compression update")
return@updateState it.copy(compressionProgress = mutableMap.toMap())
} else {
val mutableMap = it.networkProgress.toMutableMap()
@@ -547,16 +129,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
} else if (updateEvent.completed < 0.bytes) {
mutableMap.remove(attachment)
}
verboseLog("onEventAsync network update")
return@updateState it.copy(networkProgress = mutableMap.toMap())
}
}
}
fun setSlides(slides: List<Slide>) {
require(slides.isNotEmpty()) { "[$uuid] Must provide at least one slide." }
require(slides.isNotEmpty()) { "Must provide at least one slide." }
updateState { state ->
verboseLog("State update for new slides: ${slidesAsListOfTimestamps(slides)}")
val isNewSlideSet = !isUpdateToExistingSet(state, slides)
val networkProgress: MutableMap<Attachment, Progress> = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap()
if (isNewSlideSet) {
@@ -577,25 +157,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
(it.asAttachment() as? DatabaseAttachment)?.hasData == true
}
val result = state.copy(
state.copy(
slides = slides,
networkProgress = networkProgress,
compressionProgress = compressionProgress,
playableWhileDownloading = playableWhileDownloading,
isUpload = isUpload
)
verboseLog("New state calculated and being returned for new slides: ${slidesAsListOfTimestamps(slides)}\n$result")
return@updateState result
}
verboseLog("End of setSlides() for ${slidesAsListOfTimestamps(slides)}")
}
private fun slidesAsListOfTimestamps(slides: List<Slide>): String {
if (!VERBOSE_DEVELOPMENT_LOGGING) {
return ""
}
return slides.map { it.asAttachment().uploadTimestamp }.joinToString()
}
private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List<Slide>): Boolean {
@@ -611,171 +180,52 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
}
fun setTransferClickListener(listener: OnClickListener) {
verboseLog("transferClickListener update")
updateState {
it.copy(
startTransferClickListener = listener
)
}
updateState { it.copy(startTransferClickListener = listener) }
}
fun setCancelClickListener(listener: OnClickListener) {
verboseLog("cancelClickListener update")
updateState {
it.copy(
cancelTransferClickedListener = listener
)
}
updateState { it.copy(cancelTransferClickedListener = listener) }
}
fun setInstantPlaybackClickListener(listener: OnClickListener) {
verboseLog("instantPlaybackClickListener update")
updateState {
it.copy(
instantPlaybackClickListener = listener
)
}
updateState { it.copy(instantPlaybackClickListener = listener) }
}
fun clear() {
clearAnimation()
visibility = GONE
updateState { TransferControlViewState() }
}
fun setShowSecondaryText(showSecondaryText: Boolean) {
verboseLog("showSecondaryText update: $showSecondaryText")
updateState {
it.copy(
showSecondaryText = showSecondaryText
)
}
updateState { it.copy(showSecondaryText = showSecondaryText) }
}
fun setVisible(isVisible: Boolean) {
verboseLog("showSecondaryText update: $isVisible")
updateState {
it.copy(
isVisible = isVisible
)
}
updateState { it.copy(isVisible = isVisible) }
}
private fun isCompressing(state: TransferControlViewState): Boolean {
val total = state.compressionProgress.sumTotal()
return total > 0.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f
fun setAwaitingPrimaryResponse(awaiting: Boolean) {
updateState { it.copy(awaitingPrimaryResponse = awaiting) }
}
private fun calculateProgress(state: TransferControlViewState): Float {
val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress
val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat()
return weightedProgress / weightedTotal
override fun setFocusable(focusable: Boolean) {
super.setFocusable(false)
updateState { it.copy(isFocusable = focusable) }
}
private fun setSecondaryDetailsText(currentState: TransferControlViewState) {
when (deriveMode(currentState)) {
Mode.PENDING_GALLERY -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val remainingSlides = currentState.slides.filterNot { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }
val downloadCount = remainingSlides.size
binding.primaryDetailsText.text = context.resources.getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount)
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.PENDING_SINGLE_ITEM, Mode.PENDING_VIDEO_PLAYABLE -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val size: ByteSize = (currentState.slides.sumOf { it.asAttachment().size }).bytes
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.DOWNLOADING_GALLERY, Mode.DOWNLOADING_SINGLE_ITEM, Mode.DOWNLOADING_VIDEO_PLAYABLE, Mode.UPLOADING_GALLERY, Mode.UPLOADING_SINGLE_ITEM -> {
if (currentState.isUpload && (currentState.networkProgress.sumCompleted() == 0.bytes || isCompressing(currentState))) {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
binding.secondaryDetailsText.text = context.getString(R.string.TransferControlView__processing)
} else {
val progressMiB = currentState.networkProgress.sumCompleted().toUnitString()
val totalMiB = currentState.networkProgress.sumTotal().toUnitString()
val completedLabel = context.resources.getString(R.string.TransferControlView__download_progress_s_s, totalMiB, totalMiB)
val desiredWidth = StaticLayout.getDesiredWidth(completedLabel, binding.secondaryDetailsText.paint)
binding.secondaryDetailsText.text = context.resources.getString(R.string.TransferControlView__download_progress_s_s, progressMiB, totalMiB)
val roundedWidth = ceil(desiredWidth.toDouble()).roundToInt() + binding.secondaryDetailsText.compoundPaddingLeft + binding.secondaryDetailsText.compoundPaddingRight
binding.secondaryDetailsText.updateLayoutParams {
width = roundedWidth
}
}
}
Mode.RETRY_DOWNLOADING, Mode.RETRY_UPLOADING -> {
binding.secondaryDetailsText.text = resources.getString(R.string.NetworkFailure__retry)
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
Mode.GONE -> Unit
}
override fun setClickable(clickable: Boolean) {
super.setClickable(false)
updateState { it.copy(isClickable = clickable) }
}
/**
* This is an extremely chatty logging mode for local development. Each view is assigned a UUID so that you can filter by view inside a conversation.
*/
private fun verboseLog(message: String) {
private inline fun verboseLog(message: () -> String) {
if (VERBOSE_DEVELOPMENT_LOGGING) {
Log.d(TAG, "[$uuid] $message")
Log.d(TAG, "[$viewId] ${message()}")
}
}
companion object {
private const val TAG = "TransferControlView"
private const val VERBOSE_DEVELOPMENT_LOGGING = false
private const val UPLOAD_TASK_WEIGHT = 1
private const val SECONDARY_TEXT_OFFSET_DP = 6
private const val RETRY_SECONDARY_TEXT_OFFSET_DP = 6
private const val PRIMARY_TEXT_OFFSET_DP = 4
/**
* A weighting compared to [UPLOAD_TASK_WEIGHT]
*/
private const val COMPRESSION_TASK_WEIGHT = 3
@JvmStatic
fun getTransferState(slides: List<Slide>): Int {
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
var allFailed = true
for (slide in slides) {
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
slide.transferState
} else {
transferState.coerceAtLeast(slide.transferState)
}
}
}
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
}
@JvmStatic
fun containsPlayableSlides(slides: List<Slide>): Boolean {
return slides.any { MediaUtil.isInstantVideoSupported(it) }
}
private fun slidesAsLogString(slides: List<Slide>): String {
return slides.joinToString { "ts=${it.asAttachment().uploadTimestamp},xfer=${it.transferState}" }
}
data class Progress(val completed: ByteSize, val total: ByteSize) {
@@ -785,27 +235,4 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
}
}
}
private fun Map<Attachment, Progress>.sumCompleted(): ByteSize {
return this.values.sumOf { it.completed.inWholeBytes }.bytes
}
private fun Map<Attachment, Progress>.sumTotal(): ByteSize {
return this.values.sumOf { it.total.inWholeBytes }.bytes
}
enum class Mode {
PENDING_GALLERY,
PENDING_GALLERY_CONTAINS_PLAYABLE,
PENDING_SINGLE_ITEM,
PENDING_VIDEO_PLAYABLE,
DOWNLOADING_GALLERY,
DOWNLOADING_SINGLE_ITEM,
DOWNLOADING_VIDEO_PLAYABLE,
UPLOADING_GALLERY,
UPLOADING_SINGLE_ITEM,
RETRY_DOWNLOADING,
RETRY_UPLOADING,
GONE
}
}
@@ -21,5 +21,6 @@ data class TransferControlViewState(
val networkProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val compressionProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val playableWhileDownloading: Boolean = false,
val isUpload: Boolean = false
val isUpload: Boolean = false,
val awaitingPrimaryResponse: Boolean = false
)
@@ -0,0 +1,327 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.transfercontrols
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.MediaUtil
/**
* Pure, Android-View-free logic for the transfer controls UI.
*
* [deriveRenderState] maps a [TransferControlViewState] to a [TransferControlsRenderState], which is a small, fully-resolved
* description of what should be drawn. It carries semantic data (counts, byte sizes) rather than formatted strings so that it
* can be unit tested on the JVM; string formatting happens in the composable.
*/
object TransferControls {
/**
* Where the active transfer control (start button / progress indicator) is positioned.
*
* [CENTER] is the large, centered control used for single-item downloads.
* [CORNER] is the small control tucked in the corner, used for galleries, playable video, all uploads, and retries.
*/
enum class Placement {
CENTER,
CORNER
}
sealed interface ProgressLabel {
/** Attachment processing taking place, like transcoding */
data object Processing : ProgressLabel
/** Uploading/downloading progress */
data class Bytes(val completed: ByteSize, val total: ByteSize) : ProgressLabel
}
fun deriveRenderState(state: TransferControlViewState): TransferControlsRenderState {
if (state.slides.isEmpty()) {
return TransferControlsRenderState.Gone
}
if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
return TransferControlsRenderState.Gone
}
if (!state.isVisible) {
return TransferControlsRenderState.Gone
}
if (state.awaitingPrimaryResponse) {
return TransferControlsRenderState.InProgress(
isUpload = false,
placement = if (state.slides.size == 1) Placement.CENTER else Placement.CORNER,
progress = null,
showPlayButton = false,
cancelable = false,
label = null
)
}
return when (deriveMode(state)) {
Mode.PENDING_GALLERY -> TransferControlsRenderState.Pending(
isUpload = state.isUpload,
placement = Placement.CENTER,
showPlayButton = false,
itemCount = state.slides.count { it.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE },
sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null
)
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> TransferControlsRenderState.Pending(
isUpload = state.isUpload,
placement = Placement.CORNER,
showPlayButton = false,
sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null
)
Mode.PENDING_SINGLE_ITEM -> TransferControlsRenderState.Pending(
isUpload = state.isUpload,
placement = Placement.CENTER,
showPlayButton = false,
sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null
)
Mode.PENDING_VIDEO_PLAYABLE -> TransferControlsRenderState.Pending(
isUpload = state.isUpload,
placement = Placement.CORNER,
showPlayButton = true,
sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null
)
Mode.DOWNLOADING_GALLERY -> TransferControlsRenderState.InProgress(
isUpload = false,
placement = Placement.CORNER,
progress = calculateProgress(state),
showPlayButton = false,
cancelable = calculateProgress(state) != 0f,
label = progressLabel(state)
)
Mode.DOWNLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress(
isUpload = false,
placement = Placement.CENTER,
progress = calculateProgress(state),
showPlayButton = false,
cancelable = true,
label = progressLabel(state)
)
Mode.DOWNLOADING_VIDEO_PLAYABLE -> TransferControlsRenderState.InProgress(
isUpload = false,
placement = Placement.CORNER,
progress = calculateProgress(state),
showPlayButton = true,
cancelable = true,
label = progressLabel(state)
)
Mode.UPLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress(
isUpload = true,
placement = Placement.CORNER,
progress = calculateProgress(state),
showPlayButton = false,
cancelable = true,
label = progressLabel(state)
)
Mode.UPLOADING_GALLERY -> TransferControlsRenderState.InProgress(
isUpload = true,
placement = Placement.CORNER,
progress = calculateProgress(state),
showPlayButton = false,
cancelable = true,
// Note: the legacy view always showed this label for uploading galleries, regardless of showSecondaryText.
label = progressLabel(state)
)
Mode.RETRY_DOWNLOADING -> TransferControlsRenderState.Retry(isUpload = false)
Mode.RETRY_UPLOADING -> TransferControlsRenderState.Retry(isUpload = true)
Mode.GONE -> TransferControlsRenderState.Gone
}
}
private fun progressLabel(state: TransferControlViewState): ProgressLabel {
return if (state.isUpload && (state.networkProgress.sumCompleted() == 0L.bytes || isCompressing(state))) {
ProgressLabel.Processing
} else if (state.isUpload) {
ProgressLabel.Bytes(state.networkProgress.sumCompleted(), state.networkProgress.sumTotal())
} else {
val total = state.slides.sumOf { it.fileSize }.bytes
val completed = state.networkProgress.sumCompleted().let { if (it > total) total else it }
ProgressLabel.Bytes(completed, total)
}
}
private fun isCompressing(state: TransferControlViewState): Boolean {
val total = state.compressionProgress.sumTotal()
return total > 0L.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f
}
private fun calculateProgress(state: TransferControlViewState): Float {
val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress
val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat()
return weightedProgress / weightedTotal
}
/**
* Internal, view-free mirror of the legacy state machine. Kept verbatim from the original view to preserve behavior; the
* resulting [Mode] is mapped to a [TransferControlsRenderState] by [deriveRenderState].
*/
private fun deriveMode(state: TransferControlViewState): Mode {
if (state.slides.isEmpty()) {
return Mode.GONE
}
if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
return Mode.GONE
}
if (state.isVisible) {
if (state.slides.size == 1) {
val slide = state.slides.first()
if (slide.hasVideo()) {
if (state.isUpload) {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> Mode.UPLOADING_SINGLE_ITEM
AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM
else -> Mode.RETRY_UPLOADING
}
} else {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
if (state.playableWhileDownloading) Mode.DOWNLOADING_VIDEO_PLAYABLE else Mode.DOWNLOADING_SINGLE_ITEM
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_DOWNLOADING
else -> {
if (state.playableWhileDownloading) Mode.PENDING_VIDEO_PLAYABLE else Mode.PENDING_SINGLE_ITEM
}
}
}
} else {
return if (state.isUpload) {
when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_UPLOADING
AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM
else -> Mode.UPLOADING_SINGLE_ITEM
}
} else {
when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> Mode.DOWNLOADING_SINGLE_ITEM
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_DOWNLOADING
else -> Mode.PENDING_SINGLE_ITEM
}
}
}
} else {
when (getTransferState(state.slides)) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
return if (state.isUpload) Mode.UPLOADING_GALLERY else Mode.DOWNLOADING_GALLERY
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
return if (containsPlayableSlides(state.slides)) Mode.PENDING_GALLERY_CONTAINS_PLAYABLE else Mode.PENDING_GALLERY
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
return if (state.isUpload) Mode.RETRY_UPLOADING else Mode.RETRY_DOWNLOADING
}
AttachmentTable.TRANSFER_PROGRESS_DONE -> return Mode.GONE
}
}
} else {
return Mode.GONE
}
return Mode.GONE
}
@JvmStatic
fun getTransferState(slides: List<Slide>): Int {
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
var allFailed = true
for (slide in slides) {
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
slide.transferState
} else {
transferState.coerceAtLeast(slide.transferState)
}
}
}
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
}
@JvmStatic
fun containsPlayableSlides(slides: List<Slide>): Boolean {
return slides.any { MediaUtil.isInstantVideoSupported(it) }
}
private fun Map<Attachment, TransferControlView.Progress>.sumCompleted(): ByteSize {
return this.values.sumOf { it.completed.inWholeBytes }.bytes
}
private fun Map<Attachment, TransferControlView.Progress>.sumTotal(): ByteSize {
return this.values.sumOf { it.total.inWholeBytes }.bytes
}
private const val UPLOAD_TASK_WEIGHT = 1
/**
* A weighting compared to [UPLOAD_TASK_WEIGHT]
*/
private const val COMPRESSION_TASK_WEIGHT = 3
private enum class Mode {
PENDING_GALLERY,
PENDING_GALLERY_CONTAINS_PLAYABLE,
PENDING_SINGLE_ITEM,
PENDING_VIDEO_PLAYABLE,
DOWNLOADING_GALLERY,
DOWNLOADING_SINGLE_ITEM,
DOWNLOADING_VIDEO_PLAYABLE,
UPLOADING_GALLERY,
UPLOADING_SINGLE_ITEM,
RETRY_DOWNLOADING,
RETRY_UPLOADING,
GONE
}
}
/**
* A fully-resolved description of what the transfer controls should display. Produced by [TransferControls.deriveRenderState].
*/
sealed interface TransferControlsRenderState {
data object Gone : TransferControlsRenderState
data class Pending(
val isUpload: Boolean,
val placement: TransferControls.Placement,
val showPlayButton: Boolean,
val itemCount: Int? = null,
val sizeBytes: ByteSize? = null
) : TransferControlsRenderState
data class InProgress(
val isUpload: Boolean,
val placement: TransferControls.Placement,
val progress: Float?,
val showPlayButton: Boolean,
val cancelable: Boolean,
val label: TransferControls.ProgressLabel?
) : TransferControlsRenderState
data class Retry(
val isUpload: Boolean
) : TransferControlsRenderState
}
@@ -0,0 +1,415 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.transfercontrols
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.clickableContainer
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
private val CENTER_CONTROL_SIZE = 44.dp
private val NO_FONT_PADDING = PlatformTextStyle(includeFontPadding = false)
/**
* Compose rendering of the attachment transfer controls (start/cancel/progress) that overlay a media thumbnail.
*
* This renders a [TransferControlsRenderState] produced by [TransferControls.deriveRenderState]. All state derivation lives in
* [TransferControls]; this function is purely presentational so the various visual states can be previewed and tested directly.
*/
@Composable
fun TransferControls(
state: TransferControlsRenderState,
modifier: Modifier = Modifier,
onStartClick: () -> Unit = {},
onCancelClick: () -> Unit = {},
onPlayClick: () -> Unit = {}
) {
Box(modifier = modifier.fillMaxSize()) {
when (state) {
is TransferControlsRenderState.Gone -> Unit
is TransferControlsRenderState.Pending -> Content(
control = TransferProgressState.Ready(
icon = arrowIcon(state.isUpload),
startButtonContentDesc = startContentDescription(state.isUpload),
startButtonOnClickLabel = startContentDescription(state.isUpload),
onStartClick = onStartClick
),
placement = state.placement,
showPlayButton = state.showPlayButton,
centerLabel = state.itemCount?.let { pluralStringResource(R.plurals.TransferControlView_n_items, it, it) },
cornerText = state.sizeBytes?.toUnitString(),
onPlayClick = onPlayClick
)
is TransferControlsRenderState.Retry -> Content(
control = TransferProgressState.Ready(
icon = arrowIcon(state.isUpload),
startButtonContentDesc = startContentDescription(state.isUpload),
startButtonOnClickLabel = startContentDescription(state.isUpload),
onStartClick = onStartClick
),
placement = TransferControls.Placement.CORNER,
showPlayButton = false,
centerLabel = null,
cornerText = stringResource(R.string.NetworkFailure__retry),
onPlayClick = onPlayClick
)
is TransferControlsRenderState.InProgress -> {
val cancelLabel = stringResource(android.R.string.cancel)
val label = state.label
val progressFormat = stringResource(R.string.TransferControlView__download_progress_s_s)
val cornerTextReserveWidthFor = (label as? TransferControls.ProgressLabel.Bytes)?.let { byteLabel ->
val unit = byteLabel.total.getLargestNonZeroSize()
val widestCompleted = byteLabel.total.toUnitString(unit, padDecimals = true, withUnit = false)
val totalText = byteLabel.total.toUnitString(unit)
progressFormat.format(widestCompleted, totalText)
}
Content(
control = TransferProgressState.InProgress(
progress = state.progress,
cancelAction = if (state.cancelable) {
TransferProgressState.InProgress.CancelAction(
contentDesc = cancelLabel,
onClickLabel = cancelLabel,
onClick = onCancelClick
)
} else {
null
}
),
placement = state.placement,
showPlayButton = state.showPlayButton,
centerLabel = null,
cornerText = label?.let { progressLabelText(it) },
cornerTextReserveWidthFor = cornerTextReserveWidthFor,
onPlayClick = onPlayClick
)
}
}
}
}
@Composable
private fun BoxScope.Content(
control: TransferProgressState,
placement: TransferControls.Placement,
showPlayButton: Boolean,
centerLabel: String?,
cornerText: String?,
onPlayClick: () -> Unit,
cornerTextReserveWidthFor: String? = null
) {
val controlInCenter = placement == TransferControls.Placement.CENTER
val controlInCorner = placement == TransferControls.Placement.CORNER
if (controlInCenter || showPlayButton || centerLabel != null) {
Pill(
modifier = Modifier.align(Alignment.Center),
cornerRadius = 24.dp
) {
if (controlInCenter) {
OnMediaIndicator(control, CENTER_CONTROL_SIZE)
}
if (showPlayButton) {
PlayButton(onPlayClick)
}
if (centerLabel != null) {
Text(
text = centerLabel,
style = MaterialTheme.typography.bodyLarge.copy(platformStyle = NO_FONT_PADDING),
color = colorResource(CoreUiR.color.signal_colorOnCustom),
maxLines = 1,
modifier = Modifier.padding(end = 12.dp)
)
}
}
}
if (controlInCorner || cornerText != null) {
Pill(
modifier = Modifier
.align(Alignment.TopStart)
.padding(4.dp),
cornerRadius = 16.dp
) {
if (controlInCorner) {
OnMediaIndicator(control, 32.dp)
}
if (cornerText != null) {
if (!controlInCorner) {
Spacer(modifier = Modifier.width(8.dp))
}
CornerText(
text = cornerText,
reserveWidthFor = cornerTextReserveWidthFor
)
}
}
}
}
@Composable
private fun Pill(
modifier: Modifier = Modifier,
cornerRadius: Dp,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(cornerRadius))
.background(colorResource(CoreUiR.color.signal_colorTransparentInverse4)),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
/**
* Wraps [TransferProgressIndicator] in a color scheme override so it adopts the on-media ("OnCustom") palette rather than the
* default surface palette, matching the legacy view's appearance over thumbnails.
*/
@Composable
private fun OnMediaIndicator(state: TransferProgressState, size: Dp) {
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
onSurface = colorResource(CoreUiR.color.signal_colorOnCustom),
surfaceContainerHighest = colorResource(CoreUiR.color.signal_colorTransparent2)
)
) {
TransferProgressIndicator(state = state, size = size)
}
}
@Composable
private fun PlayButton(onPlayClick: () -> Unit) {
val description = stringResource(R.string.ThumbnailView_Play_video_description)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.triangle_right),
contentDescription = description,
tint = colorResource(CoreUiR.color.signal_colorOnCustom),
modifier = Modifier
.size(CENTER_CONTROL_SIZE)
.clickableContainer(
contentDescription = description,
onClickLabel = description,
onClick = onPlayClick
)
.padding(10.dp)
)
}
@Composable
private fun CornerText(
text: String,
reserveWidthFor: String?
) {
val reserving = reserveWidthFor != null
val style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Light,
platformStyle = NO_FONT_PADDING
)
val effectiveStyle = if (reserving) style.copy(fontFeatureSettings = "tnum") else style
val widthModifier = if (reserving) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val reservedWidth = remember(reserveWidthFor, effectiveStyle, density) {
with(density) { measurer.measure(reserveWidthFor, effectiveStyle).size.width.toDp() }
}
Modifier.width(reservedWidth)
} else {
Modifier
}
Text(
text = text,
style = effectiveStyle,
color = colorResource(CoreUiR.color.signal_colorOnCustom),
maxLines = 1,
textAlign = if (reserving) TextAlign.End else null,
modifier = Modifier
.padding(end = 8.dp, top = 8.dp, bottom = 8.dp)
.then(widthModifier)
)
}
@Composable
private fun arrowIcon(isUpload: Boolean): ImageVector {
return ImageVector.vectorResource(if (isUpload) R.drawable.symbol_arrow_up_24 else R.drawable.symbol_arrow_down_24)
}
@Composable
private fun startContentDescription(isUpload: Boolean): String {
return stringResource(if (isUpload) R.string.TransferControlView__upload else R.string.TransferControlView__download)
}
@Composable
private fun progressLabelText(label: TransferControls.ProgressLabel): String {
return when (label) {
is TransferControls.ProgressLabel.Processing -> stringResource(R.string.TransferControlView__processing)
is TransferControls.ProgressLabel.Bytes -> {
val unit = label.total.getLargestNonZeroSize()
stringResource(
R.string.TransferControlView__download_progress_s_s,
label.completed.toUnitString(unit, padDecimals = true, withUnit = false),
label.total.toUnitString(unit)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsPendingSinglePreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.Pending(
isUpload = false,
placement = TransferControls.Placement.CENTER,
showPlayButton = false,
sizeBytes = (2 * 1024 * 1024L).bytes
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsPendingGalleryPreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.Pending(
isUpload = false,
placement = TransferControls.Placement.CENTER,
showPlayButton = false,
itemCount = 3,
sizeBytes = (6 * 1024 * 1024L).bytes
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsPendingPlayableVideoPreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.Pending(
isUpload = false,
placement = TransferControls.Placement.CORNER,
showPlayButton = true,
sizeBytes = (12 * 1024 * 1024L).bytes
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsDownloadingSinglePreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.InProgress(
isUpload = false,
placement = TransferControls.Placement.CORNER,
progress = 0.45f,
showPlayButton = false,
cancelable = true,
label = TransferControls.ProgressLabel.Bytes((1024 * 1024L).bytes, (2 * 1024 * 1024L).bytes)
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsAwaitingPrimaryPreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.InProgress(
isUpload = false,
placement = TransferControls.Placement.CENTER,
progress = null,
showPlayButton = false,
cancelable = false,
label = null
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferControlsRetryPreview() {
Previews.Preview {
PreviewSurface {
TransferControls(
state = TransferControlsRenderState.Retry(isUpload = false)
)
}
}
}
@Composable
private fun PreviewSurface(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.size(150.dp)
.background(colorResource(CoreUiR.color.signal_colorTransparent2))
) {
content()
}
}
@@ -13,20 +13,32 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.clickableContainer
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
/**
* A button that can be used to start, cancel, show progress, and show completion of a data transfer.
@@ -34,10 +46,19 @@ import org.signal.core.ui.compose.clickableContainer
@Composable
fun TransferProgressIndicator(
state: TransferProgressState,
modifier: Modifier = Modifier.size(48.dp)
modifier: Modifier = Modifier,
size: Dp = 48.dp
) {
// Internal paddings are tuned for a 48dp control; scale them with [size] so the icon/ring proportions are preserved at
// other sizes. At 48dp this is a no-op, so existing callers are unaffected.
val scale = size / 48.dp
val sizedModifier = modifier.size(size)
AnimatedContent(
targetState = state,
// Key on the state type, not the value, so that progress updates within InProgress recompose in place instead of
// re-triggering the enter/exit transition on every tick (which would prevent the determinate fill from ever settling).
contentKey = { it::class },
transitionSpec = {
val startDelay = 200
val enterTransition = fadeIn(tween(delayMillis = startDelay, durationMillis = 500)) + scaleIn(tween(delayMillis = startDelay, durationMillis = 400))
@@ -48,9 +69,9 @@ fun TransferProgressIndicator(
}
) { targetState ->
when (targetState) {
is TransferProgressState.Ready -> StartTransferButton(targetState, modifier)
is TransferProgressState.InProgress -> ProgressIndicator(targetState, modifier)
is TransferProgressState.Complete -> CompleteIcon(targetState, modifier)
is TransferProgressState.Ready -> StartTransferButton(targetState, sizedModifier, scale)
is TransferProgressState.InProgress -> ProgressIndicator(targetState, sizedModifier, scale)
is TransferProgressState.Complete -> CompleteIcon(targetState, sizedModifier, scale)
}
}
}
@@ -58,7 +79,8 @@ fun TransferProgressIndicator(
@Composable
private fun StartTransferButton(
state: TransferProgressState.Ready,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scale: Float = 1f
) {
Box(
modifier = modifier
@@ -74,7 +96,7 @@ private fun StartTransferButton(
contentDescription = null,
modifier = Modifier
.matchParentSize()
.padding(12.dp)
.padding(12.dp * scale)
)
}
}
@@ -82,7 +104,8 @@ private fun StartTransferButton(
@Composable
private fun ProgressIndicator(
state: TransferProgressState.InProgress,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scale: Float = 1f
) {
Box(
modifier = modifier
@@ -97,7 +120,7 @@ private fun ProgressIndicator(
Modifier
}
)
.padding(10.dp)
.padding(10.dp * scale)
) {
state.icon?.let { icon ->
Icon(
@@ -106,7 +129,7 @@ private fun ProgressIndicator(
contentDescription = null,
modifier = Modifier
.matchParentSize()
.padding(6.dp)
.padding(6.dp * scale)
)
}
@@ -141,19 +164,32 @@ private fun ProgressIndicator(
modifier = indicatorModifier
)
}
// When cancelable, draw the filled "stop" square in the center of the ring (matches the legacy view's
// IN_PROGRESS_CANCELABLE state). Sized as a fraction of the control so it scales with center/corner placements.
if (state.cancelAction != null) {
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxSize(0.3f)
.clip(RoundedCornerShape(percent = 15))
.background(MaterialTheme.colorScheme.onSurface)
)
}
}
}
@Composable
private fun CompleteIcon(
state: TransferProgressState.Complete,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scale: Float = 1f
) {
Icon(
imageVector = state.icon,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = state.iconContentDesc,
modifier = modifier.padding(12.dp)
modifier = modifier.padding(12.dp * scale)
)
}
@@ -183,3 +219,97 @@ sealed interface TransferProgressState {
val iconContentDesc: String
) : TransferProgressState
}
@DayNightPreviews
@Composable
private fun TransferProgressIndicatorReadyPreview() {
Previews.Preview {
PreviewBackdrop {
TransferProgressIndicator(
state = TransferProgressState.Ready(
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
startButtonContentDesc = "",
startButtonOnClickLabel = "",
onStartClick = {}
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferProgressIndicatorIndeterminatePreview() {
Previews.Preview {
PreviewBackdrop {
TransferProgressIndicator(
state = TransferProgressState.InProgress(
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
progress = null,
cancelAction = null
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferProgressIndicatorDeterminatePreview() {
Previews.Preview {
PreviewBackdrop {
TransferProgressIndicator(
state = TransferProgressState.InProgress(
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
progress = 0.4f,
cancelAction = null
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferProgressIndicatorCancelablePreview() {
Previews.Preview {
PreviewBackdrop {
TransferProgressIndicator(
state = TransferProgressState.InProgress(
progress = 0.4f,
cancelAction = TransferProgressState.InProgress.CancelAction(
contentDesc = "",
onClickLabel = "",
onClick = {}
)
)
)
}
}
}
@DayNightPreviews
@Composable
private fun TransferProgressIndicatorCompletePreview() {
Previews.Preview {
PreviewBackdrop {
TransferProgressIndicator(
state = TransferProgressState.Complete(
icon = ImageVector.vectorResource(R.drawable.symbol_check_white_24),
iconContentDesc = ""
)
)
}
}
}
@Composable
private fun PreviewBackdrop(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.size(96.dp)
.background(colorResource(CoreUiR.color.signal_colorTransparent2))
) {
content()
}
}
@@ -1,200 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.transfercontrols
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import androidx.annotation.Discouraged
import androidx.core.content.ContextCompat
import androidx.core.graphics.withTranslation
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import kotlin.math.roundToInt
import org.signal.core.ui.R as CoreUiR
/**
* This displays a circular progress around an icon. The icon is either an upload arrow, a download arrow, or a rectangular stop button.
*/
@Discouraged("Use TransferProgressIndicator instead.")
class TransferProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes) {
companion object {
const val TAG = "TransferProgressView"
private const val PROGRESS_ARC_STROKE_WIDTH_DP = 2f
private const val ICON_SIZE_DP = 24f
private const val STOP_CORNER_RADIUS_DP = 4f
private const val PROGRESS_BAR_INSET_DP = 2
}
private val iconColor: Int
private val progressColor: Int
private val trackColor: Int
private val stopIconPaint: Paint
private val progressPaint: Paint
private val trackPaint: Paint
private val progressArcStrokeWidth: Float
private val iconSize: Float
private val stopIconSize: Float
private val stopIconCornerRadius: Float
private val progressRect = RectF()
private val stopIconRect = RectF()
private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_down_24)
private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_up_24)
private var progressPercent = 0f
private var currentState = State.UNINITIALIZED
var startClickListener: OnClickListener? = null
var cancelClickListener: OnClickListener? = null
init {
val displayDensity = Resources.getSystem().displayMetrics.density
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TransferProgressView, 0, 0)
val signalCustomColor = ContextCompat.getColor(context, CoreUiR.color.signal_colorOnCustom)
val signalTransparent2 = ContextCompat.getColor(context, CoreUiR.color.signal_colorTransparent2)
iconColor = typedArray.getColor(R.styleable.TransferProgressView_transferIconColor, signalCustomColor)
progressColor = typedArray.getColor(R.styleable.TransferProgressView_progressColor, signalCustomColor)
trackColor = typedArray.getColor(R.styleable.TransferProgressView_trackColor, signalTransparent2)
progressArcStrokeWidth = typedArray.getDimension(R.styleable.TransferProgressView_progressArcWidth, PROGRESS_ARC_STROKE_WIDTH_DP * displayDensity)
iconSize = typedArray.getDimension(R.styleable.TransferProgressView_iconSize, ICON_SIZE_DP * displayDensity)
stopIconSize = typedArray.getDimension(R.styleable.TransferProgressView_stopIconSize, ICON_SIZE_DP * displayDensity)
stopIconCornerRadius = typedArray.getDimension(R.styleable.TransferProgressView_stopIconCornerRadius, STOP_CORNER_RADIUS_DP * displayDensity)
typedArray.recycle()
progressPaint = progressPaint(progressColor)
stopIconPaint = stopIconPaint(iconColor)
trackPaint = trackPaint(trackColor)
val filter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_ATOP)
downloadDrawable?.colorFilter = filter
uploadDrawable?.colorFilter = filter
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
when (currentState) {
State.IN_PROGRESS_CANCELABLE -> drawProgress(canvas, progressPercent, true)
State.IN_PROGRESS_NON_CANCELABLE -> drawProgress(canvas, progressPercent, false)
State.READY_TO_UPLOAD -> sizeAndDrawDrawable(canvas, uploadDrawable)
State.READY_TO_DOWNLOAD -> sizeAndDrawDrawable(canvas, downloadDrawable)
State.UNINITIALIZED -> Unit
}
}
fun setProgress(progress: Float) {
currentState = State.IN_PROGRESS_CANCELABLE
if (cancelClickListener == null) {
Log.i(TAG, "Illegal click listener attached.")
} else {
setOnClickListener(cancelClickListener)
}
progressPercent = progress
invalidate()
}
fun setStopped(isUpload: Boolean) {
val newState = if (isUpload) State.READY_TO_UPLOAD else State.READY_TO_DOWNLOAD
currentState = newState
if (startClickListener == null) {
Log.i(TAG, "Illegal click listener attached.")
} else {
setOnClickListener(startClickListener)
}
progressPercent = 0f
invalidate()
}
private fun drawProgress(canvas: Canvas, progressPercent: Float, showStopIcon: Boolean) {
if (showStopIcon) {
stopIconRect.set(0f, 0f, stopIconSize, stopIconSize)
canvas.withTranslation(width / 2 - (stopIconSize / 2), height / 2 - (stopIconSize / 2)) {
drawRoundRect(stopIconRect, stopIconCornerRadius, stopIconCornerRadius, stopIconPaint)
}
}
val trackWidthScaled = progressArcStrokeWidth
val inset: Float = PROGRESS_BAR_INSET_DP * Resources.getSystem().displayMetrics.density
progressRect.left = trackWidthScaled + inset
progressRect.top = trackWidthScaled + inset
progressRect.right = (width - trackWidthScaled) - inset
progressRect.bottom = (height - trackWidthScaled) - inset
canvas.drawArc(progressRect, 0f, 360f, false, trackPaint)
canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint)
}
private fun stopIconPaint(paintColor: Int): Paint {
val stopIconPaint = Paint()
stopIconPaint.color = paintColor
stopIconPaint.isAntiAlias = true
stopIconPaint.style = Paint.Style.FILL
return stopIconPaint
}
private fun trackPaint(trackColor: Int): Paint {
val trackPaint = Paint()
trackPaint.color = trackColor
trackPaint.isAntiAlias = true
trackPaint.style = Paint.Style.STROKE
trackPaint.strokeWidth = progressArcStrokeWidth
return trackPaint
}
private fun progressPaint(progressColor: Int): Paint {
val progressPaint = Paint()
progressPaint.color = progressColor
progressPaint.isAntiAlias = true
progressPaint.style = Paint.Style.STROKE
progressPaint.strokeWidth = progressArcStrokeWidth
return progressPaint
}
private fun sizeAndDrawDrawable(canvas: Canvas, drawable: Drawable?) {
if (drawable == null) {
Log.w(TAG, "Could not load icon for $currentState")
return
}
val centerX = width / 2f
val centerY = height / 2f
// 0, 0 is the top left corner
// width, height is the bottom right
val halfIconSize = (iconSize / 2f)
val left = (centerX - halfIconSize).roundToInt().coerceAtLeast(0)
val top = (centerY - halfIconSize).roundToInt().coerceAtLeast(0)
val right = (centerX + halfIconSize).roundToInt().coerceAtMost(width)
val bottom = (centerY + halfIconSize).roundToInt().coerceAtMost(height)
drawable.setBounds(left, top, right, bottom)
drawable.draw(canvas)
}
private enum class State {
IN_PROGRESS_CANCELABLE,
IN_PROGRESS_NON_CANCELABLE,
READY_TO_UPLOAD,
READY_TO_DOWNLOAD,
UNINITIALIZED
}
}
@@ -115,10 +115,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
if (controllerInfo.getUid() != Process.myUid()) {
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
return null;
}
return mediaSession;
}
@@ -8,9 +8,7 @@ package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
@@ -96,11 +94,12 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
private var latestUri = Uri.EMPTY
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
if (Build.VERSION.SDK_INT >= 28 && controller.uid != Process.myUid()) {
Log.w(TAG, "Rejecting connection from external caller: ${controller.packageName}")
return MediaSession.ConnectionResult.reject()
return if (controller.isTrusted) {
MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
} else {
Log.w(TAG, "Rejecting connection from non-trusted caller: ${controller.packageName}")
MediaSession.ConnectionResult.reject()
}
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
}
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
@@ -96,32 +96,32 @@ private fun ParticipantActionsSheetContent(
) {
ParticipantHeader(recipient = recipient)
val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink)
if (hasAdminActions) {
if (callParticipant.isMicrophoneEnabled) {
Dividers.Default()
if (callParticipant.isMicrophoneEnabled) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
onClick = {
onMuteAudio(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
onClick = {
onMuteAudio(callParticipant)
onDismiss()
}
)
}
if (isSelfAdmin && isCallLink) {
if (!callParticipant.isMicrophoneEnabled) {
Dividers.Default()
}
if (isCallLink) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
onClick = {
onRemoveFromCall(callParticipant)
onDismiss()
}
)
}
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
onClick = {
onRemoveFromCall(callParticipant)
onDismiss()
}
)
}
Dividers.Default()
@@ -471,6 +471,7 @@ private fun VideoRenderer(
}
setMirror(mirror)
applyScreenShareAwareScaling(participant.isScreenSharing)
}
renderer = textureRenderer
@@ -491,6 +492,7 @@ private fun VideoRenderer(
}
textureRenderer.setMirror(mirror)
textureRenderer.applyScreenShareAwareScaling(participant.isScreenSharing)
}
},
onRelease = {
@@ -500,6 +502,18 @@ private fun VideoRenderer(
)
}
/**
* Screen-shared content is fit inside the view ([RendererCommon.ScalingType.SCALE_ASPECT_FIT]) so nothing is cropped,
* while camera video fills the view, falling back to balanced scaling when the video orientation does not match the view.
*/
private fun TextureViewRenderer.applyScreenShareAwareScaling(isScreenSharing: Boolean) {
if (isScreenSharing) {
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} else {
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL, RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
}
}
@Composable
internal fun ParticipantAudioIndicator(
participant: CallParticipant,
@@ -620,7 +620,7 @@ private fun ParticipantContextMenu(
.background(color = MaterialTheme.colorScheme.surfaceVariant)
)
if (isSelfAdmin && resolved.isMicrophoneEnabled) {
if (resolved.isMicrophoneEnabled) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) },
@@ -35,6 +35,7 @@ public class ContactRepository {
public static final String ID_COLUMN = "id";
public static final String NAME_COLUMN = "name";
public static final String SORT_NAME_COLUMN = "sort_name";
static final String NUMBER_COLUMN = "number";
static final String NUMBER_TYPE_COLUMN = "number_type";
static final String LABEL_COLUMN = "label";
@@ -55,6 +56,11 @@ public class ContactRepository {
return Util.getFirstNonEmpty(system, profile);
}));
// The key the results are actually ordered by (nickname/system/profile/username, lowercased). Letter
// headers must derive from this rather than NAME_COLUMN, which omits nickname/username and can begin
// with a different letter than the row's sort position.
add(new Pair<>(SORT_NAME_COLUMN, cursor -> CursorUtil.requireString(cursor, RecipientTable.SORT_NAME)));
add(new Pair<>(NUMBER_COLUMN, cursor -> {
String phone = CursorUtil.requireString(cursor, RecipientTable.E164);
String email = CursorUtil.requireString(cursor, RecipientTable.EMAIL);
@@ -9,7 +9,6 @@ import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.provider.ContactsContract;
import com.bumptech.glide.load.data.StreamLocalUriFetcher;
@@ -31,7 +30,7 @@ class ContactPhotoLocalUriFetcher extends StreamLocalUriFetcher {
protected InputStream loadResource(Uri uri, ContentResolver contentResolver)
throws FileNotFoundException
{
if (VERSION.SDK_INT >= VERSION_CODES.ICE_CREAM_SANDWICH) {
if (VERSION.SDK_INT >= 14) {
return ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri, true);
} else {
return ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri);
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.database.model.ThreadWithRecipient
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository
@@ -240,13 +239,33 @@ class ContactSearchPagedDataSource(
return 0
}
private fun getNonGroupHeaderLetterMap(section: ContactSearchConfiguration.Section.Individuals, query: String?): Map<RecipientId, String> {
return contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(
query = query,
includeSelfMode = section.includeSelfMode,
includePush = true,
includeSms = false
)
/**
* Returns the letter header to display above the recipient at the cursor's current row, or null if
* none should be shown. A header is shown only when this row begins a new letter group, determined by
* comparing its letter to the immediately preceding row in display order. Peeking that single adjacent
* row means a letter group split across pages still yields exactly one header, anchored to the first
* row of the group, without re-scanning the whole contact set.
*
* The cursor is restored to its original position before returning so iteration is unaffected.
*/
private fun getHeaderLetterForCurrentRow(cursor: Cursor): String? {
val position = cursor.position
val currentLetter = letterForCurrentRow(cursor) ?: return null
if (position <= 0) {
return currentLetter
}
cursor.moveToPosition(position - 1)
val previousLetter = letterForCurrentRow(cursor)
cursor.moveToPosition(position)
return if (previousLetter != currentLetter) currentLetter else null
}
private fun letterForCurrentRow(cursor: Cursor): String? {
val sortName = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.SORT_NAME_COLUMN))
return sortName?.takeIf { it.isNotEmpty() }?.first()?.uppercaseChar()?.toString()
}
private fun getStoriesSearchIterator(query: String?): ContactSearchIterator<Cursor> {
@@ -379,12 +398,6 @@ class ContactSearchPagedDataSource(
}
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
val headerMap: Map<RecipientId, String> = if (section.includeLetterHeaders) {
getNonGroupHeaderLetterMap(section, query)
} else {
emptyMap()
}
return getNonGroupSearchIterator(section, query).use { records ->
readContactData(
records = records,
@@ -394,7 +407,8 @@ class ContactSearchPagedDataSource(
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(it)
ContactSearchData.KnownRecipient(section.sectionKey, recipient, headerLetter = headerMap[recipient.id])
val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it) else null
ContactSearchData.KnownRecipient(section.sectionKey, recipient, headerLetter = headerLetter)
}
)
}
@@ -43,10 +43,6 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.querySignalContacts(contactsSearchQuery)
}
open fun querySignalContactLetterHeaders(query: String?, includeSelfMode: RecipientTable.IncludeSelfMode, includePush: Boolean, includeSms: Boolean): Map<RecipientId, String> {
return SignalDatabase.recipients.querySignalContactLetterHeaders(query ?: "", includeSelfMode, includePush, includeSms)
}
open fun queryGroupMemberContacts(query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "")
}
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -94,9 +93,11 @@ class ContactSearchViewModel(
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
val query: StateFlow<String?> = rawQuery
init {
viewModelScope.launch {
rawQuery.drop(1).debounce(300).collect { query ->
rawQuery.drop(1).collect { query ->
savedStateHandle[QUERY] = query
internalConfigurationState.update { it.copy(query = query) }
}
@@ -146,19 +147,19 @@ class ContactSearchViewModel(
}
suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
val size = withContext(Dispatchers.IO) { pagedDataSource.size() }
val (pagedDataSource, size) = withContext(Dispatchers.IO) {
val source = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
source to source.size()
}
internalTotalCount.value = size
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
}
fun getQuery(): String? = rawQuery.value
fun setQuery(query: String?) {
rawQuery.value = query
}
@@ -5,6 +5,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.Cdn;
import org.thoughtcrime.securesms.attachments.PointerAttachment;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@@ -118,10 +119,16 @@ public class ContactModelMapper {
if (contact.avatar != null && contact.avatar.avatar != null) {
try {
SignalServiceAttachmentPointer attachmentPointer = AttachmentPointerUtil.createSignalAttachmentPointer(contact.avatar.avatar);
Attachment attachment = PointerAttachment.forPointer(Optional.of(attachmentPointer.asPointer())).get();
boolean isProfile = Boolean.TRUE.equals(contact.avatar.isProfile);
Optional<Attachment> attachment = PointerAttachment.forPointer(Optional.of(attachmentPointer.asPointer()));
avatar = new Avatar(null, attachment, isProfile);
if (!attachment.isPresent()) {
Log.w(TAG, "Unable to create avatar attachment for contact. Ignoring avatar.");
} else if (attachment.get().cdn == Cdn.S3) {
Log.w(TAG, "Ignoring contact avatar that resolves to the internal release-channel CDN.");
} else {
boolean isProfile = Boolean.TRUE.equals(contact.avatar.isProfile);
avatar = new Avatar(null, attachment.get(), isProfile);
}
} catch (InvalidMessageStructureException e) {
Log.w(TAG, "Unable to create avatar for contact", e);
}
@@ -9,8 +9,8 @@ import org.thoughtcrime.securesms.recipients.Recipient
data class ConversationData(
val threadRecipient: Recipient,
val threadId: Long,
val lastSeen: Long,
val lastSeenPosition: Int,
val firstUnreadId: Long,
val firstUnreadPosition: Int,
val lastScrolledPosition: Int,
val jumpToPosition: Int,
val threadSize: Int,
@@ -24,14 +24,14 @@ data class ConversationData(
return jumpToPosition >= 0
}
fun shouldScrollToLastSeen(): Boolean {
return lastSeenPosition > 0
fun shouldScrollToFirstUnread(): Boolean {
return firstUnreadPosition > 0
}
fun getStartPosition(): Int {
return when {
shouldJumpToMessage() -> jumpToPosition
messageRequestData.isMessageRequestAccepted && shouldScrollToLastSeen() -> lastSeenPosition
messageRequestData.isMessageRequestAccepted && shouldScrollToFirstUnread() -> firstUnreadPosition
messageRequestData.isMessageRequestAccepted -> lastScrolledPosition
else -> threadSize
}
@@ -50,8 +50,10 @@ public class ConversationRepository {
public @NonNull ConversationData getConversationData(long threadId, @NonNull Recipient conversationRecipient, int jumpToPosition) {
ThreadTable.ConversationMetadata metadata = SignalDatabase.threads().getConversationMetadata(threadId);
int threadSize = SignalDatabase.messages().getMessageCountForThread(threadId);
long lastSeen = metadata.getLastSeen();
int lastSeenPosition = 0;
MessageTable.OldestUnread oldestUnread = metadata.getUnreadCount() > 0 ? SignalDatabase.messages().getOldestUnread(threadId) : null;
long firstUnreadId = oldestUnread != null ? oldestUnread.getId() : -1;
long firstUnreadDateReceived = oldestUnread != null ? oldestUnread.getDateReceived() : 0;
int firstUnreadPosition = 0;
long lastScrolled = metadata.getLastScrolled();
int lastScrolledPosition = 0;
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(threadId);
@@ -59,15 +61,16 @@ public class ConversationRepository {
ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted, isConversationHidden);
boolean showUniversalExpireTimerUpdate = false;
if (lastSeen > 0) {
lastSeenPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, lastSeen, false);
if (firstUnreadDateReceived > 0) {
firstUnreadPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, firstUnreadDateReceived, false);
}
if (lastSeenPosition <= 0) {
lastSeen = 0;
if (firstUnreadPosition <= 0) {
firstUnreadId = -1;
firstUnreadDateReceived = 0;
}
if (lastSeen == 0 && lastScrolled > 0) {
if (firstUnreadDateReceived == 0 && lastScrolled > 0) {
lastScrolledPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, lastScrolled, true);
}
@@ -108,7 +111,7 @@ public class ConversationRepository {
showUniversalExpireTimerUpdate = true;
}
return new ConversationData(conversationRecipient, threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate, metadata.getUnreadCount(), groupMemberAcis);
return new ConversationData(conversationRecipient, threadId, firstUnreadId, firstUnreadPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate, metadata.getUnreadCount(), groupMemberAcis);
}
public void markGiftBadgeRevealed(long messageId) {
@@ -934,7 +934,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
private void presentTimer(UpdateDescription updateDescription) {
if (updateDescription.hasExpiration() && messageRecord.getExpiresIn() > 0) {
if (updateDescription.hasExpiration() && messageRecord.getExpiresIn() > 0 && messageRecord.getExpireStarted() > 0) {
timer = new ExpirationTimer(messageRecord.getExpireStarted(), messageRecord.getExpiresIn());
handler.post(timerUpdateRunnable);
} else {
@@ -23,6 +23,7 @@ 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.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -50,7 +51,8 @@ fun RecipientSearchBar(
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
modifier: Modifier = Modifier,
enabledKeyboardTypes: List<KeyboardType> = listOf(KeyboardType.Text, KeyboardType.Phone)
enabledKeyboardTypes: List<KeyboardType> = listOf(KeyboardType.Text, KeyboardType.Phone),
onFocusChanged: (Boolean) -> Unit = {}
) {
val state = rememberSearchBarState()
var keyboardType by remember(enabledKeyboardTypes) { mutableStateOf(enabledKeyboardTypes.first()) }
@@ -67,6 +69,7 @@ fun RecipientSearchBar(
TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) },
placeholder = { Text(hint) },
singleLine = true,
textStyle = TextStyle(textDirection = TextDirection.ContentOrLtr),
@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
@@ -29,14 +28,6 @@ open class MultiselectForwardActivity : FragmentWrapperActivity(), MultiselectFo
override val contentViewId: Int = R.layout.multiselect_forward_activity
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
val toolbar: Toolbar = findViewById(R.id.toolbar)
toolbar.setTitle(args.title)
toolbar.setNavigationOnClickListener { exitFlow() }
}
override fun getFragment(): Fragment {
return MultiselectForwardFragment.create(args)
}
@@ -0,0 +1,303 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.foundation.layout.Arrangement.spacedBy
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.isSplitPane
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.rememberRecipientField
@Composable
fun MultiselectForwardBottomBar(
isSplitPane: Boolean,
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
val gutter = dimensionResource(org.signal.core.ui.R.dimen.gutter)
Column(
modifier = modifier
) {
HorizontalDivider(
modifier = Modifier.padding(bottom = 12.dp)
)
Row {
if (isSplitPane) {
Spacer(modifier = Modifier.weight(1f))
}
Column(
modifier = Modifier.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val shouldDisplayAddMessageInThisRow = state.isSendButtonVisible && !state.isAddMessageVisible
Selection(
state,
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp)
.padding(end = if (shouldDisplayAddMessageInThisRow) 8.dp else gutter)
)
if (shouldDisplayAddMessageInThisRow) {
Send(
state = state,
events = events,
modifier = Modifier.padding(end = gutter)
)
}
}
if (state.isAddMessageVisible) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(8.dp),
modifier = Modifier
.heightIn(min = 44.dp)
.horizontalGutters()
) {
AddMessage(
state = state,
events = events,
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp)
)
if (state.isSendButtonVisible) {
Send(
state = state,
events = events
)
}
}
}
}
}
}
}
@Composable
private fun Selection(
state: MultiselectForwardBottomBarState,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(items = state.selection, key = { _, contact -> contact.key }) { index, contact ->
val name by rememberDisplayName(contact)
Text(
text = "$name${if (index != state.selection.lastIndex) ", " else " "}",
modifier = Modifier.padding(start = if (index == 0) dimensionResource(org.signal.core.ui.R.dimen.gutter) else 0.dp)
)
}
}
}
@Composable
private fun AddMessage(
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
TextFields.TextField(
value = state.message,
onValueChange = { events(MultiselectForwardBottomBarEvent.AddMessageUpdate(it)) },
placeholder = {
Text(text = stringResource(R.string.MultiselectForwardFragment__add_a_message))
},
shape = RoundedCornerShape(50),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 10.dp,
bottom = 10.dp
),
modifier = modifier
)
}
@Composable
private fun Send(
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val background = remember(context) { state.sendButtonColors.background.resolve(context) }
val foreground = remember(context) { state.sendButtonColors.foreground.resolve(context) }
IconButtons.IconButton(
enabled = state.isSendButtonEnabled,
onClick = { events(MultiselectForwardBottomBarEvent.SendClick) },
modifier = modifier,
colors = IconButtons.iconButtonColors(
contentColor = Color(foreground),
containerColor = Color(background)
)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_send_fill_24),
contentDescription = stringResource(R.string.ShareActivity__send)
)
}
}
@Composable
private fun rememberDisplayName(contact: MultiselectForwardBottomBarState.SelectedContact): State<String> = when (contact) {
is MultiselectForwardBottomBarState.SelectedContact.KnownRecipient -> {
val context = LocalContext.current
if (contact.recipient.isSelf) {
val noteToSelf = stringResource(R.string.note_to_self)
rememberUpdatedState(noteToSelf)
} else {
rememberRecipientField(contact.recipient) { getShortDisplayName(context) }
}
}
is MultiselectForwardBottomBarState.SelectedContact.UnknownRecipient -> rememberUpdatedState(contact.e164)
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreview() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithSend() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isSendButtonVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithAddMessage() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithBoth() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isSendButtonVisible = true,
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithSplit() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = true,
state = rememberPreviewState(
isSendButtonVisible = true,
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun rememberPreviewState(
isSendButtonVisible: Boolean = false,
isAddMessageVisible: Boolean = false
): MultiselectForwardBottomBarState {
return remember {
MultiselectForwardBottomBarState(
selection = listOf(
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(1), isResolving = false, systemContactName = "Miles")),
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(2), isResolving = false, systemContactName = "Peter")),
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(3), isResolving = false, systemContactName = "May"))
),
isSendButtonVisible = isSendButtonVisible,
isAddMessageVisible = isAddMessageVisible
)
}
}
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
sealed interface MultiselectForwardBottomBarEvent {
data class AddMessageUpdate(val message: String) : MultiselectForwardBottomBarEvent
data object SendClick : MultiselectForwardBottomBarEvent
}
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.runtime.annotation.RememberInComposition
import org.thoughtcrime.securesms.color.ViewColorSet
import org.thoughtcrime.securesms.recipients.Recipient
data class MultiselectForwardBottomBarState @RememberInComposition constructor(
val selection: List<SelectedContact> = emptyList(),
val message: String = "",
val sendButtonColors: ViewColorSet = ViewColorSet.PRIMARY,
val isSendButtonEnabled: Boolean = true,
val isSendButtonVisible: Boolean = false,
val isAddMessageVisible: Boolean = false
) {
sealed interface SelectedContact {
val key: String
data class KnownRecipient(val recipient: Recipient) : SelectedContact {
override val key: String = recipient.id.toString()
}
data class UnknownRecipient(val e164: String) : SelectedContact {
override val key: String = e164
}
}
}
@@ -1,24 +1,38 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.res.ColorStateList
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.TouchDelegate
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -26,23 +40,25 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.color.ViewColorSet
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchError
@@ -50,29 +66,27 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
@@ -91,38 +105,35 @@ import org.thoughtcrime.securesms.util.visible
* It is up to the user of this fragment to handle the result accordingly utilizing a fragment result listener.
*/
class MultiselectForwardFragment :
Fragment(R.layout.multiselect_forward_fragment),
ComposeFragment(),
SafetyNumberBottomSheet.Callbacks,
ChooseStoryTypeBottomSheet.Callback,
GroupStoryEducationSheet.Callback,
WrapperDialogFragment.WrapperDialogFragmentCallback,
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val viewModel: MultiselectForwardViewModel by viewModel {
MultiselectForwardViewModel(args)
}
private val contactSearchViewModel: ContactSearchViewModel by viewModels {
ContactSearchViewModel.Factory(
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = !args.selectSingleRecipient,
repository = ContactSearchRepository(),
performSafetyNumberChecks = true,
arbitraryRepository = null,
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository(),
searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
)
}
private val disposables = LifecycleDisposable()
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearch: ContactSearchView
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
return MultiselectForwardViewModel.Factory(args.storySendRequirements, args.multiShareArgs, args.forceSelectionOnly, MultiselectForwardRepository())
}
private var bottomBarHeightPx by mutableIntStateOf(0)
private val args: MultiselectForwardFragmentArgs by lazy {
requireArguments().getParcelableCompat(ARGS, MultiselectForwardFragmentArgs::class.java)!!
@@ -136,105 +147,109 @@ class MultiselectForwardFragment :
}
}
@Composable
override fun FragmentContent() {
CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) {
MultiselectForwardScreen(
isSplitPane = !args.isWrappedInBottomSheet && LocalResources.current.rememberIsSplitPane(),
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = this@MultiselectForwardFragment::getConfiguration,
contactSearchCallbacks = remember { SearchCallbacks() },
additionalEntries = findListener<SearchConfigurationProvider>()?.getAdditionalEntries() ?: persistentHashMapOf(),
bottomContentPadding = with(LocalDensity.current) { bottomBarHeightPx.toDp() }
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Log.d(TAG, "onViewCreated()")
view.minimumHeight = resources.displayMetrics.heightPixels
contactSearch = view.findViewById(R.id.contact_selection_list)
contactSearch.bind(
viewModel = contactSearchViewModel,
fragmentManager = childFragmentManager,
displayOptions = ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
),
mapStateToConfiguration = this::getConfiguration,
callbacks = object : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
return filtered
}
}
)
callback = findListener()!!
callback = requireListener()
disposables.bindTo(viewLifecycleOwner.lifecycle)
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.visible = args.isSearchEnabled
contactFilterView.setOnSearchInputFocusChangedListener { _, hasFocus ->
if (hasFocus) {
callback.onSearchInputFocused()
}
}
contactFilterView.setOnFilterChangedListener {
contactSearchViewModel.setQuery(it)
}
val container = callback.getContainer()
val title: TextView? = container.findViewById(R.id.title)
val bottomBarAndSpacer = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar_and_spacer, container, false)
val bottomBar: ViewGroup = bottomBarAndSpacer.findViewById(R.id.bottom_bar)
val bottomBarSpacer: View = bottomBarAndSpacer.findViewById(R.id.bottom_bar_spacer)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButtonFrame: View = bottomBar.findViewById(R.id.share_confirm_frame)
val sendButton: AppCompatImageView = bottomBar.findViewById(R.id.share_confirm)
val backgroundHelper: View = bottomBar.findViewById(R.id.background_helper)
val bottomBar = ComposeView(requireContext())
bottomBar.layoutParams = when (container) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { gravity = Gravity.BOTTOM }
is FrameLayout -> FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT, Gravity.BOTTOM)
else -> ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
bottomBar.setContent {
val state by viewModel.bottomBarState.collectAsStateWithLifecycle()
val imeController = LocalSoftwareKeyboardController.current
val isSplitPane = !args.isWrappedInBottomSheet && LocalResources.current.rememberIsSplitPane()
val sendButtonColors: ViewColorSet = args.sendButtonColors
sendButton.setColorFilter(sendButtonColors.foreground.resolve(requireContext()))
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(sendButtonColors.background.resolve(requireContext())))
FullscreenHelper.configureBottomBarLayout(requireActivity(), bottomBarSpacer, bottomBar)
bottomBar.setOnTouchListener { _, _ -> true }
backgroundHelper.setBackgroundColor(callback.getDialogBackgroundColor())
bottomBarSpacer.setBackgroundColor(callback.getDialogBackgroundColor())
title?.setText(args.title)
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.doOnNextLayout {
val rect = Rect()
sendButton.getHitRect(rect)
rect.top -= sendButtonFrame.paddingTop
rect.left -= sendButtonFrame.paddingStart
rect.right += sendButtonFrame.paddingEnd
rect.bottom += sendButtonFrame.paddingBottom
sendButtonFrame.touchDelegate = TouchDelegate(rect, sendButton)
SignalTheme {
Surface(
color = remember { Color(callback.getDialogBackgroundColor()) },
// Swallow touches that miss the bar's children so they don't toggle a contact row behind it.
modifier = Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
}
) {
MultiselectForwardBottomBar(
state = state,
events = {
when (it) {
is MultiselectForwardBottomBarEvent.AddMessageUpdate -> {
viewModel.setMessage(it.message)
}
MultiselectForwardBottomBarEvent.SendClick -> {
imeController?.hide()
onSend()
}
}
},
isSplitPane = isSplitPane,
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
)
}
}
}
sendButton.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), it)
onSend(it)
if (args.isWrappedInBottomSheet) {
title?.setText(args.title)
}
sendButton.visible = !args.selectSingleRecipient
shareSelectionRecycler.adapter = shareSelectionAdapter
bottomBar.visible = false
container.addView(bottomBarAndSpacer)
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
bottomBarHeightPx = if (bottomBar.isVisible) bottom - top else 0
}
container.addView(bottomBar)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
contactSearchViewModel.selectionState.collect { contactSelection ->
if (contactSelection.isNotEmpty() && args.selectSingleRecipient) {
onSend(sendButton)
onSend()
return@collect
}
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
viewModel.setShareSelection(
contactSelection.map { key ->
val contact = key.requireShareContact()
contact.recipientId
.map<MultiselectForwardBottomBarState.SelectedContact> { MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient.resolved(it)) }
.orElseGet {
MultiselectForwardBottomBarState.SelectedContact.UnknownRecipient(contact.number)
}
}
)
addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty()
viewModel.setAddMessageVisible(!args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty())
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
@@ -245,6 +260,7 @@ class MultiselectForwardFragment :
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
bottomBarHeightPx = 0
}
}
}
@@ -277,19 +293,20 @@ class MultiselectForwardFragment :
dismissibleDialog?.dismiss()
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
}
MultiselectForwardState.Stage.SomeFailed -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithSelection(it.stage.selectedContacts)
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
viewModel.setSendEnabled(it.stage == MultiselectForwardState.Stage.Selection)
}
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelableCompat(CreateStoryWithViewersFragment.STORY_RECIPIENT, RecipientId::class.java)!!
contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactFilterView.clear()
contactSearchViewModel.setQuery(null)
}
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
@@ -297,7 +314,7 @@ class MultiselectForwardFragment :
val keys: Set<ContactSearchKey.RecipientSearchKey> = groups.map { ContactSearchKey.RecipientSearchKey(it, true) }.toSet()
contactSearchViewModel.addToVisibleGroupStories(keys)
contactSearchViewModel.setKeysSelected(keys)
contactFilterView.clear()
contactSearchViewModel.setQuery(null)
}
}
@@ -344,7 +361,7 @@ class MultiselectForwardFragment :
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
viewModel.confirmFirstSend(contactSearchViewModel.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
@@ -353,9 +370,9 @@ class MultiselectForwardFragment :
.show()
}
private fun onSend(sendButton: View) {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
private fun onSend() {
viewModel.setSendEnabled(false)
viewModel.send(contactSearchViewModel.getSelectedContacts())
}
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
@@ -387,7 +404,7 @@ class MultiselectForwardFragment :
callback.exitFlow()
}
private fun getMessageCount(): Int = args.multiShareArgs.size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun getMessageCount(): Int = args.multiShareArgs.size + if (viewModel.bottomBarState.value.message.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
Log.d(TAG, "handleMessageExpired")
@@ -413,7 +430,7 @@ class MultiselectForwardFragment :
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
viewModel.confirmSafetySend(addMessage.text.toString(), destinations.toSet())
viewModel.confirmSafetySend(destinations.toSet())
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
@@ -439,9 +456,11 @@ class MultiselectForwardFragment :
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_will_be_trimmed)
}
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_sent_to_stories_cant)
}
Stories.MediaTransform.SendRequirements.VALID_DURATION -> Unit
}
}
@@ -551,6 +570,14 @@ class MultiselectForwardFragment :
contactSearchViewModel.refresh()
}
private inner class SearchCallbacks : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() }}")
return filtered
}
}
interface Callback {
fun onFinishForwardAction()
fun exitFlow()
@@ -559,12 +586,16 @@ class MultiselectForwardFragment :
fun getContainer(): ViewGroup
fun getDialogBackgroundColor(): Int
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
/**
* Called when the user presses the navigation icon in the toolbar. Defaults to exitFlow().
*/
fun navigateUp() = exitFlow()
}
companion object {
private val TAG = Log.tag(MultiselectForwardActivity::class.java)
const val DIALOG_TITLE = "title"
const val ARGS = "args"
const val RESULT_KEY = "result_key"
const val RESULT_SELECTION = "result_selection_recipients"
@@ -592,7 +623,7 @@ class MultiselectForwardFragment :
}
private fun showDialogFragment(supportFragmentManager: FragmentManager, fragment: DialogFragment, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
fragment.arguments = bundleOf(ARGS to multiselectForwardFragmentArgs, DIALOG_TITLE to multiselectForwardFragmentArgs.title)
fragment.arguments = bundleOf(ARGS to multiselectForwardFragmentArgs)
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@@ -6,6 +6,7 @@ import android.os.Parcelable
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.WorkerThread
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.models.media.Media
import org.signal.core.util.StreamUtil
@@ -42,6 +43,7 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
val forceDisableAddMessage: Boolean = false,
val forceSelectionOnly: Boolean = false,
val forceHideToolbar: Boolean = false,
val selectSingleRecipient: Boolean = false,
val sendButtonColors: ViewColorSet = ViewColorSet.PRIMARY,
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND,
@@ -50,6 +52,9 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
val isWrappedInBottomSheet: Boolean = false
) : Parcelable {
@IgnoredOnParcel
val isToolbarVisible: Boolean = !forceHideToolbar && !isWrappedInBottomSheet
fun withSendButtonTint(@ColorInt sendButtonTint: Int) = copy(sendButtonColors = ViewColorSet.forCustomColor(sendButtonTint))
companion object {
@@ -1,25 +1,32 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment.Companion.DIALOG_TITLE
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback {
override fun getTitle(): Int = requireArguments().getInt(DIALOG_TITLE)
class MultiselectForwardFullScreenDialogFragment : DialogFragment(), MultiselectForwardFragment.Callback {
override fun getDialogLayoutResource(): Int = R.layout.fragment_container
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onFinishForwardAction() {
findListener<Callback>()?.onFinishForwardAction()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.multiselect_forward_activity, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
@@ -40,7 +47,7 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup
return requireView().findViewById(R.id.fragment_container_wrapper)!!
}
override fun setResult(bundle: Bundle) {
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.stories.Stories
import java.util.Optional
class MultiselectForwardRepository {
object MultiselectForwardRepository {
class MultiselectForwardResultHandlers(
val onAllMessageSentSuccessfully: () -> Unit,
@@ -0,0 +1,178 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.foundation.layout.Box
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.persistentHashMapOf
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearch
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.conversation.RecipientSearchBar
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MultiselectForwardScreen(
isSplitPane: Boolean,
args: MultiselectForwardFragmentArgs,
contactSearchViewModel: ContactSearchViewModel,
callback: MultiselectForwardFragment.Callback,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
contactSearchCallbacks: ContactSearchCallbacks,
bottomContentPadding: Dp = 0.dp
) {
if (args.isWrappedInBottomSheet) {
MultiselectForwardContent(
isSplitPane = isSplitPane,
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = mapStateToConfiguration,
contactSearchCallbacks = contactSearchCallbacks,
additionalEntries = additionalEntries,
bottomContentPadding = bottomContentPadding
)
} else {
Scaffold(
topBar = {
if (args.isToolbarVisible) {
TopAppBar(
title = {
if (!isSplitPane) {
Text(text = stringResource(args.title))
}
},
navigationIcon = {
IconButton(
onClick = {
callback.navigateUp()
}
) {
Icon(
imageVector = SignalIcons.ArrowStart.imageVector,
contentDescription = stringResource(R.string.DSLSettingsToolbar__navigate_up)
)
}
}
)
}
}
) {
MultiselectForwardContent(
isSplitPane = isSplitPane,
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = mapStateToConfiguration,
contactSearchCallbacks = contactSearchCallbacks,
modifier = Modifier.padding(it),
additionalEntries = additionalEntries,
bottomContentPadding = bottomContentPadding
)
}
}
}
@Composable
private fun MultiselectForwardContent(
isSplitPane: Boolean,
args: MultiselectForwardFragmentArgs,
contactSearchViewModel: ContactSearchViewModel,
callback: MultiselectForwardFragment.Callback,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
contactSearchCallbacks: ContactSearchCallbacks,
additionalEntries: MappingEntryProvider<Any>,
bottomContentPadding: Dp = 0.dp,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (isSplitPane) {
Box(
modifier = Modifier
.weight(1f)
.horizontalGutters()
.padding(top = 4.dp)
) {
Text(
text = stringResource(args.title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(bottom = bottomContentPadding)
) {
if (args.isSearchEnabled) {
val query by contactSearchViewModel.query.collectAsStateWithLifecycle()
RecipientSearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.horizontalGutters(),
query = query ?: "",
onQueryChange = {
contactSearchViewModel.setQuery(it)
},
onSearch = {
contactSearchViewModel.setQuery(it)
},
onFocusChanged = {
if (it) {
callback.onSearchInputFocused()
}
}
)
}
ContactSearch(
viewModel = contactSearchViewModel,
mapStateToConfiguration = mapStateToConfiguration,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
additionalEntries = additionalEntries,
displayOptions = remember {
ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
)
},
callbacks = contactSearchCallbacks
)
}
}
}
@@ -2,38 +2,44 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.livedata.Store
class MultiselectForwardViewModel(
private val storySendRequirements: Stories.MediaTransform.SendRequirements,
private val records: List<MultiShareArgs>,
private val isSelectionOnly: Boolean,
private val repository: MultiselectForwardRepository,
private val args: MultiselectForwardFragmentArgs,
private val identityChangesSince: Long = System.currentTimeMillis()
) : ViewModel() {
private val store = Store(
MultiselectForwardState(
storySendRequirements = storySendRequirements
storySendRequirements = args.storySendRequirements
)
)
val state: LiveData<MultiselectForwardState> = store.stateLiveData
val snapshot: MultiselectForwardState get() = store.state
private val internalBottomBarState = MutableStateFlow(
MultiselectForwardBottomBarState(
sendButtonColors = args.sendButtonColors,
isSendButtonVisible = !args.selectSingleRecipient
)
)
val bottomBarState: StateFlow<MultiselectForwardBottomBarState> = internalBottomBarState
private val disposables = CompositeDisposable()
init {
if (records.isNotEmpty()) {
disposables += repository.checkAllSelectedMediaCanBeSentToStories(records).subscribe { sendRequirements ->
if (args.multiShareArgs.isNotEmpty()) {
disposables += MultiselectForwardRepository.checkAllSelectedMediaCanBeSentToStories(args.multiShareArgs).subscribe { sendRequirements ->
store.update { it.copy(storySendRequirements = sendRequirements) }
}
}
@@ -43,7 +49,23 @@ class MultiselectForwardViewModel(
disposables.clear()
}
fun send(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
fun setSendEnabled(isSendEnabled: Boolean) {
internalBottomBarState.update { it.copy(isSendButtonEnabled = isSendEnabled) }
}
fun setShareSelection(selection: List<MultiselectForwardBottomBarState.SelectedContact>) {
internalBottomBarState.update { it.copy(selection = selection) }
}
fun setAddMessageVisible(isAddMessageVisible: Boolean) {
internalBottomBarState.update { it.copy(isAddMessageVisible = isAddMessageVisible) }
}
fun setMessage(message: String) {
internalBottomBarState.update { it.copy(message = message) }
}
fun send(selectedContacts: Set<ContactSearchKey>) {
if (SignalStore.tooltips.showMultiForwardDialog()) {
SignalStore.tooltips.markMultiForwardDialogSeen()
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
@@ -51,7 +73,7 @@ class MultiselectForwardViewModel(
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
UntrustedRecords.checkForBadIdentityRecords(selectedContacts.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java).toSet(), identityChangesSince) { identityRecords ->
if (identityRecords.isEmpty()) {
performSend(additionalMessage, selectedContacts)
performSend(selectedContacts)
} else {
store.update { state ->
state.copy(
@@ -66,26 +88,26 @@ class MultiselectForwardViewModel(
}
}
fun confirmFirstSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
fun confirmFirstSend(selectedContacts: Set<ContactSearchKey>) {
send(selectedContacts)
}
fun confirmSafetySend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
fun confirmSafetySend(selectedContacts: Set<ContactSearchKey>) {
send(selectedContacts)
}
fun cancelSend() {
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
}
private fun performSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
private fun performSend(selectedContacts: Set<ContactSearchKey>) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
if (records.isEmpty() || isSelectionOnly) {
if (args.multiShareArgs.isEmpty() || args.forceSelectionOnly) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SelectionConfirmed(selectedContacts)) }
} else {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
MultiselectForwardRepository.send(
additionalMessage = bottomBarState.value.message,
multiShareArgs = args.multiShareArgs,
shareContacts = selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
@@ -95,15 +117,4 @@ class MultiselectForwardViewModel(
)
}
}
class Factory(
private val storySendRequirements: Stories.MediaTransform.SendRequirements,
private val records: List<MultiShareArgs>,
private val isSelectionOnly: Boolean,
private val repository: MultiselectForwardRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MultiselectForwardViewModel(storySendRequirements, records, isSelectionOnly, repository)))
}
}
}
@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.runtime.Composable
import androidx.fragment.app.FragmentManager
import kotlinx.collections.immutable.persistentHashMapOf
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
/**
* Allows a parent of MultiselectForwardFragment to provide a custom search page configuration.
@@ -21,4 +24,10 @@ interface SearchConfigurationProvider {
* @return An ArbitraryRepository or null. Returning null will result in not being able to use the Arbitrary section, keys, or data.
*/
fun getArbitraryRepository(): ArbitraryRepository? = null
/**
* @return a mapping of additional entries. Recommended to put your arbitrary stuff here.
*/
@Composable
fun getAdditionalEntries(): MappingEntryProvider<Any> = persistentHashMapOf()
}
@@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentSaver
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.backup.v2.ui.subscription.BackupUpgradeAvailabilityChecker
import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste
import org.thoughtcrime.securesms.badges.gifts.OpenableGift
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet
@@ -1172,7 +1173,7 @@ class ConversationFragment :
.doOnSuccess { state ->
SignalLocalMetrics.ConversationOpen.onDataLoaded()
conversationItemDecorations.selfRecipientId = Recipient.self().id
conversationItemDecorations.setFirstUnreadCount(state.meta.unreadCount)
conversationItemDecorations.setUnreadState(state.meta.unreadCount, state.meta.firstUnreadId)
colorizer.onGroupMembershipChanged(state.meta.groupMemberAcis)
}
.observeOn(AndroidSchedulers.mainThread())
@@ -1301,6 +1302,7 @@ class ConversationFragment :
addTextChangedListener(composeTextEventsListener)
setStylingChangedListener(composeTextEventsListener)
setOnClickListener(composeTextEventsListener)
guardAgainstRecoveryKeyPaste(this@ConversationFragment)
filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
}
@@ -2882,10 +2884,11 @@ class ConversationFragment :
requireContext(),
recipient,
{
val disabledInput = binding.conversationDisabledInput
messageRequestViewModel
.onReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy {
Log.d(TAG, "report spam complete")
toast(R.string.ConversationFragment_reported_as_spam)
@@ -2895,10 +2898,11 @@ class ConversationFragment :
null
} else {
Runnable {
val disabledInput = binding.conversationDisabledInput
messageRequestViewModel
.onBlockAndReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> {
@@ -2957,7 +2961,6 @@ class ConversationFragment :
messageRequestViewModel
.onAccept()
.subscribeWithShowProgress("accept message request")
.addTo(disposables)
}
private fun onDeleteConversation() {
@@ -2976,8 +2979,9 @@ class ConversationFragment :
}
private fun Single<Result<Unit, GroupChangeFailureReason>>.subscribeWithShowProgress(logMessage: String): Disposable {
return doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
val disabledInput = binding.conversationDisabledInput
return doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "$logMessage complete")
@@ -3236,20 +3240,34 @@ class ConversationFragment :
val toolbarOffset = rect.bottom
binding.toolbar.viewTreeObserver.removeOnGlobalLayoutListener(this)
val offset = when {
meta.getStartPosition() == 0 -> 0
meta.shouldJumpToMessage() -> (binding.conversationItemRecycler.height - toolbarOffset) / 4
meta.shouldScrollToLastSeen() -> binding.conversationItemRecycler.height - toolbarOffset
else -> binding.conversationItemRecycler.height
}
val startPosition = meta.getStartPosition()
Log.d(TAG, "Scrolling to start position $startPosition")
Log.d(TAG, "Scrolling to start position ${meta.getStartPosition()}")
layoutManager.scrollToPositionWithOffset(meta.getStartPosition(), offset) {
animationsAllowed = true
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
if (meta.shouldJumpToMessage()) {
binding.conversationItemRecycler.post {
adapter.pulseAtPosition(meta.getStartPosition())
if (!meta.messageRequestData.isMessageRequestAccepted) {
// Always scroll to the top to show header in MR state
layoutManager.scrollToPositionTopAligned(meta.threadSize, toolbarOffset) {
animationsAllowed = true
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
}
} else if (meta.shouldScrollToFirstUnread()) {
// Land the divider just below the toolbar.
layoutManager.scrollToPositionTopAligned(startPosition, toolbarOffset) {
animationsAllowed = true
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
}
} else {
val offset = when {
startPosition == 0 -> 0
meta.shouldJumpToMessage() -> (binding.conversationItemRecycler.height - toolbarOffset) / 4
else -> binding.conversationItemRecycler.height
}
layoutManager.scrollToPositionWithOffset(startPosition, offset) {
animationsAllowed = true
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
if (meta.shouldJumpToMessage()) {
binding.conversationItemRecycler.post {
adapter.pulseAtPosition(startPosition)
}
}
}
}
@@ -10,6 +10,7 @@ import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
@@ -45,6 +46,11 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
unreadViewHolder?.bind()
}
/** The current unread-divider state. Exposed for instrumentation tests asserting end-to-end divider behavior. */
@get:VisibleForTesting
val unreadStateForTesting: UnreadState
get() = unreadState
var currentItems: List<ConversationElement?> = emptyList()
set(value) {
field = value
@@ -119,31 +125,24 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
}
}
/** Must be called before first setting of [currentItems] */
fun setFirstUnreadCount(unreadCount: Int) {
if (unreadState == UnreadState.None && unreadCount > 0) {
unreadState = UnreadState.InitialUnreadState(unreadCount)
/**
* Must be called before first setting of [currentItems]. [firstUnreadId] is the row id of the oldest unread message,
* used as the unread divider's anchor.
*/
fun setUnreadState(unreadCount: Int, firstUnreadId: Long) {
if (unreadState == UnreadState.None && unreadCount > 0 && firstUnreadId > 0) {
unreadState = UnreadState.CompleteUnreadState(unreadCount = unreadCount, firstUnreadId = firstUnreadId)
}
}
/**
* If [unreadState] is [UnreadState.InitialUnreadState] we need to determine the first unread timestamp based on
* initial unread count.
*
* Once in [UnreadState.CompleteUnreadState], need to update the unread count based on new incoming messages since
* the first unread timestamp. If an outgoing message is found in this range the unread state is cleared completely,
* which causes the unread divider to be removed.
* Recomputes the unread count from newer messages up to the first unread message. If an outgoing message is found in
* that range the unread state is cleared, removing the divider.
*/
private fun updateUnreadState(items: List<ConversationElement?>) {
val state: UnreadState = unreadState
if (state is UnreadState.InitialUnreadState) {
val firstUnread: ConversationMessageElement? = findFirstUnreadStartingAt(items, (state.unreadCount - 1).coerceIn(items.indices), state.unreadCount)
val timestamp = firstUnread?.timestamp()
if (timestamp != null) {
unreadState = UnreadState.CompleteUnreadState(unreadCount = state.unreadCount, firstUnreadTimestamp = timestamp)
}
} else if (state is UnreadState.CompleteUnreadState) {
if (state is UnreadState.CompleteUnreadState) {
var newUnreadCount = 0
for (element in items) {
if (element is ConversationMessageElement) {
@@ -155,7 +154,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
newUnreadCount++
}
if (element.timestamp() == state.firstUnreadTimestamp) {
if (element.conversationMessage.messageRecord.id == state.firstUnreadId) {
unreadState = state.copy(unreadCount = max(state.unreadCount, newUnreadCount))
break
}
@@ -165,30 +164,6 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
}
}
/**
* Attempt to find the "first" unread message, searching a range of 20 items in the list starting at index `unreadCount - 1`. The
* search helps us skip over interspersed read messages like chat events that could mess up the location of the header.
*/
private fun findFirstUnreadStartingAt(items: List<ConversationElement?>, startingIndex: Int, unreadCount: Int): ConversationMessageElement? {
val endingIndex = (startingIndex + 20).coerceAtMost(items.lastIndex)
var targetUnread: ConversationMessageElement? = null
var runningUnreadCount = 0
for (index in startingIndex..endingIndex) {
val item = items[index] as? ConversationMessageElement
if ((item?.conversationMessage?.messageRecord as? MmsMessageRecord)?.isRead == false) {
targetUnread = item
runningUnreadCount++
}
if (runningUnreadCount >= unreadCount) {
break
}
}
return targetUnread ?: items[startingIndex] as? ConversationMessageElement
}
/**
* Only include message that would normally count towards unread count when updating the banner while new messages
* come in while viewing the chat.
@@ -201,6 +176,9 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
* Note 2: The caller should've already checked [MmsMessageRecord.isOutgoing] before calling this but some outgoing
* messages don't use the outgoing types like an outgoing group call, so filter on the [MmsMessageRecord.fromRecipient]
* here as well.
*
* Note 3: Only actually-unread rows count -- some inbox-type events are inserted already-read (e.g. identity updates),
* and counting them would inflate the banner past the thread's stored unread count.
*/
private fun MmsMessageRecord.countsTowardsUnread(): Boolean {
val likelyIncoming = MessageTypes.isInboxType(this.type) ||
@@ -208,16 +186,15 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
MessageTypes.isIncomingAudioCall(this.type) ||
MessageTypes.isIncomingVideoCall(this.type)
return likelyIncoming && !MessageTypes.isGroupUpdate(this.type) && this.fromRecipient.id != selfRecipientId
return likelyIncoming && !this.isRead && !MessageTypes.isGroupUpdate(this.type) && this.fromRecipient.id != selfRecipientId
}
private fun isFirstUnread(bindingAdapterPosition: Int): Boolean {
val state = unreadState
return state is UnreadState.CompleteUnreadState &&
state.firstUnreadTimestamp != null &&
bindingAdapterPosition in currentItems.indices &&
(currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp
(currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.conversationMessage?.messageRecord?.id == state.firstUnreadId
}
/**
@@ -365,10 +342,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
/** Unread state hasn't been initialized or there are 0 unreads upon entering the conversation */
object None : UnreadState()
/** On first load of data, there is at least 1 unread message but we don't know the 'position' in the list yet */
data class InitialUnreadState(val unreadCount: Int) : UnreadState()
/** We have at least one unread and know the timestamp of the first unread message and thus 'position' for the header */
data class CompleteUnreadState(val unreadCount: Int, val firstUnreadTimestamp: Long? = null) : UnreadState()
/** We have at least one unread and know the row id of the first unread message, used to position the header */
data class CompleteUnreadState(val unreadCount: Int, val firstUnreadId: Long) : UnreadState()
}
}
@@ -16,6 +16,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView
@@ -39,6 +40,10 @@ class DisabledInputView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object {
private val TAG = Log.tag(DisabledInputView::class.java)
}
private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) }
private var expiredOrUnauthorized: View? = null
@@ -93,30 +98,51 @@ class DisabledInputView @JvmOverloads constructor(
setWallpaperEnabled(recipient.hasWallpaper)
setAcceptOnClickListener {
Log.i(TAG, "[message-request] Accept tapped. isIndividual: ${messageRequestState.isIndividual}, isGroupV2Add: ${messageRequestState.isGroupV2Add}, listener present: ${listener != null}")
if (messageRequestState.isIndividual) {
val signalWillNever = context.getString(R.string.MessageRequestBottomView_signal_will_never)
val body = context.getString(R.string.MessageRequestBottomView_accept_request_body, signalWillNever)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MessageRequestBottomView_accept_request)
.setMessage(SpanUtil.boldSubstring(body, signalWillNever))
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ -> listener?.onAcceptMessageRequestClicked() }
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(false)
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ ->
Log.i(TAG, "[message-request] Individual request confirmed. listener present: ${listener != null}")
listener?.onAcceptMessageRequestClicked()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> Log.i(TAG, "[message-request] Individual request canceled.") }
.show()
} else if (messageRequestState.isGroupV2Add) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MessageRequestBottomView_join_group)
.setMessage(R.string.MessageRequestBottomView_review_requests_carefully_groups)
.setPositiveButton(R.string.MessageRequestBottomView_join) { _, _ -> listener?.onAcceptMessageRequestClicked() }
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(false)
.setPositiveButton(R.string.MessageRequestBottomView_join) { _, _ ->
Log.i(TAG, "[message-request] Group join confirmed. listener present: ${listener != null}")
listener?.onAcceptMessageRequestClicked()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> Log.i(TAG, "[message-request] Group join canceled.") }
.show()
} else {
listener?.onAcceptMessageRequestClicked()
}
}
setDeleteOnClickListener { listener?.onDeleteClicked() }
setBlockOnClickListener { listener?.onBlockClicked() }
setUnblockOnClickListener { listener?.onUnblockClicked() }
setReportOnClickListener { listener?.onReportSpamClicked() }
setDeleteOnClickListener {
Log.i(TAG, "[message-request] Delete tapped. listener present: ${listener != null}")
listener?.onDeleteClicked()
}
setBlockOnClickListener {
Log.i(TAG, "[message-request] Block tapped. listener present: ${listener != null}")
listener?.onBlockClicked()
}
setUnblockOnClickListener {
Log.i(TAG, "[message-request] Unblock tapped. listener present: ${listener != null}")
listener?.onUnblockClicked()
}
setReportOnClickListener {
Log.i(TAG, "[message-request] Report tapped. listener present: ${listener != null}")
listener?.onReportSpamClicked()
}
}
)
}
@@ -167,6 +167,7 @@ import org.signal.core.util.ServiceUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
@@ -430,12 +431,13 @@ public class ConversationListFragment extends MainFragment implements Conversati
maybeScheduleRefreshProfileJob();
ConversationListFragmentExtensionsKt.listenToEventBusWhileResumed(this, mainNavigationViewModel.getDetailLocation());
String query = contactSearchViewModel.getQuery();
String query = contactSearchViewModel.getQuery().getValue();
if (query != null) {
onSearchQueryUpdated(query);
}
if (SignalStore.account().isRegistered() &&
!TextSecurePreferences.isUnauthorizedReceived(requireContext()) &&
SignalStore.settings().getAutomaticVerificationEnabled() &&
SignalStore.misc().getHasKeyTransparencyFailure() &&
!SignalStore.misc().getHasSeenKeyTransparencyFailure()) {
@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.crypto;
import android.os.Build;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
@@ -110,13 +109,13 @@ public final class KeyStoreHelper {
return result.get();
}
@RequiresApi(Build.VERSION_CODES.M)
@RequiresApi(23)
private static SecretKey getOrCreateKeyStoreEntry() {
if (hasKeyStoreEntry()) return getKeyStoreEntry();
else return createKeyStoreEntry();
}
@RequiresApi(Build.VERSION_CODES.M)
@RequiresApi(23)
private static SecretKey createKeyStoreEntry() {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
@@ -133,7 +132,7 @@ public final class KeyStoreHelper {
}
}
@RequiresApi(Build.VERSION_CODES.M)
@RequiresApi(23)
private static SecretKey getKeyStoreEntry() {
KeyStore keyStore = getKeyStore();
@@ -171,7 +170,7 @@ public final class KeyStoreHelper {
}
}
@RequiresApi(Build.VERSION_CODES.M)
@RequiresApi(23)
private static boolean hasKeyStoreEntry() {
try {
KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
@@ -15,6 +15,8 @@ import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode;
import org.thoughtcrime.securesms.database.model.RecipientRecord;
import org.thoughtcrime.securesms.keyvalue.CertificateType;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -56,6 +58,20 @@ public class SealedSenderAccessUtil {
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(recipient, true), getSealedSenderCertificate(), createGroupSendToken);
}
@WorkerThread
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull RecipientRecord record) {
return getSealedSenderAccessFor(record, true);
}
@WorkerThread
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull RecipientRecord record, boolean log) {
return SealedSenderAccess.forIndividual(getAccessFor(record, log));
}
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull RecipientRecord record, @Nullable SealedSenderAccess.CreateGroupSendToken createGroupSendToken) {
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(record, true), getSealedSenderCertificate(), createGroupSendToken);
}
@WorkerThread
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull Recipient recipient, boolean log) {
return getAccessFor(Collections.singletonList(recipient), false, log)
@@ -63,6 +79,39 @@ public class SealedSenderAccessUtil {
.orElse(null);
}
@WorkerThread
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull RecipientRecord record, boolean log) {
byte[] ourUnidentifiedAccessCertificate = SignalStore.certificate().getUnidentifiedAccessCertificate(getUnidentifiedAccessCertificateType());
UnidentifiedAccess unidentifiedAccess = null;
if (ourUnidentifiedAccessCertificate != null) {
try {
unidentifiedAccess = getTargetUnidentifiedAccess(record.getProfileKey(), getEffectiveSealedSenderAccessMode(record), ourUnidentifiedAccessCertificate, false);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Invalid unidentified access certificate!", e);
}
} else {
Log.w(TAG, "Missing our unidentified access certificate!");
}
if (log) {
Log.i(TAG, "Unidentified: " + (unidentifiedAccess != null ? 1 : 0) + ", Other: " + (unidentifiedAccess != null ? 0 : 1));
}
return unidentifiedAccess;
}
/**
* Mirrors {@link Recipient#getSealedSenderAccessMode()}: a recipient addressed only by PNI cannot receive sealed sender.
*/
private static @NonNull SealedSenderAccessMode getEffectiveSealedSenderAccessMode(@NonNull RecipientRecord record) {
if (record.getAci() == null && record.getPni() != null) {
return SealedSenderAccessMode.DISABLED;
} else {
return record.getSealedSenderAccessMode();
}
}
@WorkerThread
public static Map<RecipientId, Optional<UnidentifiedAccess>> getAccessMapFor(@NonNull List<Recipient> recipients, boolean isForStory) {
List<Optional<UnidentifiedAccess>> accessList = getAccessFor(recipients, isForStory, true);
@@ -88,7 +137,8 @@ public class SealedSenderAccessUtil {
UnidentifiedAccess unidentifiedAccess = null;
if (ourUnidentifiedAccessCertificate != null) {
try {
unidentifiedAccess = getTargetUnidentifiedAccess(recipient, ourUnidentifiedAccessCertificate, isForStory);
Recipient resolved = recipient.resolve();
unidentifiedAccess = getTargetUnidentifiedAccess(resolved.getProfileKey(), resolved.getSealedSenderAccessMode(), ourUnidentifiedAccessCertificate, isForStory);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Invalid unidentified access certificate!", e);
}
@@ -135,12 +185,12 @@ public class SealedSenderAccessUtil {
.getUnidentifiedAccessCertificate(getUnidentifiedAccessCertificateType());
}
private static @Nullable UnidentifiedAccess getTargetUnidentifiedAccess(@NonNull Recipient recipient, @NonNull byte[] certificate, boolean isForStory) throws InvalidCertificateException {
ProfileKey theirProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.resolve().getProfileKey());
private static @Nullable UnidentifiedAccess getTargetUnidentifiedAccess(@Nullable byte[] theirProfileKeyBytes, @NonNull SealedSenderAccessMode accessMode, @NonNull byte[] certificate, boolean isForStory) throws InvalidCertificateException {
ProfileKey theirProfileKey = ProfileKeyUtil.profileKeyOrNull(theirProfileKeyBytes);
byte[] accessKey;
switch (recipient.resolve().getSealedSenderAccessMode()) {
switch (accessMode) {
case UNKNOWN:
if (theirProfileKey == null) {
if (isForStory) {
@@ -166,7 +216,7 @@ public class SealedSenderAccessUtil {
accessKey = UNRESTRICTED_KEY;
break;
default:
throw new AssertionError("Unknown mode: " + recipient.getSealedSenderAccessMode().getMode());
throw new AssertionError("Unknown mode: " + accessMode.getMode());
}
if (accessKey == null && isForStory) {
@@ -2088,6 +2088,10 @@ class AttachmentTable(
if (duplicate != null) {
val (duplicateAttachment, dataFileInfo) = duplicate
if (duplicateAttachment.attachmentId == attachmentId) {
return
}
if (duplicateAttachment.remoteLocation != null && duplicateAttachment.remoteDigest != null && dataFileInfo != null) {
Log.w(TAG, "[createRemoteKeyIfNecessary][$attachmentId] Found duplicate with full remote data. Copying all remote data.")
writableDatabase
@@ -288,15 +288,17 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
return emptyList()
}
val inputValues = objects.joinToString(separator = ", ") { "('${it.mediaId}', ${it.cdn})" }
val placeholders = objects.joinToString(separator = ", ") { "(?, ?)" }
val args: Array<Any> = objects.flatMap { listOf(it.mediaId, it.cdn) }.toTypedArray()
return readableDatabase.rawQuery(
"""
WITH input_pairs($MEDIA_ID, $CDN) AS (VALUES $inputValues)
WITH input_pairs($MEDIA_ID, $CDN) AS (VALUES $placeholders)
SELECT a.$PLAINTEXT_HASH, a.$REMOTE_KEY, b.$CDN
FROM $TABLE_NAME a
JOIN input_pairs b ON a.$MEDIA_ID = b.$MEDIA_ID
WHERE a.$CDN != b.$CDN AND a.$IS_THUMBNAIL = 0 AND $SNAPSHOT_VERSION = $MAX_VERSION
"""
""",
*args
).readToList { cursor ->
CdnMismatchResult(
plaintextHash = cursor.requireNonNullBlob(PLAINTEXT_HASH),
@@ -703,7 +703,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
peekGroupCallEraId,
peekJoinedUuids,
isCallFull,
call.event == Event.RINGING
call.event
)
} else {
SignalDatabase.messages.insertGroupCall(
@@ -803,6 +803,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val call = getCallById(callId, groupRecipientId)
if (call == null) {
val direction = if (sender == Recipient.self().id) Direction.OUTGOING else Direction.INCOMING
val isMissedIncoming = direction == Direction.INCOMING && !isGroupCallActive && !didLocalUserJoin
writableDatabase
.insertInto(TABLE_NAME)
@@ -816,7 +817,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
TIMESTAMP to timestamp,
RINGER to null,
LOCAL_JOINED to didLocalUserJoin,
GROUP_CALL_ACTIVE to isGroupCallActive
GROUP_CALL_ACTIVE to isGroupCallActive,
READ to ReadState.serialize(if (isMissedIncoming) ReadState.UNREAD else ReadState.READ)
)
.run(SQLiteDatabase.CONFLICT_ABORT)
@@ -839,6 +841,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
AppDependencies.databaseObserver.notifyCallUpdateObservers()
AppDependencies.databaseObserver.notifyConversationListListeners()
}
/**
@@ -933,7 +936,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
): Boolean {
val localJoined = call.didLocalUserJoin || hasLocalUserJoined
Log.d(TAG, "Updating group call state: localJoined: $localJoined, isGroupCallActive: $isGroupCallActive")
Log.d(TAG, "Updating group call state: localJoined: $localJoined, isGroupCallActive: $isGroupCallActive, call event: ${call.event}")
val changed = writableDatabase.update(TABLE_NAME)
.values(
@@ -957,6 +960,18 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
Log.d(TAG, "[updateGroupCallState] Transitioned group call ${call.callId} from RINGING to ACCEPTED on local join")
}
val unanswered = call.event == Event.GENERIC_GROUP_CALL || call.event == Event.MISSED || call.event == Event.MISSED_NOTIFICATION_PROFILE
if (!isGroupCallActive && !localJoined && unanswered) {
val updated = writableDatabase.update(TABLE_NAME)
.values(
READ to ReadState.serialize(ReadState.UNREAD),
EVENT to Event.serialize(Event.MISSED)
)
.where("$CALL_ID = ?", call.callId)
.run()
Log.d(TAG, "[updateGroupCallState] Marking call as missed: $updated")
}
return changed
}
@@ -1079,6 +1094,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
AppDependencies.databaseObserver.notifyCallUpdateObservers()
AppDependencies.databaseObserver.notifyConversationListListeners()
}
private fun updateEventFromRingState(
@@ -1146,7 +1162,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
TYPE to Type.serialize(Type.GROUP_CALL),
DIRECTION to Direction.serialize(direction),
TIMESTAMP to timestamp,
RINGER to ringerRecipient.toLong()
RINGER to ringerRecipient.toLong(),
READ to ReadState.serialize(if (direction == Direction.INCOMING) ReadState.UNREAD else ReadState.READ)
)
.run(SQLiteDatabase.CONFLICT_ABORT)
}
@@ -7,6 +7,7 @@ import androidx.core.content.contentValuesOf
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.deleteAll
import org.signal.core.util.exists
import org.signal.core.util.forEach
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
@@ -127,6 +128,13 @@ class GroupReceiptTable(context: Context?, databaseHelper: SignalDatabase?) : Da
}
}
fun hasReceipt(mmsId: Long, recipientId: RecipientId): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where("$MMS_ID = ? AND $RECIPIENT_ID = ?", mmsId, recipientId)
.run()
}
fun getGroupReceiptInfo(mmsId: Long): List<GroupReceiptInfo> {
return readableDatabase
.select()
@@ -0,0 +1,248 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import org.signal.core.util.PendingIntentFlags
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.IssueEntry
import org.thoughtcrime.securesms.database.model.IssuePriority
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.ByteArrayOutputStream
import java.io.PrintStream
/**
* Records noteworthy runtime issues to the [LogDatabase] issue table on a low-priority background thread.
*
* Issues are assigned an [IssuePriority]. Issues whose priority is at or above the user's configured notification
* threshold ([SignalStore.internal] `issueNotificationPriority`) additionally raise a user notification.
* Lower-priority issues simply sit in the table to be reviewed later via the internal issues screen or a submitted debug log.
*
* To limit any potential perf overhead for external users, issues are gated to be saved at most once ever [NON_INTERNAL_DEBOUNCE_MS].
*/
object IssueReporter {
const val ISSUE_SLOW_DATABASE_WRITE = "Slow Database Write"
const val ISSUE_SLOW_DATABASE_READ = "Slow Database Read"
const val ISSUE_SLOW_DATABASE_LOCK = "Slow Database Lock"
const val SLOW_WRITE_LOW_PRIORITY_MS = 1_000L
const val SLOW_WRITE_MEDIUM_PRIORITY_MS = 5_000L
const val SLOW_READ_LOW_PRIORITY_MS = 3_000L
const val SLOW_READ_MEDIUM_PRIORITY_MS = 10_000L
const val SLOW_LOCK_LOW_PRIORITY_MS = 1_000L
const val SLOW_LOCK_MEDIUM_PRIORITY_MS = 5_000L
private const val NON_INTERNAL_DEBOUNCE_MS = 5_000L
private val IGNORED_DB_STACK_TRACE_CLASSES = listOf(
"BackupRepository",
"BackupMessagesJob",
"ArchiveAttachmentReconciliationJob",
"SubmitDebugLogRepository"
)
private val requests = IssueRequests()
@Volatile
private var lastInsertTime = 0L
init {
WriteThread(requests).apply {
priority = Thread.MIN_PRIORITY
}.start()
}
/**
* Records a generic issue. Safe to call from any thread.
*/
@JvmStatic
@JvmOverloads
fun report(name: String, description: String, throwable: Throwable? = null, priority: IssuePriority = IssuePriority.LOW, duration: Long? = null) {
val now = System.currentTimeMillis()
if (!RemoteConfig.internalUser) {
if (now - lastInsertTime < NON_INTERNAL_DEBOUNCE_MS) {
return
}
lastInsertTime = now
}
requests.add(IssueRequest(now, BuildConfig.VERSION_NAME, name, description, throwable, priority, duration))
maybeNotify(name, priority)
}
@JvmStatic
fun noteSlowDatabaseWrite(query: String?, durationMs: Long, throwable: Throwable) {
if (isExpectedSlowDatabaseOperation()) {
return
}
val priority = when {
durationMs >= SLOW_WRITE_MEDIUM_PRIORITY_MS -> IssuePriority.MEDIUM
durationMs >= SLOW_WRITE_LOW_PRIORITY_MS -> IssuePriority.LOW
else -> return
}
report(ISSUE_SLOW_DATABASE_WRITE, query?.trim() ?: "", throwable, priority = priority, duration = durationMs)
}
/**
* Notes time spent waiting to acquire the write lock to begin a transaction. This is distinct from a slow write: the
* write itself may be fast, but it was blocked waiting on another holder of the lock (e.g. a long-running transaction).
*/
@JvmStatic
fun noteSlowDatabaseLockAcquire(durationMs: Long, throwable: Throwable) {
if (isExpectedSlowDatabaseOperation()) {
return
}
val priority = when {
durationMs >= SLOW_LOCK_MEDIUM_PRIORITY_MS -> IssuePriority.MEDIUM
durationMs >= SLOW_LOCK_LOW_PRIORITY_MS -> IssuePriority.LOW
else -> return
}
report(ISSUE_SLOW_DATABASE_LOCK, "Long wait to acquire the write lock to BEGIN a transaction.", throwable, priority = priority, duration = durationMs)
}
@JvmStatic
fun noteSlowDatabaseRead(query: String?, durationMs: Long, throwable: Throwable) {
if (isExpectedSlowDatabaseOperation()) {
return
}
val priority = when {
durationMs >= SLOW_READ_MEDIUM_PRIORITY_MS -> IssuePriority.MEDIUM
durationMs >= SLOW_READ_LOW_PRIORITY_MS -> IssuePriority.LOW
else -> return
}
report(ISSUE_SLOW_DATABASE_READ, query?.trim() ?: "", throwable, priority = priority, duration = durationMs)
}
private fun maybeNotify(name: String, priority: IssuePriority) {
if (!RemoteConfig.internalUser) {
return
}
if (priority.value < SignalStore.internal.issueNotificationPriority.value) {
return
}
val context = AppDependencies.application
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification: Notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("[Internal-only] Issue detected")
.setContentText("$name (${priority.label}). Please tap to get a debug log.")
.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, SubmitDebugLogActivity::class.java), PendingIntentFlags.mutable()))
.build()
NotificationManagerCompat.from(context).notify(NotificationIds.INTERNAL_ERROR, notification)
}
private fun isExpectedSlowDatabaseOperation(): Boolean {
return Thread
.currentThread()
.stackTrace
.any { element ->
IGNORED_DB_STACK_TRACE_CLASSES.any {
element.className.contains(it)
}
}
}
private data class IssueRequest(
val createdAt: Long,
val version: String,
val name: String,
val description: String,
val throwable: Throwable?,
val priority: IssuePriority,
val duration: Long?
)
private class WriteThread(
private val requests: IssueRequests
) : Thread("signal-issue-reporter") {
private val db: LogDatabase by lazy { LogDatabase.getInstance(AppDependencies.application) }
override fun run() {
var buffer = mutableListOf<IssueRequest>()
while (true) {
buffer = requests.blockForRequests(buffer)
db.issues.insert(buffer.asSequence().map { requestToEntry(it) }, System.currentTimeMillis())
buffer.clear()
}
}
private fun requestToEntry(request: IssueRequest): IssueEntry {
return IssueEntry(
createdAt = request.createdAt,
version = request.version,
name = request.name,
description = request.description,
stackTrace = request.throwable?.let { stackTraceToString(it) },
priority = request.priority,
duration = request.duration
)
}
private fun stackTraceToString(throwable: Throwable): String {
val outputStream = ByteArrayOutputStream()
throwable.printStackTrace(PrintStream(outputStream))
return String(outputStream.toByteArray())
}
}
private class IssueRequests {
// Mutable because it gets replaced in blockForRequests, to save a copy operation.
var requests = mutableListOf<IssueRequest>()
val lock = Object()
fun add(request: IssueRequest) {
synchronized(lock) {
requests.add(request)
lock.notify()
}
}
/**
* Blocks until requests are available. When they are, returns all pending requests and swaps `swapBuffer` with the
* internal storage for future requests. `swapBuffer` should already be empty upon entry to this method.
*/
fun blockForRequests(swapBuffer: MutableList<IssueRequest>): MutableList<IssueRequest> {
synchronized(lock) {
while (requests.isEmpty()) {
lock.wait()
}
val result = requests
requests = swapBuffer
return result
}
}
}
}
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.annotation.SuppressLint
import android.app.Application
import android.database.Cursor
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.signal.core.util.SqlUtil
@@ -18,7 +19,9 @@ import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
@@ -27,6 +30,8 @@ import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.crash.CrashConfig
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.model.IssueEntry
import org.thoughtcrime.securesms.database.model.IssuePriority
import org.thoughtcrime.securesms.database.model.LogEntry
import java.io.Closeable
import kotlin.math.abs
@@ -60,7 +65,7 @@ class LogDatabase private constructor(
companion object {
private val TAG = Log.tag(LogDatabase::class.java)
private const val DATABASE_VERSION = 4
private const val DATABASE_VERSION = 5
private const val DATABASE_NAME = "signal-logs.db"
@SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
@@ -90,15 +95,20 @@ class LogDatabase private constructor(
@get:JvmName("anrs")
val anrs: AnrTable by lazy { AnrTable(this) }
@get:JvmName("issues")
val issues: IssueTable by lazy { IssueTable({ readableDatabase }, { writableDatabase }) }
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(LogTable.CREATE_TABLE)
db.execSQL(CrashTable.CREATE_TABLE)
db.execSQL(AnrTable.CREATE_TABLE)
db.execSQL(IssueTable.CREATE_TABLE)
LogTable.CREATE_INDEXES.forEach { db.execSQL(it) }
CrashTable.CREATE_INDEXES.forEach { db.execSQL(it) }
IssueTable.CREATE_INDEXES.forEach { db.execSQL(it) }
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -120,6 +130,12 @@ class LogDatabase private constructor(
if (oldVersion < 4) {
db.execSQL("CREATE TABLE anr (_id INTEGER PRIMARY KEY, created_at INTEGER NOT NULL, thread_dump TEXT NOT NULL)")
}
if (oldVersion < 5) {
db.execSQL("CREATE TABLE issue (_id INTEGER PRIMARY KEY, created_at INTEGER NOT NULL, app_version TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, stack_trace TEXT, priority INTEGER NOT NULL, duration INTEGER)")
db.execSQL("CREATE INDEX issue_created_at ON issue (created_at)")
db.execSQL("CREATE INDEX issue_name ON issue (name)")
}
}
override fun onOpen(db: SQLiteDatabase) {
@@ -526,4 +542,155 @@ class LogDatabase private constructor(
val threadDump: String
)
}
class IssueTable(
private val readableDatabaseProvider: () -> SupportSQLiteDatabase,
private val writableDatabaseProvider: () -> SupportSQLiteDatabase
) {
companion object {
const val TABLE_NAME = "issue"
const val ID = "_id"
const val CREATED_AT = "created_at"
const val APP_VERSION = "app_version"
const val NAME = "name"
const val DESCRIPTION = "description"
const val STACK_TRACE = "stack_trace"
const val PRIORITY = "priority"
const val DURATION = "duration"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$CREATED_AT INTEGER NOT NULL,
$APP_VERSION TEXT NOT NULL,
$NAME TEXT NOT NULL,
$DESCRIPTION TEXT NOT NULL,
$STACK_TRACE TEXT,
$PRIORITY INTEGER NOT NULL,
$DURATION INTEGER
)
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX issue_created_at ON $TABLE_NAME ($CREATED_AT)",
"CREATE INDEX issue_name ON $TABLE_NAME ($NAME)"
)
private val MAX_LIFESPAN = 30.days.inWholeMilliseconds
private const val MAX_ROWS = 500
}
private val readableDatabase: SupportSQLiteDatabase get() = readableDatabaseProvider()
private val writableDatabase: SupportSQLiteDatabase get() = writableDatabaseProvider()
fun insert(issues: Sequence<IssueEntry>, currentTime: Long) {
writableDatabase.withinTransaction { db ->
issues.forEach { issue ->
db.insertInto(TABLE_NAME)
.values(
CREATED_AT to issue.createdAt,
APP_VERSION to issue.version,
NAME to issue.name,
DESCRIPTION to issue.description,
STACK_TRACE to issue.stackTrace,
PRIORITY to issue.priority.value,
DURATION to issue.duration
)
.run()
}
trimToSize(db, currentTime)
}
}
fun getRecent(limit: Int = MAX_ROWS): List<IssueRecord> {
return readableDatabase
.select()
.from(TABLE_NAME)
.orderBy("$CREATED_AT DESC")
.limit(limit)
.run()
.readToList { cursor ->
IssueRecord(
id = cursor.requireLong(ID),
createdAt = cursor.requireLong(CREATED_AT),
version = cursor.requireNonNullString(APP_VERSION),
name = cursor.requireNonNullString(NAME),
description = cursor.requireNonNullString(DESCRIPTION),
stackTrace = cursor.requireString(STACK_TRACE),
priority = IssuePriority.fromValue(cursor.requireInt(PRIORITY)),
duration = cursor.requireLongOrNull(DURATION)
)
}
}
fun getSummary(): List<IssueSummary> {
return readableDatabase
.select(NAME, "COUNT(*) AS count", "MAX($PRIORITY) AS max_priority", "MIN($CREATED_AT) AS first_seen", "MAX($CREATED_AT) AS last_seen", "CAST(AVG($DURATION) AS INTEGER) AS avg_duration")
.from(TABLE_NAME)
.where("1 = 1")
.groupBy(NAME)
.run()
.readToList { cursor ->
val name = cursor.requireNonNullString(NAME)
IssueSummary(
name = name,
count = cursor.requireInt("count"),
maxPriority = IssuePriority.fromValue(cursor.requireInt("max_priority")),
firstSeen = cursor.requireLong("first_seen"),
lastSeen = cursor.requireLong("last_seen"),
lastVersion = getLatestVersion(name),
averageDuration = cursor.requireLongOrNull("avg_duration")
)
}
.sortedByDescending { it.count }
}
fun clear() {
writableDatabase.deleteAll(TABLE_NAME)
}
private fun getLatestVersion(name: String): String {
return readableDatabase
.select(APP_VERSION)
.from(TABLE_NAME)
.where("$NAME = ?", name)
.orderBy("$CREATED_AT DESC")
.limit(1)
.run()
.readToList { it.requireNonNullString(APP_VERSION) }
.firstOrNull() ?: ""
}
private fun trimToSize(db: SupportSQLiteDatabase, currentTime: Long) {
db.delete(TABLE_NAME)
.where("$CREATED_AT < ${currentTime - MAX_LIFESPAN}")
.run()
db.delete(TABLE_NAME)
.where("$ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT $MAX_ROWS)")
.run()
}
data class IssueRecord(
val id: Long,
val createdAt: Long,
val version: String,
val name: String,
val description: String,
val stackTrace: String?,
val priority: IssuePriority,
val duration: Long?
)
data class IssueSummary(
val name: String,
val count: Int,
val maxPriority: IssuePriority,
val firstSeen: Long,
val lastSeen: Long,
val lastVersion: String,
val averageDuration: Long?
)
}
}
@@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment.DisplayOrderCom
import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.database.CallTable.Event
import org.thoughtcrime.securesms.database.EarlyDeliveryReceiptCache.Receipt
import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
@@ -320,6 +321,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
private const val INDEX_STARRED = "message_starred_index"
private const val INDEX_NOTIFICATION_STATE = "message_notification_state_index"
private const val INDEX_RATE_LIMITED = "message_rate_limited_index"
private const val INDEX_SCHEDULED_NON_STORY = "message_scheduled_non_story_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -356,7 +358,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS $INDEX_NOTIFICATION_STATE ON $TABLE_NAME ($DATE_RECEIVED) WHERE $NOTIFIED = 0 AND $STORY_TYPE = 0 AND $LATEST_REVISION_ID IS NULL",
"CREATE INDEX IF NOT EXISTS message_expire_started_index ON $TABLE_NAME ($EXPIRE_STARTED) WHERE $EXPIRE_STARTED > 0",
"CREATE INDEX IF NOT EXISTS message_view_once_index ON $TABLE_NAME ($VIEW_ONCE) WHERE $VIEW_ONCE > 0",
"CREATE INDEX IF NOT EXISTS $INDEX_RATE_LIMITED ON $TABLE_NAME ($ID) WHERE ($TYPE & ${MessageTypes.MESSAGE_RATE_LIMITED_BIT}) != 0"
"CREATE INDEX IF NOT EXISTS $INDEX_RATE_LIMITED ON $TABLE_NAME ($ID) WHERE ($TYPE & ${MessageTypes.MESSAGE_RATE_LIMITED_BIT}) != 0",
"CREATE INDEX IF NOT EXISTS $INDEX_SCHEDULED_NON_STORY ON $TABLE_NAME ($SCHEDULED_DATE) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE != -1"
)
private val MMS_PROJECTION_BASE = arrayOf(
@@ -983,6 +986,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
): MessageId {
val recipient = Recipient.resolved(groupRecipientId)
val threadId = threads.getOrCreateThreadIdFor(recipient)
val expiresIn = if (RemoteConfig.disappearMore) recipient.expiresInSeconds.seconds.inWholeMilliseconds else 0
val messageId: MessageId = writableDatabase.withinTransaction { db ->
val self = Recipient.self()
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || self.id == sender
@@ -1002,11 +1006,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
TO_RECIPIENT_ID to groupRecipientId.serialize(),
DATE_RECEIVED to timestamp,
DATE_SENT to timestamp,
READ to if (markRead) 1 else 0,
NOTIFIED to if (markRead) 1 else 0,
READ to 1,
NOTIFIED to 1,
BODY to Base64.encodeWithPadding(updateDetails),
TYPE to MessageTypes.GROUP_CALL_TYPE,
THREAD_ID to threadId
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
)
val messageId = MessageId(db.insert(TABLE_NAME, null, values))
@@ -1014,9 +1019,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val isActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice
if (!isActiveCall) {
maybeCollapseMessage(db = db, messageId = messageId.id, threadId = threadId, dateReceived = timestamp, messageExtras = null, messageType = MessageTypes.GROUP_CALL_TYPE)
if (markRead && expiresIn != 0L) {
Log.d(TAG, "[insertGroupCall] Starting expiration timer for group call.")
val now = System.currentTimeMillis()
markExpireStarted(messageId.id, now)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId.id, true, now, expiresIn)
}
}
threads.incrementUnread(threadId, 1, 0)
threads.update(threadId, true)
messageId
@@ -1092,8 +1102,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
eraId: String,
joinedUuids: Collection<UUID>,
isCallFull: Boolean,
isRingingOnLocalDevice: Boolean
event: Event
): MessageId {
val isRingingOnLocalDevice = event == Event.RINGING
writableDatabase.withinTransaction { db ->
val message = try {
getMessageRecord(messageId = messageId)
@@ -1111,7 +1122,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
BODY to body
)
if (sameEraId && (containsSelf || updateDetail.localUserJoined)) {
val localJoined = sameEraId && (containsSelf || updateDetail.localUserJoined)
if (localJoined) {
contentValues.put(READ, 1)
contentValues.put(NOTIFIED, 1)
}
@@ -1119,8 +1131,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(messageId), contentValues)
val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0
if (inCallUuids.isEmpty() && message.collapsedState == CollapsedState.NONE) {
maybeCollapseMessage(db = db, messageId = messageId, threadId = message.threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = message.type)
if (inCallUuids.isEmpty()) {
val acknowledgedCall = localJoined || event == Event.DECLINED
finalizeEndedGroupCallMessage(db, message, acknowledgedCall, logPrefix = "[updateGroupCall]")
}
if (updated) {
@@ -1172,8 +1185,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(record.id), contentValues)
val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0
if (inCallUuids.isEmpty() && record.collapsedState == CollapsedState.NONE) {
maybeCollapseMessage(db = db, messageId = record.id, threadId = record.threadId, dateReceived = record.dateReceived, messageExtras = record.messageExtras, messageType = record.type)
if (inCallUuids.isEmpty()) {
val acknowledgedCall = sameEraId && (containsSelf || groupCallUpdateDetails.localUserJoined)
finalizeEndedGroupCallMessage(db, record, acknowledgedCall, logPrefix = "[updatePreviousGroupCall]")
}
if (updated) {
@@ -1183,6 +1197,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun finalizeEndedGroupCallMessage(db: SQLiteDatabase, message: MessageRecord, acknowledgedCall: Boolean, logPrefix: String) {
if (message.collapsedState == CollapsedState.NONE) {
maybeCollapseMessage(db = db, messageId = message.id, threadId = message.threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = message.type)
}
val unstartedExpiration = message.expireStarted == 0L && message.expiresIn != 0L
if (unstartedExpiration && acknowledgedCall) {
Log.d(TAG, "$logPrefix Starting expiration after call has ended.")
val now = System.currentTimeMillis()
markExpireStarted(message.id, now)
AppDependencies.expiringMessageManager.scheduleDeletion(message.id, message.isMms, now, message.expiresIn)
}
}
fun insertEditMessageInbox(mediaMessage: IncomingMessage, targetMessage: MmsMessageRecord): Optional<InsertResult> {
val insertResult = insertMessageInbox(retrieved = mediaMessage, editedMessage = targetMessage, notifyObservers = false)
@@ -2670,6 +2698,30 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
/**
* The oldest unread message as displayed in the thread (latest revision, not collapsed, not pinned), or null if there
* are none. Anchors the unread divider ([OldestUnread.id]) and its scroll position ([OldestUnread.dateReceived]); this
* is a separate query from the unread count and is not expected to select an identical row set.
*/
fun getOldestUnread(threadId: Long): OldestUnread? {
val pinnedMessageClause = "($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE}"
// The redundant "($READ = 0 OR $REACTIONS_UNREAD = 1 OR $VOTES_UNREAD = 1)" term lets the planner use the partial
// index to satisfy ORDER BY $DATE_RECEIVED without a sort (same trick as setMessagesReadSince).
return readableDatabase
.select(ID, DATE_RECEIVED)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE_RECEIVED_UNREAD")
.where("$THREAD_ID = ? AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR $REACTIONS_UNREAD = 1 OR $VOTES_UNREAD = 1) AND $READ = 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id} AND $pinnedMessageClause", threadId)
.orderBy("$DATE_RECEIVED ASC")
.limit(1)
.run()
.readToSingleObject { cursor ->
OldestUnread(
id = cursor.requireLong(ID),
dateReceived = cursor.requireLong(DATE_RECEIVED)
)
}
}
fun getUnreadMentionCount(threadId: Long): Int {
return readableDatabase
.count()
@@ -5488,6 +5540,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return emptySet()
}
// Only allow updating receipts for a group message if the sender is actually a member (stories are excluded because a single story timestamp can map to multiple messages)
if (!receiptData.forIndividualChat && receiptData.storyType == StoryType.NONE && !groupReceipts.hasReceipt(receiptData.messageId, receiptAuthor)) {
return emptySet()
}
if (!receiptData.marked) {
// We set the receipt_timestamp to the max of the two values because that single column represents the timestamp of the last receipt of any type.
// That means we want to update it for each new receipt type, but we never want the time to go backwards.
@@ -5698,8 +5755,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getScheduledMessagesBefore(time: Long): List<MessageRecord> {
val cursor = readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ? AND $SCHEDULED_DATE <= ?", 0, 0, -1, time)
.from("$TABLE_NAME INDEXED BY $INDEX_SCHEDULED_NON_STORY")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE != -1 AND $SCHEDULED_DATE <= ?", time)
.orderBy("$SCHEDULED_DATE ASC, $ID ASC")
.run()
@@ -5711,8 +5768,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getOldestScheduledSendTimestamp(): MessageRecord? {
val cursor = readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", 0, 0, -1)
.from("$TABLE_NAME INDEXED BY $INDEX_SCHEDULED_NON_STORY")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE != -1")
.orderBy("$SCHEDULED_DATE ASC, $ID ASC")
.limit(1)
.run()
@@ -6557,6 +6614,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threadId: Long
)
data class OldestUnread(
val id: Long,
val dateReceived: Long
)
data class Duplicate(
val id: Long,
val dateSent: Long,
@@ -2128,6 +2128,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
fun isProfileSharing(groupId: GroupId): Boolean {
return readableDatabase
.select(PROFILE_SHARING)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString())
.run()
.readToSingleBoolean(defaultValue = false)
}
fun setNotificationChannel(id: RecipientId, notificationChannel: String?) {
val contentValues = ContentValues(1).apply {
put(NOTIFICATION_CHANNEL, notificationChannel)
@@ -4078,7 +4087,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
/**
* Does not trigger any recipient refreshes -- it is assumed the caller handles this.
* Will *not* give storageIds to those that shouldn't get them (e.g. MMS groups, unregistered
* users).
* users) but will rotate ids if one already exists regardless of state.
*/
fun rotateStorageId(recipientId: RecipientId, logFailure: Boolean = false) {
val selfId = Recipient.self().id
@@ -4092,7 +4101,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
}
val query = "$ID = ? AND ($TYPE IN (?, ?, ?, ?) OR $REGISTERED = ? OR $ID = ?)"
val query = "$ID = ? AND ($TYPE IN (?, ?, ?, ?) OR $REGISTERED = ? OR $ID = ? OR $STORAGE_SERVICE_ID IS NOT NULL)"
val args = SqlUtil.buildArgs(recipientId, RecipientType.GV1.id, RecipientType.GV2.id, RecipientType.DISTRIBUTION_LIST.id, RecipientType.CALL_LINK.id, RegisteredState.REGISTERED.id, selfId.toLong())
writableDatabase.update(TABLE_NAME, values, query, args).also { updateCount ->
@@ -4,6 +4,7 @@ import android.content.Context
import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.signal.core.util.delete
import org.signal.core.util.exists
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
@@ -78,9 +79,20 @@ class RemappedRecordTables internal constructor(context: Context?, databaseHelpe
}
fun trimStaleMappings() {
val hasStaleRecipients = hasInvalidEntries(Recipients.TABLE_NAME, RecipientTable.TABLE_NAME)
val hasStaleThreads = hasInvalidEntries(Threads.TABLE_NAME, ThreadTable.TABLE_NAME)
if (!hasStaleRecipients && !hasStaleThreads) {
return
}
writableDatabase.withinTransaction { db ->
trimInvalidRecipientEntries(db)
trimInvalidThreadEntries(db)
if (hasStaleRecipients) {
trimInvalidRecipientEntries(db)
}
if (hasStaleThreads) {
trimInvalidThreadEntries(db)
}
}
}
@@ -116,6 +128,13 @@ class RemappedRecordTables internal constructor(context: Context?, databaseHelpe
RemappedRecords.getInstance().resetCache()
}
private fun hasInvalidEntries(table: String, sourceTable: String): Boolean {
return readableDatabase
.exists(table)
.where("$OLD_ID IN (SELECT $ID FROM $sourceTable)")
.run()
}
private fun trimInvalidRecipientEntries(db: SQLiteDatabase) {
val count = db.delete(Recipients.TABLE_NAME)
.where("$OLD_ID IN (SELECT $ID FROM ${RecipientTable.TABLE_NAME})")

Some files were not shown because too many files have changed in this diff Show More