mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-11 17:56:02 +01:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e92fca26d | |||
| 526928ce0a | |||
| 2ea2f561ae | |||
| c88f048049 | |||
| 204a233235 | |||
| 2e4abd8ed3 | |||
| a2a0b11c98 | |||
| 96f893652b | |||
| d4924d2a13 | |||
| fa6b512cfc | |||
| d447af36ba | |||
| 41260f37c9 | |||
| 8950f7f7f9 | |||
| 14916d068f | |||
| 33022baaa2 | |||
| 4cdd1f70ac | |||
| 9e3ee16e65 | |||
| bf912e14d9 | |||
| 56f1a9e0ec | |||
| e468156c4c | |||
| 029b91066f | |||
| 5909a1b92a | |||
| 9478cdf049 | |||
| 6d260ab63d | |||
| 4bc11fcf0d | |||
| 33619fe463 | |||
| 2b17868797 | |||
| 582a464a52 | |||
| 812a858761 | |||
| 6ea96795cb | |||
| 70ab0baa3c | |||
| 5041057bab | |||
| 6210d1b397 | |||
| f1accae295 | |||
| 5f6d20453c | |||
| d33385d1b2 | |||
| 3d05bc3471 | |||
| 0514a5c6c8 | |||
| 798ba11e62 | |||
| 848a61787b | |||
| ccfbb27695 | |||
| 26b1d3a0f8 | |||
| c5785c086e | |||
| 6aeb145024 | |||
| 7e8c6228d8 | |||
| 4c08b94b88 | |||
| 135bc6e560 | |||
| 0ebeb5aa92 | |||
| aaa7a18190 | |||
| bfc1c4ebfa | |||
| b4cf59f9c2 | |||
| 15e7b30fa1 | |||
| a5359e05a3 | |||
| 73557ae72a | |||
| 2505049e39 | |||
| a4ae6581ef | |||
| 6399a2d899 | |||
| 56af57db9e | |||
| ddf0de52b1 | |||
| 925e2c1705 | |||
| cb719dff1a | |||
| f2fd3e63c8 | |||
| 8560ab0515 | |||
| d586eff80b | |||
| 27ddd62d7a | |||
| 38f31528ff | |||
| 335fcd72f3 | |||
| 118231a328 | |||
| 754dd15c94 | |||
| 566c2d5838 | |||
| eae894152c | |||
| 53c4069c64 | |||
| 65082893db | |||
| 595364b522 | |||
| 7cce504f16 | |||
| 337afb11db | |||
| 6027d58fb5 | |||
| 132eaa5c70 | |||
| ba3e15ea6d | |||
| ceee5f714d | |||
| 4c942f39b0 | |||
| f3a5bba3f2 | |||
| 8af7606e3f | |||
| 2adf84a895 | |||
| 30ed0aa11a | |||
| ec9ae9e3b1 | |||
| 6a23896077 | |||
| f5a1d79eb5 | |||
| 4f0f0938d8 | |||
| 0136971963 | |||
| f810d731dd | |||
| 7c7c364fef | |||
| aa9591211b | |||
| bbd48547e5 | |||
| 757b521744 | |||
| a6311c87c1 | |||
| 045bd9287b | |||
| f1a72dd01a | |||
| af4d0a0ef0 | |||
| 7dcaa933f2 | |||
| 2c88945e6b | |||
| f9b9ce6c14 | |||
| 1d8fbad17e |
@@ -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() }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+393
@@ -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)
|
||||
}
|
||||
}
|
||||
-174
@@ -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
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-120
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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()
|
||||
)
|
||||
|
||||
+8
-20
@@ -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())
|
||||
|
||||
+46
-1
@@ -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)
|
||||
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
+14
@@ -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
-2
@@ -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),
|
||||
|
||||
+1
-3
@@ -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
|
||||
}
|
||||
|
||||
+7
-8
@@ -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 ||
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+1
-2
@@ -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();
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+53
-46
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+34
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
+344
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+88
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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
|
||||
|
||||
|
||||
+5
-1
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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) ->
|
||||
|
||||
+17
-13
@@ -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()
|
||||
|
||||
|
||||
+14
-4
@@ -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 ->
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+70
-643
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+327
@@ -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
|
||||
}
|
||||
+415
@@ -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()
|
||||
}
|
||||
}
|
||||
+141
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
-200
@@ -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
|
||||
}
|
||||
}
|
||||
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -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) {
|
||||
|
||||
+22
-22
@@ -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()
|
||||
|
||||
+14
@@ -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);
|
||||
|
||||
+1
-2
@@ -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);
|
||||
|
||||
+29
-15
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
-4
@@ -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 ?: "")
|
||||
}
|
||||
|
||||
+13
-12
@@ -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
|
||||
}
|
||||
|
||||
+11
-8
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
-9
@@ -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)
|
||||
}
|
||||
|
||||
+303
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
-110
@@ -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)
|
||||
}
|
||||
|
||||
+5
@@ -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 {
|
||||
|
||||
+13
-6
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+178
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-32
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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()
|
||||
}
|
||||
|
||||
+39
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-50
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
Reference in New Issue
Block a user