Compare commits

..

82 Commits

Author SHA1 Message Date
jeffrey-signal 955b8c382e Bump version to 8.11.5 2026-05-21 17:00:08 -04:00
Cody Henthorne 6f7dce47db Fix chat bubbles not rendering due to ConstraintLayout bug. 2026-05-21 13:27:33 -04:00
jeffrey-signal 93077ac457 Bump version to 8.11.4 2026-05-21 10:24:18 -04:00
jeffrey-signal c069eb1b88 Update baseline profile. 2026-05-21 10:08:50 -04:00
jeffrey-signal e5cd18bf1e Update translations and other static files. 2026-05-21 10:02:16 -04:00
Alex Hart 9e8ae7e26a Only update desktop activity timestamp for user-initiated sync messages.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-20 12:42:52 -03:00
Cody Henthorne 00042b9579 Stop screen sharing when disabled from system UI. 2026-05-20 10:06:18 -04:00
Greyson Parrelli e750b81a31 Disable dnsjava hosts file parsing to fix NPE race condition. 2026-05-20 10:05:39 -04:00
Greyson Parrelli daec317f52 Don't auto-snooze donation megaphones. 2026-05-20 10:02:35 -04:00
Greyson Parrelli 112514c221 Remove persistent play services error notification.
Fixes #14786
2026-05-19 18:05:05 -04:00
Cody Henthorne f43db8ace0 Fix chat bubbles not rendering due to ConstraintLayout bug.
Resolves signalapp/Signal-Android#14774
2026-05-19 18:05:04 -04:00
jeffrey-signal 54df95727b Bump version to 8.11.3 2026-05-19 12:12:47 -04:00
jeffrey-signal 022b4d9508 Update baseline profile. 2026-05-19 11:58:37 -04:00
jeffrey-signal 7411e725ec Update translations and other static files. 2026-05-19 11:51:57 -04:00
Alex Hart 83a279f422 Fix display bug with donation type. 2026-05-19 11:42:12 -04:00
Michelle Tang 523066d093 Turn off key transparency. 2026-05-19 10:39:06 -04:00
Michelle Tang de27343c24 Update key transparency api. 2026-05-19 10:38:08 -04:00
Michelle Tang c36179293e Fix missing safety number dialog. 2026-05-18 14:13:59 -04:00
andrew-signal a79a91bafb Bump to libsignal v0.94.1 2026-05-18 14:09:27 -04:00
Michelle Tang 13de1ede90 Bump version to 8.11.2 2026-05-15 16:32:54 -04:00
Michelle Tang b94f420393 Update translations and other static files. 2026-05-15 16:29:45 -04:00
Michelle Tang 4909f130cc Animate safety number once. 2026-05-15 16:21:45 -04:00
Michelle Tang 0010386b9e Bump version to 8.11.1 2026-05-14 15:20:56 -04:00
Michelle Tang 02c760945d Update translations and other static files. 2026-05-14 14:58:23 -04:00
Greyson Parrelli a0247bb8cc Add R8 keep rule for org.signal.network to fix Jackson deserialization. 2026-05-14 14:43:36 -04:00
Greyson Parrelli bcfec5de50 Fix benchmark build. 2026-05-14 14:36:10 -04:00
Cody Henthorne b2215915ef Switch to speaker and disable proximity when screen sharing. 2026-05-14 14:36:10 -04:00
Michelle Tang a0577cd8a2 Bump version to 8.11.0 2026-05-14 13:41:16 -04:00
Michelle Tang 9438646814 Update translations and other static files. 2026-05-14 13:28:56 -04:00
Cody Henthorne 9dcf68581d Improve screen share capture dimension calculation and use remote config. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 4dd57460de Move a bunch of files into the network modules. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 6339b38dee Move RemoteConfigResponse. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 1ce41edc7f Move CertificateApi and RateLimitChallengeApi deps. 2026-05-14 13:23:16 -04:00
Michelle Tang 3087116618 Move hardcoded reg strings to strings.xml. 2026-05-14 13:23:16 -04:00
Michelle Tang 8fd2065253 Revert "Use adaptive bitmap for dynamic shortcut icons to remove white border."
This reverts commit b98452f7b3c99bdf5d4e02ba5129a63846ed563a.
2026-05-14 13:23:16 -04:00
Greyson Parrelli 09822d3ae9 More changes to fix reproducible build issues. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 10b0221e98 Add support for a better AEP character set. 2026-05-14 13:23:16 -04:00
Cody Henthorne db4def45f9 Reduce blocking calls in happy network call path. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 7dd6829bfa Automatically snooze megaphones after 3 days. 2026-05-14 13:23:16 -04:00
jeffrey-signal 0e40acfdaa Fix permissions screen button background elevation for medium/large layout. 2026-05-14 13:23:16 -04:00
Greyson Parrelli c2e8cec042 Upgrade to SQLCipher 4.16.0 2026-05-14 13:23:16 -04:00
Cody Henthorne 04d2b3b0fe Improve prekey fetch performance. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 64cdff4638 Remove support for END_SESSION flag. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 59b42ac546 Prompt permission for scheduled sends in media flow. 2026-05-14 13:23:16 -04:00
Cody Henthorne 2e9fd87b06 Reduce thrashing on multiple identity change events. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 0cf7705d4f Convert megaphone database and repository to kotlin. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5bcbbdf339 React to availability of Play Services at app start. 2026-05-14 13:23:16 -04:00
jeffrey-signal a796316ad6 Add click handling for regV5 welcome screen terms button. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5655fcf973 Create new linkifier.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-05-12 10:05:38 -04:00
Alex Hart f4bd5fbe8b Add log line for ignored payment transcript. 2026-05-12 10:53:43 -03:00
Alex Hart fc448ecb59 Fix call screen crash when participant count drops during speaker view. 2026-05-12 10:47:28 -03:00
Michelle Tang c4e7841ea3 Fix missing chevron. 2026-05-12 09:46:19 -04:00
Cody Henthorne e248aee25c Improve quote query performance for conversation open. 2026-05-11 16:32:26 -04:00
Michelle Tang 7c9268e326 Turn on key transparency. 2026-05-11 16:32:26 -04:00
Alex Hart 8ffc2e7ab8 Handle SUBSCRIPTION_STATUS FetchLatest sync message. 2026-05-11 16:32:26 -04:00
andrew-signal b4404bb5b4 Bump to libsignal v0.94.0 2026-05-11 16:32:26 -04:00
Michelle Tang 155bba2f81 Expand touch area for key transparency bottom sheet. 2026-05-11 16:32:26 -04:00
adel-signal 639438b863 Update RingRTC version to v2.69.0 2026-05-11 16:32:26 -04:00
Alex Hart b374a90ffe Allow donations on linked devices. 2026-05-11 16:32:26 -04:00
Cody Henthorne d333503838 Migrate to new staging SVR2 enclave. 2026-05-11 16:32:26 -04:00
Alex Hart 2efc115410 Add intrumented testing and a small fix for nav from shortcuts. 2026-05-11 16:32:26 -04:00
Cody Henthorne 43a1c93961 Make network calling infrastructure more coroutine friendly. 2026-05-11 16:32:26 -04:00
Cody Henthorne 39529af4e9 Add screen share to 1:1 and group calling. 2026-05-11 16:32:24 -04:00
Cody Henthorne d1e2fc0423 Use trimmed video start for thumbnail generation. 2026-05-11 16:31:32 -04:00
Alex Hart 5e4865be73 Add a sync after rotating a subscriber id. 2026-05-11 16:31:32 -04:00
Cody Henthorne 5903c1bbf5 Update reproducible build dependencies. 2026-05-11 16:31:32 -04:00
Alex Hart 45da9fbfc0 Skip slow deviceheuristics for linked devices. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 0dacc4e8dc Ensure we use the correct index for starred messages. 2026-05-11 16:31:32 -04:00
Cody Henthorne 74935c963a Refresh thread snippet after view-once attachment is viewed.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-11 16:31:32 -04:00
Greyson Parrelli 02d245ac0c Manually draw location on google map. 2026-05-11 16:31:32 -04:00
Cody Henthorne e100ffbc14 Fix crash during group call peek with unknown members. 2026-05-11 16:31:32 -04:00
Cody Henthorne d4b3328151 Fix IllegalArgumentException crash when dismissing progress dialog. 2026-05-11 16:31:32 -04:00
Cody Henthorne 0e82a43be7 Cycle use REST fallback remote config to try without it again. 2026-05-11 16:31:32 -04:00
Alex Hart 0960c0dfea Skip processing backfill requests on linked devices. 2026-05-11 16:31:32 -04:00
jeffrey-signal 8503c49db0 Prevent input panel quote author name from overlapping the dismiss button. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 88b95ce6a5 Ignore lint error in keyboard layout. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 6a1d06486c Treat unparseable shortcut recipient ids as invalid shortcuts. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 824f0af00b Reject unknown share-target recipient ids. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 03a6d8c12f Fix crash when handling malformed URI's. 2026-05-11 16:31:32 -04:00
Alex Hart f1b231ca38 Wire in inactive-primary websocket alert. 2026-05-11 16:31:31 -04:00
Greyson Parrelli dca4351b8b Update some gradle properties that are giving warnings. 2026-05-11 16:31:31 -04:00
andrew-signal b2a18f7202 Make chat list tap state slightly darker. 2026-05-11 16:31:31 -04:00
608 changed files with 11395 additions and 4116 deletions
+6 -5
View File
@@ -27,9 +27,9 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1687
val canonicalVersionName = "8.10.2"
val currentHotfixVersion = 0
val canonicalVersionCode = 1692
val canonicalVersionName = "8.11.5"
val currentHotfixVersion = 1
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
@@ -463,8 +463,8 @@ android {
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
@@ -686,6 +686,7 @@ dependencies {
implementation(libs.google.play.services.maps)
implementation(libs.google.play.services.auth)
implementation(libs.google.signin)
implementation(libs.androidx.media)
implementation(libs.bundles.media3)
implementation(libs.conscrypt.android)
implementation(libs.signal.aesgcmprovider)
+11
View File
@@ -513,6 +513,17 @@
column="31"/>
</issue>
<issue
id="SoonBlockedPrivateApi"
message="Reflective access to mAttachInfo will throw an exception when targeting API 35 and above"
errorLine1=" Field attachInfoField = View.class.getDeclaredField(&quot;mAttachInfo&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java"
line="157"
column="31"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+9
View File
@@ -7,6 +7,7 @@
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keep class org.signal.network.** { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}
@@ -16,6 +17,14 @@
-keep class androidx.window.** { *; }
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
# the Signature attribute on this Kotlin lambda subclass of the generic
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
# keeping the class stabilizes R8's attribute decisions.
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
*;
}
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
public <init>();
}
@@ -16,6 +16,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -19,6 +19,8 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -27,8 +29,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
class BackupDeleteJobTest {
@@ -27,6 +27,8 @@ import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -0,0 +1,843 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
* through [MainNavigationViewModel].
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
private val context: Context get() = harness.context
private val recipient: RecipientId get() = harness.others.first()
/**
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
* fragment and dropping share data.
*/
@Test
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
try {
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
} catch (e: IllegalStateException) {
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val state = runOnMainSync {
buildString {
appendLine("--- diagnostic dump ---")
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
// duplicate second nav before we count fragments.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.shareDataTimestamp == timestamp) {
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
}
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == blob) {
"Expected draftMedia=$blob, got ${args.draftMedia}"
}
}
}
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
val media = realJpegMedia()
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
}
// Exactly one ConversationFragment should have been created — the share dispatch
// happens from inside it, then it stays put while the media editor sits on top.
check(launched.recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
}
}
}
/**
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
* top — *and* that the draft text actually shows up in the composer the user sees.
*/
@Test
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
val draftText = "hello from share"
val intent = shareTextIntent(recipient = recipient, text = draftText)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
awaitComposerText(launched, draftText)
// Give a beat for any spurious second navigation to surface.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(launched.nonMainActivities().isEmpty()) {
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
}
}
}
@Test
fun coldLaunch_notificationIntent_opensConversation() {
val intent = notificationToConversationIntent(recipient)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.threadId > 0) {
"Expected threadId > 0, got ${args.threadId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(args.shareDataTimestamp == -1L) {
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun coldLaunch_tabIntent_setsListLocation() {
val intent = tabIntent(MainNavigationListLocation.CALLS)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
}
}
}
/**
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
* test should fail and force a deliberate decision.
*/
@Test
fun coldLaunch_detailLocationIntent_isNoOpToday() {
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
launchSync(intent).use { launched ->
val recorder = launched.recorder
Thread.sleep(1500)
check(recorder.createdArgs.isEmpty()) {
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
}
}
}
@Test
fun coldLaunch_deepLinkIntent_reachesChatsList() {
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun coldLaunch_noExtras_defaultsToChats() {
val intent = Intent(context, MainActivity::class.java)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
val recorder = launched.recorder
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
Thread.sleep(1000)
val baseline = recorder.createdArgs.size
val warmIntent = notificationToConversationIntent(recipient)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
recorder.createdArgs.size > baseline
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val newArgs = recorder.createdArgs.drop(baseline)
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
check(newArgs.single().recipientId == recipient) {
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
}
}
}
/**
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
* on phones. No new ConversationFragment should be added.
*/
@Test
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val baseline = recorder.createdArgs.size
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun warmStart_onNewIntent_tabIntent_switchesList() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midConversation_restoresState() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initial = recorder.createdArgs.first()
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size >= 2
}
// Verify the user-visible title rebinds after recreate, not just the args.
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs[1]
check(recreated.recipientId == initial.recipientId) {
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
}
check(recreated.threadId == initial.threadId) {
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
}
}
}
@Test
fun recreate_midTab_restoresTab() {
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CALLS)
runOnMainSync { launched.activity.recreate() }
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
// recorder removes destroyed fragments, so this only passes once the post-recreate
// CallLogFragment instance is attached.
awaitListFragment(launched, MainNavigationListLocation.CALLS)
// launched.activity returns the *latest* MainActivity (the holder updates in
// onActivityCreated), so this reads the post-recreate VM instance.
val location = runOnMainSync {
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
}
check(location == MainNavigationListLocation.CALLS) {
"Expected VM CALLS post-recreate, got $location"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midShareConversation_preservesShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initialCount = recorder.createdArgs.size
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size > initialCount
}
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs.last()
check(recreated.shareDataTimestamp == timestamp) {
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
}
check(recreated.draftMedia == blob) {
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
}
}
}
// region Helpers
/**
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
*/
private fun shareToConversationIntent(
recipient: RecipientId,
blob: Uri,
mimeType: String,
draftText: String? = null,
shareDataTimestamp: Long = System.currentTimeMillis()
): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(blob)
.withDataType(mimeType)
.withMedia(emptyList())
.withDraftText(draftText)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(shareDataTimestamp)
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
* which is what triggers the hop to the media-send screen.
*/
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(media.uri)
.withDataType(media.contentType)
.withMedia(listOf(media))
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
* `ShareOrDraftData.SetText`.
*/
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withMedia(emptyList())
.withDraftText(text)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
.blockingGet()
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun tabIntent(tab: MainNavigationListLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("STARTING_TAB", tab)
}
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("DETAIL_LOCATION", location)
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return BlobProvider.getInstance()
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
stream.toByteArray()
}
bitmap.recycle()
val uri = realBlob(bytes, "image/jpeg")
return Media(
uri = uri,
contentType = "image/jpeg",
date = 0L,
width = 1,
height = 1,
size = bytes.size.toLong(),
duration = 0L,
isBorderless = false,
isVideoGif = false,
bucketId = null,
caption = null,
transformProperties = null,
fileName = null
)
}
/**
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
* plus a [Uri] in the data field.
*/
private fun deepLinkIntent(data: Uri): Intent {
return Intent(context, MainActivity::class.java).setData(data)
}
/**
* Synchronously launch [MainActivity] and return the running instance plus a fragment
* recorder wired up *before* the activity is created.
*
* We bypass [androidx.test.core.app.ActivityScenario] and
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
* idle which never arrives while MainActivity's composition + ConversationFragment
* setup keeps the looper busy.
*/
private fun launchSync(intent: Intent): LaunchedActivity {
val recorder = ConversationFragmentRecorder()
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val activityHolder = arrayOfNulls<MainActivity>(1)
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities += activity
if (activity is MainActivity) {
activityHolder[0] = activity
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) resumed.countDown()
}
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(callbacks)
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
try {
app.startActivity(launchIntent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(callbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result.getOrThrow()
}
private fun await(
timeoutMs: Long = 5_000,
pollMs: Long = 50,
description: String = "condition",
predicate: () -> Boolean
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (runOnMainSync(predicate)) return
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
}
/**
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
* text won't be present at fragment-create time — we have to poll the rendered view.
*/
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
}
}
/**
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
*/
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
}
}
/**
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
* signal — strictly stronger than reading the VM's `currentListLocation`.
*/
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
launched.recorder.isAttached(expected)
}
} catch (e: IllegalStateException) {
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
}
}
// endregion
// region Types
/**
* Records every [ConversationFragment] added under an activity's fragment manager,
* capturing each fragment's arguments at create-time.
*/
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
val allCreated: MutableList<String> = mutableListOf()
private val active: MutableList<ConversationFragment> = mutableListOf()
private val attached: MutableList<Fragment> = mutableListOf()
var destroyedCount: Int = 0
private set
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
fun latestActive(): ConversationFragment? = active.lastOrNull()
/**
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
* pass when the archive list is attached.
*/
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
allCreated += f::class.simpleName ?: f::class.java.name
attached += f
if (f is ConversationFragment) {
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
active += f
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
attached.remove(f)
if (f is ConversationFragment) {
active.remove(f)
destroyedCount++
}
}
}
private class LaunchedActivity(
private val activityHolder: Array<MainActivity?>,
val recorder: ConversationFragmentRecorder,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
/**
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
}
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
allActivities.filter { it !is MainActivity }.toList()
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
// endregion
}
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.junit.rules.ExternalResource
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
/**
@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.security.SecureRandom
@@ -27,8 +26,6 @@ import java.security.SecureRandom
*/
object MockProvider {
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
svr1Credentials = AuthCredentials.create("username", "password")
svr2Credentials = AuthCredentials.create("username", "password")
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -8,6 +8,9 @@ package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
+2 -3
View File
@@ -2,8 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
<!-- ======================================= -->
<!-- Features -->
<!-- ======================================= -->
@@ -40,6 +38,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
@@ -1405,7 +1404,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
@@ -111,6 +112,7 @@ import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -439,7 +441,24 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeFcmCheck() {
if (SignalStore.account().isRegistered()) {
if (!SignalStore.account().isRegistered()) {
return;
}
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
SignalStore.account().setFcmEnabled(true);
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
.then(new RefreshAttributesJob())
.enqueue();
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
SignalStore.account().setFcmEnabled(false);
SignalStore.account().setFcmToken(null);
AppDependencies.getJobManager().add(new RefreshAttributesJob());
} else if (SignalStore.account().isFcmEnabled()) {
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
long now = System.currentTimeMillis();
@@ -447,6 +466,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
AppDependencies.getJobManager().add(new FcmRefreshJob());
}
} else {
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
}
}
@@ -10,12 +10,15 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.TaskStackBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
public class ShortcutLauncherActivity extends AppCompatActivity {
private static final String TAG = Log.tag(ShortcutLauncherActivity.class);
private static final String KEY_RECIPIENT = "recipient_id";
public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
@@ -30,9 +33,18 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
RecipientId recipientId = null;
if (rawId == null) {
if (rawId != null) {
try {
recipientId = RecipientId.from(rawId);
} catch (Throwable t) {
Log.w(TAG, "Failed to parse recipientId from intent.", t);
}
}
if (recipientId == null) {
Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
// TODO [greyson] Navigation
startActivity(MainActivity.clearTop(this));
@@ -40,7 +52,7 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
return;
}
Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
Recipient recipient = Recipient.live(recipientId).get();
// TODO [greyson] Navigation
TaskStackBuilder backStack = TaskStackBuilder.create(this)
.addNextIntent(MainActivity.clearTop(this));
@@ -66,7 +66,11 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.ApplicationErrorAction
import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -145,9 +149,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.ApplicationErrorAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
@@ -161,7 +162,6 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
bioText = selfRecord.about ?: "",
bioEmoji = selfRecord.aboutEmoji ?: "",
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
bioEmoji = selfRecord.aboutEmoji ?: ""
)
)
)
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
SignalStore.account.usernameLink = null
}
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
SignalDatabase.recipients.clearSelfKeyTransparencyData()
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
@@ -93,7 +93,8 @@ fun EnterKeyScreen(
val updateEnteredBackupKey = { input: String ->
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
isBackupKeyValid = enteredBackupKey == backupKey
val normalized = AccountEntropyPool.formatForStorage(enteredBackupKey)
isBackupKeyValid = normalized.equals(AccountEntropyPool.formatForStorage(backupKey), ignoreCase = true)
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
}
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType
import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
@@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
import org.whispersystems.signalservice.api.NetworkResult
class NewCallViewModel : ViewModel() {
companion object {
@@ -36,6 +36,7 @@ import com.bumptech.glide.request.Request;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import org.signal.core.models.media.TransformProperties;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.core.util.logging.Log;
@@ -608,7 +609,14 @@ public class ThumbnailView extends FrameLayout {
}
private RequestBuilder<Drawable> buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri())))
long videoTrimStartTimeUs = 0;
TransformProperties transformProperties = slide.asAttachment().transformProperties;
if (transformProperties != null && !transformProperties.shouldSkipTransform()) {
videoTrimStartTimeUs = transformProperties.videoTrimStartTimeUs;
}
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri()), videoTrimStartTimeUs))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.transition(withCrossFade()));
@@ -312,49 +312,49 @@ private fun AppSettingsContent(
enabled = isRegisteredAndUpToDate
)
}
}
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
}
},
icon = {
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
@@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
class ExportAccountDataRepository {
@@ -21,6 +21,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
@@ -33,7 +34,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.math.BigDecimal
import java.util.Currency
@@ -16,12 +16,12 @@ import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.NetworkResult
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes
import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
@@ -51,7 +52,6 @@ import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -21,6 +21,7 @@ import org.signal.libsignal.protocol.state.SignalProtocolStore
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.protocol.util.Medium
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -36,7 +37,6 @@ import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.SvrNoDataException
@@ -147,7 +147,7 @@ class ChangeNumberRepository(
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
retryChangeLocalNumberNetworkOperation {
SignalNetwork.keys.setPreKeys(
SignalNetwork.keys.setPreKeysSync(
PreKeyUpload(
serviceIdType = ServiceIdType.PNI,
signedPreKey = signedPreKey,
@@ -5,11 +5,11 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket.RegistrationLockFailure
@@ -503,7 +503,7 @@ fun Screen(
text = "Copy Account Entropy Pool (AEP)",
label = "Copies the Account Entropy Pool (AEP) to the clipboard, which is labeled as the \"Backup Key\" in the designs.",
onClick = {
Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.value)
Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.displayValue)
Toast.makeText(context, "Copied!", Toast.LENGTH_SHORT).show()
}
)
@@ -37,6 +37,7 @@ import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.roundedString
import org.signal.core.util.stream.LimitedInputStream
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
@@ -58,7 +59,6 @@ import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@@ -12,6 +12,5 @@ sealed interface LabsSettingsEvents {
data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
}
@@ -15,6 +15,5 @@ data class LabsSettingsState(
val groupSuggestionsForMembers: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
)
@@ -57,7 +57,6 @@ class LabsSettingsViewModel : ViewModel() {
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers,
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
)
}
@@ -7,6 +7,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
@@ -297,6 +297,7 @@ object RecurringInAppPaymentRepository {
if (response.status == 200 || response.status == 204) {
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${response.status}", true)
SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType)
MultiDeviceSubscriptionSyncRequestJob.enqueue()
syncAccountRecord().subscribe()
} else {
if (response.applicationError.isPresent) {
@@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.lifecycle.ViewModel
import org.signal.core.util.logging.Log
import org.signal.network.util.Preconditions
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.whispersystems.signalservice.api.util.Preconditions
/**
* State holder for the checkout flow when utilizing Google Pay.
@@ -236,8 +236,8 @@ private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
}
}
@@ -14,6 +14,7 @@ import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.PayPalPaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.network.util.Preconditions
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
class PayPalPaymentInProgressViewModel : ViewModel() {
@@ -16,6 +16,7 @@ import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.network.util.Preconditions
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
class StripePaymentInProgressViewModel : ViewModel() {
@@ -28,6 +28,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -297,15 +298,38 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
}
override fun clearSenderKeyAndArchiveSessions(recipientId: RecipientId) {
clearSenderKey(recipientId)
lifecycleScope.launch {
val dialog = withContext(Dispatchers.Main) {
SignalProgressDialog.show(requireContext(), "Clearing...", cancelable = false, indeterminate = true)
}
val group = SignalDatabase.groups.getGroup(recipientId).orNull()
if (group == null) {
Log.w(TAG, "Couldn't find group for recipientId: $recipientId")
return
withContext(Dispatchers.Default) {
clearSenderKey(recipientId)
val group = SignalDatabase.groups.getGroup(recipientId).orNull()
if (group == null) {
Log.w(TAG, "Couldn't find group for recipientId: $recipientId")
return@withContext
}
group.members.forEach { memberId ->
archiveSessions(memberId)
val member = Recipient.resolved(memberId)
if (member.hasAci) {
AppDependencies.protocolStore.aci().identities().delete(member.requireAci().toString())
}
if (member.hasPni) {
AppDependencies.protocolStore.aci().identities().delete(member.requirePni().toString())
}
}
}
withContext(Dispatchers.Main) {
dialog.dismiss()
}
}
group.members.forEach { archiveSessions(it) }
}
class InternalViewModel(
@@ -212,7 +212,7 @@ fun InternalConversationSettingsScreen(
item {
Rows.TextRow(
text = "Clear sender key and archive sessions",
label = "Resets any sender key state and archives all sessions for group members, will force creating new sessions and re-distributing sender key material.",
label = "Resets any sender key state, archives all sessions, and removes identity keys for group members, will force creating new sessions and re-distributing sender key material.",
onClick = {
dialog = Dialog.CLEAR_SENDER_KEY_AND_ARCHIVE_SESSIONS
}
@@ -8,6 +8,8 @@ import androidx.annotation.Px;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Set;
@@ -164,7 +166,9 @@ public final class WebRtcControls {
}
public boolean displayOverflow() {
return isAtLeastOutgoing() && hasAtLeastOneRemote && isGroupCall() && groupCallState == GroupCallState.CONNECTED;
boolean connectedGroupCall = isGroupCall() && groupCallState == GroupCallState.CONNECTED && hasAtLeastOneRemote;
boolean connected1to1Call = !isGroupCall() && callState == CallState.ONGOING && RemoteConfig.screenSharing();
return isAtLeastOutgoing() && (connectedGroupCall || connected1to1Call);
}
public boolean displayMuteAudio() {
@@ -280,7 +284,7 @@ public final class WebRtcControls {
return callState.isAtLeast(CallState.OUTGOING);
}
private boolean isGroupCall() {
public boolean isGroupCall() {
return groupCallState != GroupCallState.NONE;
}
@@ -46,6 +46,9 @@ data class AdditionalActionsState(
val isShown: Boolean = false,
val reactions: PersistentList<String> = persistentListOf(),
val isSelfHandRaised: Boolean = false,
val isScreenSharing: Boolean = false,
val displayScreenShareToggle: Boolean = false,
val isGroupCall: Boolean = true,
@Stable val listener: AdditionalActionsListener = AdditionalActionsListener.Empty
)
@@ -53,11 +56,13 @@ interface AdditionalActionsListener {
fun onReactClick(reaction: String)
fun onReactWithAnyClick()
fun onRaiseHandClick(raised: Boolean)
fun onScreenShareClick(sharing: Boolean)
object Empty : AdditionalActionsListener {
override fun onReactClick(reaction: String) = Unit
override fun onReactWithAnyClick() = Unit
override fun onRaiseHandClick(raised: Boolean) = Unit
override fun onScreenShareClick(sharing: Boolean) = Unit
}
}
@@ -81,17 +86,23 @@ private fun AdditionalActionsPopupContent(
Column(
verticalArrangement = spacedBy(12.dp),
modifier = Modifier
.width(IntrinsicSize.Min)
.width(IntrinsicSize.Max)
.padding(12.dp)
) {
CallReactionScrubber(
reactions = state.reactions,
listener = state.listener
)
if (state.isGroupCall) {
CallReactionScrubber(
reactions = state.reactions,
listener = state.listener
)
}
CallScreenMenu(
isGroupCall = state.isGroupCall,
onRaiseHandClick = state.listener::onRaiseHandClick,
isSelfHandRaised = state.isSelfHandRaised
isSelfHandRaised = state.isSelfHandRaised,
isScreenSharing = state.isScreenSharing,
displayScreenShareToggle = state.displayScreenShareToggle,
onScreenShareClick = state.listener::onScreenShareClick
)
}
}
@@ -134,19 +145,33 @@ private fun CallReactionScrubber(
@Composable
private fun CallScreenMenu(
isGroupCall: Boolean,
isSelfHandRaised: Boolean,
onRaiseHandClick: (Boolean) -> Unit
onRaiseHandClick: (Boolean) -> Unit,
isScreenSharing: Boolean = false,
displayScreenShareToggle: Boolean = false,
onScreenShareClick: (Boolean) -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SignalTheme.colors.colorSurface2, RoundedCornerShape(18.dp))
) {
CallScreenMenuOption(
imageVector = ImageVector.vectorResource(R.drawable.symbol_raise_hand_24),
title = if (isSelfHandRaised) stringResource(R.string.CallOverflowPopupWindow__lower_hand) else stringResource(R.string.CallOverflowPopupWindow__raise_hand),
onClick = { onRaiseHandClick(!isSelfHandRaised) }
)
if (isGroupCall) {
CallScreenMenuOption(
imageVector = ImageVector.vectorResource(R.drawable.symbol_raise_hand_24),
title = if (isSelfHandRaised) stringResource(R.string.CallOverflowPopupWindow__lower_hand) else stringResource(R.string.CallOverflowPopupWindow__raise_hand),
onClick = { onRaiseHandClick(!isSelfHandRaised) }
)
}
if (displayScreenShareToggle) {
CallScreenMenuOption(
imageVector = ImageVector.vectorResource(R.drawable.symbol_screen_share_24),
title = if (isScreenSharing) stringResource(R.string.CallOverflowPopupWindow__stop_screen_share) else stringResource(R.string.CallOverflowPopupWindow__share_screen),
onClick = { onScreenShareClick(!isScreenSharing) }
)
}
}
}
@@ -224,3 +249,28 @@ private fun CallScreenAdditionalActionsPopupPreview() {
)
}
}
@NightPreview
@Composable
private fun CallScreenAdditionalActionsScreenSharingPreview() {
Previews.Preview {
AdditionalActionsPopupContent(
state = AdditionalActionsState(
isGroupCall = false,
displayScreenShareToggle = true,
isShown = false,
reactions = persistentListOf(
"\u2764\ufe0f",
"\ud83d\udc4d",
"\ud83d\udc4e",
"\ud83d\ude02",
"\ud83d\ude2e",
"\ud83d\ude22"
),
isSelfHandRaised = false,
listener = AdditionalActionsListener.Empty,
triggerAlignedPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
)
)
}
}
@@ -82,7 +82,7 @@ fun CallControls(
}
val hasCameraPermission = ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (callControlsState.displayVideoToggle) {
if (callControlsState.displayVideoToggle && !callControlsState.isLocalScreenSharing) {
CallScreenTooltipBox(
text = stringResource(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video),
displayTooltip = displayVideoTooltip,
@@ -211,10 +211,12 @@ data class CallControlsState(
val displayGroupRingingToggle: Boolean = false,
val isGroupRingingEnabled: Boolean = false,
val isGroupRingingAllowed: Boolean = false,
val isGroupCall: Boolean = false,
val displayAdditionalActions: Boolean = false,
val displayStartCallButton: Boolean = false,
val startCallButtonText: Int = R.string.WebRtcCallView__start_call,
val displayEndCallButton: Boolean = false
val displayEndCallButton: Boolean = false,
val isLocalScreenSharing: Boolean = false
) {
val hasAnyControls: Boolean
@@ -235,7 +237,8 @@ data class CallControlsState(
callParticipantsState: CallParticipantsState,
webRtcControls: WebRtcControls,
groupMemberCount: Int,
isAudioDeviceChangePending: Boolean = false
isAudioDeviceChangePending: Boolean = false,
isLocalScreenSharing: Boolean = false
): CallControlsState {
return CallControlsState(
isEarpieceAvailable = webRtcControls.isEarpieceAvailableForAudioToggle,
@@ -250,12 +253,14 @@ data class CallControlsState(
displayMicToggle = webRtcControls.displayMuteAudio(),
isMicEnabled = callParticipantsState.localParticipant.isMicrophoneEnabled,
displayGroupRingingToggle = webRtcControls.displayRingToggle(),
isGroupCall = webRtcControls.isGroupCall,
isGroupRingingEnabled = callParticipantsState.ringGroup,
isGroupRingingAllowed = groupMemberCount <= RemoteConfig.maxGroupCallRingSize,
displayAdditionalActions = webRtcControls.displayOverflow(),
displayStartCallButton = webRtcControls.displayStartCallControls(),
startCallButtonText = webRtcControls.startCallButtonText,
displayEndCallButton = webRtcControls.displayEndCall()
displayEndCallButton = webRtcControls.displayEndCall(),
isLocalScreenSharing = isLocalScreenSharing
)
}
}
@@ -22,7 +22,7 @@ sealed interface CallEvent {
data class StartCall(val isVideoCall: Boolean) : CallEvent
data class ShowGroupCallSafetyNumberChange(val identityRecords: List<IdentityRecord>) : CallEvent
data object SwitchToSpeaker : CallEvent
data object ShowSwipeToSpeakerHint : CallEvent
data object ShowSwipeToScreenShareHint : CallEvent
data object ShowLargeGroupAutoMuteToast : CallEvent
data class ShowRemoteMuteToast(private val muted: Recipient, private val mutedBy: Recipient) : CallEvent {
fun getDescription(context: Context): String {
@@ -15,7 +15,6 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -52,67 +51,55 @@ fun CallParticipantsPager(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
// Use movableContentOf to preserve CallGrid state when switching between
// single participant (no pager) and multiple participants (with pager)
val callGridContent = remember {
movableContentOf { state: CallParticipantsPagerState, mod: Modifier, aspectRatio: Float? ->
CallGrid(
items = state.callParticipants,
singleParticipantAspectRatio = aspectRatio,
modifier = mod,
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
var itemWindowOrigin by remember(participant.callParticipantId) { mutableStateOf(Offset.Zero) }
itemModifier
.onGloballyPositioned { coords -> itemWindowOrigin = coords.positionInRoot() }
.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { local -> currentOnLongPress.value?.invoke(participant, itemWindowOrigin + local) }
)
}
} else {
itemModifier
}
VerticalPager(
state = pagerState,
modifier = modifier
.displayCutoutPadding()
.statusBarsPadding()
) { page ->
when (page) {
0 -> {
CallGrid(
items = callParticipantsPagerState.callParticipants,
singleParticipantAspectRatio = firstParticipantAR,
modifier = Modifier.fillMaxSize(),
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
var itemWindowOrigin by remember(participant.callParticipantId) { mutableStateOf(Offset.Zero) }
itemModifier
.onGloballyPositioned { coords -> itemWindowOrigin = coords.positionInRoot() }
.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { local -> currentOnLongPress.value?.invoke(participant, itemWindowOrigin + local) }
)
}
} else {
itemModifier
}
RemoteParticipantContent(
participant = participant,
renderInPip = state.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
showAudioIndicator = state.callParticipants.size > 1,
modifier = longPressModifier
)
}
}
}
if (callParticipantsPagerState.callParticipants.size > 1) {
VerticalPager(
state = pagerState,
modifier = modifier
.displayCutoutPadding()
.statusBarsPadding()
) { page ->
when (page) {
0 -> {
callGridContent(callParticipantsPagerState, Modifier.fillMaxSize(), firstParticipantAR)
}
1 -> {
RemoteParticipantContent(
participant = callParticipantsPagerState.focusedParticipant,
participant = participant,
renderInPip = callParticipantsPagerState.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
modifier = Modifier.fillMaxSize()
showAudioIndicator = callParticipantsPagerState.callParticipants.size > 1,
modifier = longPressModifier
)
}
}
1 -> {
RemoteParticipantContent(
participant = callParticipantsPagerState.focusedParticipant,
renderInPip = callParticipantsPagerState.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
callGridContent(callParticipantsPagerState, modifier, firstParticipantAR)
}
}
@@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.math.max
import kotlin.math.round
import kotlin.time.Duration.Companion.milliseconds
@@ -103,8 +104,10 @@ fun CallScreen(
savedLocalParticipantLandscape: Boolean = false,
callScreenState: CallScreenState,
callControlsState: CallControlsState,
callParticipantsPagerState: CallParticipantsPagerState,
callScreenController: CallScreenController = CallScreenController.rememberCallScreenController(
skipHiddenState = callControlsState.skipHiddenState,
hasMultipleRemoteParticipants = callParticipantsPagerState.callParticipants.size > 1,
onControlsToggled = {},
callControlsState = callControlsState,
callControlsListener = CallScreenControlsListener.Empty
@@ -112,7 +115,6 @@ fun CallScreen(
callScreenControlsListener: CallScreenControlsListener = CallScreenControlsListener.Empty,
callScreenSheetDisplayListener: CallScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty,
additionalActionsListener: AdditionalActionsListener = AdditionalActionsListener.Empty,
callParticipantsPagerState: CallParticipantsPagerState,
pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty,
callParticipantUpdatePopupController: CallParticipantUpdatePopupController,
overflowParticipants: List<CallParticipant>,
@@ -171,11 +173,16 @@ fun CallScreen(
val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
val additionalActionsState = remember(
callScreenState.reactions,
localParticipant.isHandRaised
localParticipant.isHandRaised,
callScreenState.isLocalScreenSharing,
callControlsState.displayEndCallButton
) {
AdditionalActionsState(
reactions = callScreenState.reactions,
isSelfHandRaised = localParticipant.isHandRaised,
isScreenSharing = callScreenState.isLocalScreenSharing,
displayScreenShareToggle = callControlsState.displayEndCallButton && RemoteConfig.screenSharing,
isGroupCall = callControlsState.isGroupCall,
listener = additionalActionsListener,
triggerAlignedPopupState = additionalActionsPopupState
)
@@ -505,7 +512,7 @@ fun CallScreen(
)
SwipeToSpeakerHintPopup(
visible = callScreenState.displaySwipeToSpeakerHint,
hintType = callScreenState.swipeHint,
onDismiss = onSwipeToSpeakerHintDismissed,
modifier = Modifier
.statusBarsPadding()
@@ -77,11 +77,13 @@ class CallScreenController private constructor(
@Composable
fun rememberCallScreenController(
skipHiddenState: Boolean,
hasMultipleRemoteParticipants: Boolean,
onControlsToggled: (Boolean) -> Unit,
callControlsState: CallControlsState,
callControlsListener: CallScreenControlsListener
): CallScreenController {
val skip by rememberUpdatedState(skipHiddenState)
val hasMultipleRemoteParticipantsState = rememberUpdatedState(hasMultipleRemoteParticipants)
val valueChangeOperation: (SheetValue) -> Boolean = remember {
{
!(it == SheetValue.Hidden && skip)
@@ -130,7 +132,7 @@ class CallScreenController private constructor(
val callParticipantsVerticalPagerState = rememberPagerState(
initialPage = 0,
pageCount = { 2 }
pageCount = { if (hasMultipleRemoteParticipantsState.value) 2 else 1 }
)
return remember(scaffoldState, callParticipantsVerticalPagerState, audioOutputPickerController) {
@@ -36,6 +36,7 @@ interface CallScreenControlsListener {
fun onNavigateUpClicked()
fun toggleControls()
fun onAudioPermissionsRequested(onGranted: Runnable?)
fun onScreenShareChanged(sharing: Boolean)
object Empty : CallScreenControlsListener {
override fun onStartCall(isVideoCall: Boolean) = Unit
@@ -58,5 +59,6 @@ interface CallScreenControlsListener {
override fun onNavigateUpClicked() = Unit
override fun toggleControls() = Unit
override fun onAudioPermissionsRequested(onGranted: Runnable?) = Unit
override fun onScreenShareChanged(sharing: Boolean) = Unit
}
}
@@ -43,6 +43,7 @@ interface CallScreenMediator {
fun enableRingGroup(canRing: Boolean)
fun showSpeakerViewHint()
fun hideSpeakerViewHint()
fun showScreenShareHint()
fun showVideoTooltip(): Dismissible
fun showCameraTooltip(): Dismissible
fun onCallStateUpdate(callControlsChange: CallControlsChange)
@@ -25,7 +25,7 @@ data class CallScreenState(
val isDisplayingAudioToggleSheet: Boolean = false,
val displaySwitchCameraTooltip: Boolean = false,
val displayVideoTooltip: Boolean = false,
val displaySwipeToSpeakerHint: Boolean = false,
val swipeHint: SwipeHintType = SwipeHintType.NONE,
val displayWifiToCellularPopup: Boolean = false,
val remoteMuteToastMessage: String? = null,
val displayAdditionalActionsDialog: Boolean = false,
@@ -34,7 +34,14 @@ data class CallScreenState(
val isParticipantUpdatePopupEnabled: Boolean = true,
val isCallStateUpdatePopupEnabled: Boolean = false,
val isWaitingToBeLetIn: Boolean = false,
val reactions: PersistentList<String> = persistentListOf()
val reactions: PersistentList<String> = persistentListOf(),
val isLocalScreenSharing: Boolean = false
) {
fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog
}
enum class SwipeHintType {
NONE,
SPEAKER_VIEW,
SCREEN_SHARE
}
@@ -14,6 +14,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.lifecycle.ViewModel
@@ -110,6 +111,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
val recipient by viewModel.getRecipientFlow().collectAsStateWithLifecycle(Recipient.UNKNOWN)
val webRtcCallState by callScreenViewModel.callState.collectAsStateWithLifecycle()
val callScreenState by callScreenViewModel.callScreenState.collectAsStateWithLifecycle()
val isLocalScreenSharing by viewModel.isLocalScreenSharing.collectAsStateWithLifecycle()
val callControlsState by viewModel.getCallControlsState().collectAsStateWithLifecycle(CallControlsState())
val callParticipantsViewState by callScreenViewModel.callParticipantsViewState.collectAsStateWithLifecycle()
val callParticipantsState = remember(callParticipantsViewState) { callParticipantsViewState.callParticipantsState }
@@ -162,6 +164,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
val callScreenController = CallScreenController.rememberCallScreenController(
skipHiddenState = callControlsState.skipHiddenState,
hasMultipleRemoteParticipants = callParticipantsPagerState.callParticipants.size > 1,
onControlsToggled = onControlsToggled,
callControlsState = callControlsState,
callControlsListener = callScreenControlsListener
@@ -173,6 +176,22 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
}
LaunchedEffect(isLocalScreenSharing) {
callScreenViewModel.callScreenState.update { it.copy(isLocalScreenSharing = isLocalScreenSharing) }
}
LaunchedEffect(callScreenController, callScreenControlsListener) {
snapshotFlow { callScreenController.callParticipantsVerticalPagerState.settledPage }
.collect { page ->
val selected = if (page == 1) {
CallParticipantsState.SelectedPage.FOCUSED
} else {
CallParticipantsState.SelectedPage.GRID
}
callScreenControlsListener.onPageChanged(selected)
}
}
val controlAndInfoState by controlsAndInfoViewModel.state
SignalTheme(isDarkMode = true) {
@@ -217,7 +236,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onControlsToggled = onControlsToggled,
onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } },
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.NONE) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
@@ -322,11 +341,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
override fun showSpeakerViewHint() {
callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = true) }
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.SPEAKER_VIEW) }
}
override fun hideSpeakerViewHint() {
callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) }
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.NONE) }
}
override fun showScreenShareHint() {
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.SCREEN_SHARE) }
}
override fun showVideoTooltip(): Dismissible {
@@ -393,6 +416,11 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
override fun onScreenShareClick(sharing: Boolean) {
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
controlsListener.value.onScreenShareChanged(sharing)
}
private fun handleFailure() {
Toast.makeText(activity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -25,16 +26,23 @@ import kotlin.time.Duration.Companion.seconds
import org.signal.core.ui.R as CoreUiR
/**
* Popup shown to hint the user that they can swipe to view screen share.
* Popup shown to hint the user that they should swipe between the grid view and
* the focused page for speaker/screen share when available.
*/
@Composable
fun SwipeToSpeakerHintPopup(
visible: Boolean,
hintType: SwipeHintType,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val textResId = when (hintType) {
SwipeHintType.SCREEN_SHARE -> R.string.CallToastPopupWindow__swipe_to_view_screen_share
SwipeHintType.SPEAKER_VIEW,
SwipeHintType.NONE -> R.string.CallToastPopupWindow__swipe_to_view_speaker
}
CallScreenPopup(
visible = visible,
visible = hintType != SwipeHintType.NONE,
onDismiss = onDismiss,
displayDuration = 3.seconds,
modifier = modifier
@@ -51,7 +59,7 @@ fun SwipeToSpeakerHintPopup(
)
Text(
text = stringResource(R.string.CallToastPopupWindow__swipe_to_view_screen_share),
text = stringResource(textResId),
color = colorResource(CoreUiR.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(start = 8.dp)
)
@@ -63,9 +71,16 @@ fun SwipeToSpeakerHintPopup(
@Composable
private fun SwipeToSpeakerHintPopupPreview() {
Previews.Preview {
SwipeToSpeakerHintPopup(
visible = true,
onDismiss = {}
)
Column {
SwipeToSpeakerHintPopup(
hintType = SwipeHintType.SPEAKER_VIEW,
onDismiss = {}
)
SwipeToSpeakerHintPopup(
hintType = SwipeHintType.SCREEN_SHARE,
onDismiss = {}
)
}
}
}
@@ -14,6 +14,8 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.media.projection.MediaProjectionConfig
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
@@ -22,6 +24,7 @@ import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
@@ -38,6 +41,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
@@ -123,6 +127,11 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private var ephemeralStateDisposable = Disposable.empty()
private val callPermissionsDialogController = CallPermissionsDialogController()
private val eventBusSubscriber = EventBusSubscriber()
private val mediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK && result.data != null) {
AppDependencies.signalCallManager.startScreenShare(result.data!!)
}
}
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
@@ -210,6 +219,17 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
callScreen.setMicEnabled(viewModel.microphoneEnabled.value)
}
}
lifecycleScope.launch {
viewModel
.isLocalScreenSharing
.drop(1)
.collect { sharing ->
if (!sharing && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
AppDependencies.signalCallManager.setEnableVideo(false)
}
}
}
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -312,7 +332,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
requestNewSizesThrottle.clear()
}
if (!isChangingConfigurations && !isInMultiWindowModeCompat()) {
if (!isChangingConfigurations && !isInMultiWindowModeCompat() && !viewModel.isLocalScreenSharing.value) {
AppDependencies.signalCallManager.setEnableVideo(false)
}
@@ -985,7 +1005,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
is CallEvent.StartCall -> startCall(event.isVideoCall)
is CallEvent.ShowGroupCallSafetyNumberChange -> SafetyNumberBottomSheet.forGroupCall(event.identityRecords).show(supportFragmentManager)
is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView()
is CallEvent.ShowSwipeToSpeakerHint -> callScreen.showSpeakerViewHint()
is CallEvent.ShowSwipeToScreenShareHint -> callScreen.showScreenShareHint()
is CallEvent.ShowRemoteMuteToast -> callScreen.showRemoteMuteToast(event.getDescription(this))
is CallEvent.ShowLargeGroupAutoMuteToast -> {
callScreen.onCallStateUpdate(CallControlsChange.MIC_OFF)
@@ -1390,6 +1410,20 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
override fun onAudioPermissionsRequested(onGranted: Runnable?) {
askAudioPermissions { onGranted?.run() }
}
override fun onScreenShareChanged(sharing: Boolean) {
if (sharing) {
val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = if (Build.VERSION.SDK_INT >= 34) {
mediaProjectionManager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay())
} else {
mediaProjectionManager.createScreenCaptureIntent()
}
mediaProjectionLauncher.launch(intent)
} else {
AppDependencies.signalCallManager.stopScreenShare()
}
}
}
private inner class PendingParticipantsViewListener : PendingParticipantsListener {
@@ -70,6 +70,9 @@ class WebRtcCallViewModel : ViewModel() {
private val ephemeralState = MutableStateFlow<WebRtcEphemeralState?>(null)
private val remoteMutesReported = MutableStateFlow(HashSet<CallParticipantId>())
private val _isLocalScreenSharing = MutableStateFlow(false)
val isLocalScreenSharing: StateFlow<Boolean> = _isLocalScreenSharing
private val controlsWithFoldableState: Flow<WebRtcControls> = combine(foldableState, webRtcControls, this::updateControlsFoldableState)
private val realWebRtcControls: StateFlow<WebRtcControls> = combine(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls)
.stateIn(viewModelScope, SharingStarted.Eagerly, WebRtcControls.NONE)
@@ -100,6 +103,7 @@ class WebRtcCallViewModel : ViewModel() {
private var callConnectedTime = -1L
private var answerWithVideoAvailable = false
private var previousParticipantList = Collections.emptyList<CallParticipant>()
private var hasSeededParticipantList = false
private var switchOnFirstScreenShare = true
private var showScreenShareTip = true
private var hasShownAutoMuteToast = false
@@ -181,9 +185,10 @@ class WebRtcCallViewModel : ViewModel() {
callParticipantsState,
getWebRtcControls(),
groupSize,
isAudioDeviceChangePending
) { participantsState, controls, groupMemberCount, audioChangePending ->
CallControlsState.fromViewModelData(participantsState, controls, groupMemberCount, audioChangePending)
isAudioDeviceChangePending,
_isLocalScreenSharing
) { participantsState, controls, groupMemberCount, audioChangePending, isLocalScreenSharing ->
CallControlsState.fromViewModelData(participantsState, controls, groupMemberCount, audioChangePending, isLocalScreenSharing)
}
}
@@ -263,7 +268,7 @@ class WebRtcCallViewModel : ViewModel() {
) {
showScreenShareTip = false
viewModelScope.launch {
events.emit(CallEvent.ShowSwipeToSpeakerHint)
events.emit(CallEvent.ShowSwipeToScreenShareHint)
}
}
@@ -319,6 +324,7 @@ class WebRtcCallViewModel : ViewModel() {
val wasMicrophoneEnabled = internalMicrophoneEnabled.value
internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled
isAudioDeviceChangePending.value = webRtcViewModel.isAudioDeviceChangePending
_isLocalScreenSharing.value = webRtcViewModel.isLocalScreenSharing
if (internalMicrophoneEnabled.value) {
remoteMutedBy.update { null }
@@ -347,12 +353,13 @@ class WebRtcCallViewModel : ViewModel() {
}
if (webRtcViewModel.groupState.isConnected) {
if (!containsPlaceholders(previousParticipantList)) {
if (!containsPlaceholders(previousParticipantList) && hasSeededParticipantList) {
val update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantList, webRtcViewModel.remoteParticipants)
viewModelScope.launch {
callParticipantListUpdate.emit(update)
}
}
hasSeededParticipantList = true
for (remote in webRtcViewModel.remoteParticipants) {
if (remote.remotelyMutedBy == null) {
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.network.util.Preconditions
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
@@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.search.SearchFilter
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.whispersystems.signalservice.api.util.Preconditions
/**
* Manages paged contact search data, query/filter state, and contact selection. Drives
@@ -5,6 +5,7 @@ import androidx.annotation.WorkerThread
import org.signal.contacts.SystemContactsRepository
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.InputResult
import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.OutputResult
import org.thoughtcrime.securesms.database.RecipientTable.CdsV2Result
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.cds.CdsiV2Service
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.signal.network.util.Preconditions;
import java.util.ArrayList;
import java.util.Collection;
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage.Info
@@ -27,7 +28,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
import org.whispersystems.signalservice.api.NetworkResult
class NewConversationViewModel : ViewModel() {
companion object {
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.network.util.Preconditions
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
@@ -10,7 +11,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.stories.Stories
import org.whispersystems.signalservice.api.util.Preconditions
import java.util.Optional
class MultiselectForwardRepository {
@@ -607,6 +607,8 @@ class ConversationFragment :
private var releaseNotesLayoutApplied: Boolean = false
private var releaseNotesWallpaperApplied: Boolean = false
private var applyToolbarPaddingRunnable: Runnable? = null
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
ScrollToPositionDelegate.JumpToPositionStrategy.performScroll(recyclerView, layoutManager, position, smooth)
@@ -761,19 +763,30 @@ class ConversationFragment :
split.second
}
binding.conversationItemRecycler.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
binding.conversationItemRecycler.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
}
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
binding.conversationItemRecycler.padding(top = bottom)
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
binding.conversationItemRecycler.invalidateItemDecorations()
// Bug: ConstraintLayout can provide a negative value for the toolbar causing RV layout problems
if (bottom < 0) return@addOnLayoutChangeListener
// Bug: LinearLayoutManger can get stuck and not layout children under Compose's AndroidFragment if updated too quickly.
val rv = binding.conversationItemRecycler
applyToolbarPaddingRunnable?.let { rv.removeCallbacks(it) }
val runnable = Runnable {
if (view == null) return@Runnable
rv.padding(top = bottom)
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
rv.invalidateItemDecorations()
}
}
}
applyToolbarPaddingRunnable = runnable
rv.post(runnable)
}
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
@@ -329,7 +329,8 @@ class ConversationViewModel(
recipientRepository.groupRecord
) { _, r, g -> Pair(r, g) }
.subscribeOn(Schedulers.io())
.flatMapSingle { (r, g) -> repository.getIdentityRecords(r, g.orNull()) }
.throttleLatest(250, TimeUnit.MILLISECONDS, true)
.switchMapSingle { (r, g) -> repository.getIdentityRecords(r, g.orNull()) }
.subscribeBy { newState ->
identityRecordsStore.update { newState }
}
@@ -158,9 +158,16 @@ class V2ConversationItemThumbnail @JvmOverloads constructor(
}
if (thumbnailUri != null) {
val transformProperties = thumbnailAttachment.transformProperties
val videoTrimStartTimeUs = if (transformProperties != null && !transformProperties.skipTransform) {
transformProperties.videoTrimStartTimeUs
} else {
0L
}
conversationContext
.requestManager
.load(DecryptableUri(thumbnailUri))
.load(DecryptableUri(thumbnailUri, videoTrimStartTimeUs))
.centerInside()
.dontAnimate()
.override(thumbnailSize.width, thumbnailSize.height)
@@ -14,6 +14,7 @@ import androidx.core.text.util.LinkifyCompat
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.Linkification
import org.thoughtcrime.securesms.util.UrlClickHandler
import org.thoughtcrime.securesms.util.hasOnlyThumbnail
@@ -28,24 +29,21 @@ object V2ConversationItemUtils {
@JvmStatic
fun linkifyUrlLinks(messageBody: Spannable, shouldLinkifyAllLinks: Boolean, urlClickHandler: UrlClickHandler) {
val linkPattern = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
val hasLinks = LinkifyCompat.addLinks(messageBody, if (shouldLinkifyAllLinks) linkPattern else 0)
if (!hasLinks) {
if (!shouldLinkifyAllLinks) {
return
}
messageBody.getSpans(0, messageBody.length, URLSpan::class.java)
.filterNot { LinkUtil.isLegalUrl(it.url) }
.forEach(messageBody::removeSpan)
LinkifyCompat.addLinks(messageBody, Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
Linkification.applyWebUrlSpans(messageBody)
messageBody.getSpans(0, messageBody.length, URLSpan::class.java).forEach { urlSpan ->
val url = urlSpan.url
val start = messageBody.getSpanStart(urlSpan)
val end = messageBody.getSpanEnd(urlSpan)
val span = InterceptableLongClickCopyLinkSpan(urlSpan.url, urlClickHandler)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
messageBody.removeSpan(urlSpan)
if (LinkUtil.isLegalUrl(url)) {
messageBody.setSpan(InterceptableLongClickCopyLinkSpan(url, urlClickHandler), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
}
@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter
import androidx.annotation.FloatRange
import org.whispersystems.signalservice.api.util.Preconditions
import org.signal.network.util.Preconditions
import kotlin.time.Duration
/**
@@ -76,7 +76,7 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
SessionRecord sessionRecord = SignalDatabase.sessions().load(accountId, address);
return sessionRecord != null && sessionRecord.hasSenderChain();
return sessionRecord != null && sessionRecord.hasSenderChain(0.0);
}
}
@@ -188,6 +188,6 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
}
private static boolean isActive(@Nullable SessionRecord record) {
return record != null && record.hasSenderChain();
return record != null && record.hasSenderChain(0.0);
}
}
@@ -1364,6 +1364,7 @@ class AttachmentTable(
val filePathsToDelete: MutableSet<String> = mutableSetOf()
val contentTypesToDelete: MutableSet<String> = mutableSetOf()
var threadId: Long = -1
writableDatabase.withinTransaction { db ->
db.select(DATA_FILE, CONTENT_TYPE, ID)
@@ -1411,12 +1412,16 @@ class AttachmentTable(
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
val threadId = messages.getThreadIdForMessage(messageId)
threadId = messages.getThreadIdForMessage(messageId)
if (threadId > 0) {
notifyConversationListeners(threadId)
}
}
if (threadId > 0) {
SignalDatabase.threads.updateSnippetContentTypeSilently(threadId, messageId, MediaUtil.VIEW_ONCE)
}
deleteDataFiles(filePathsToDelete, contentTypesToDelete)
}
@@ -49,6 +49,16 @@ class IdentityTable internal constructor(context: Context?, databaseHelper: Sign
companion object {
private val TAG = Log.tag(IdentityTable::class.java)
/**
* When set, [saveIdentity] will skip its per-recipient `markNeedsSync` + `scheduleSyncForDataChange`
* side effects and instead deposit the affected [RecipientId] into the set. The caller is then
* responsible for performing a single bulk follow-up (storage-id rotation, cache invalidate,
* storage-sync schedule).
*/
@JvmField
val SUPPRESS_RECIPIENT_REFRESH: ThreadLocal<MutableSet<RecipientId>> = ThreadLocal()
const val TABLE_NAME = "identities"
private const val ID = "_id"
const val ADDRESS = "address"
@@ -125,8 +135,14 @@ class IdentityTable internal constructor(context: Context?, databaseHelper: Sign
nonBlockingApproval: Boolean
) {
saveIdentityInternal(addressName, recipientId, identityKey, verifiedStatus, firstUse, timestamp, nonBlockingApproval)
recipients.markNeedsSync(recipientId)
StorageSyncHelper.scheduleSyncForDataChange()
val deferred = SUPPRESS_RECIPIENT_REFRESH.get()
if (deferred != null) {
deferred += recipientId
} else {
recipients.markNeedsSync(recipientId)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun setApproval(addressName: String, recipientId: RecipientId, nonBlockingApproval: Boolean) {
@@ -1,190 +0,0 @@
package org.thoughtcrime.securesms.database;
import android.app.Application;
import android.content.ContentValues;
import android.database.Cursor;
import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import org.signal.core.util.CursorUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* IMPORTANT: Writes should only be made through {@link org.thoughtcrime.securesms.megaphone.MegaphoneRepository}.
*/
public class MegaphoneDatabase extends SQLiteOpenHelper implements SignalDatabaseOpenHelper {
private static final String TAG = Log.tag(MegaphoneDatabase.class);
private static final int DATABASE_VERSION = 1;
private static final String DATABASE_NAME = "signal-megaphone.db";
private static final String TABLE_NAME = "megaphone";
private static final String ID = "_id";
private static final String EVENT = "event";
private static final String SEEN_COUNT = "seen_count";
private static final String LAST_SEEN = "last_seen";
private static final String FIRST_VISIBLE = "first_visible";
private static final String FINISHED = "finished";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
EVENT + " TEXT UNIQUE, " +
SEEN_COUNT + " INTEGER, " +
LAST_SEEN + " INTEGER, " +
FIRST_VISIBLE + " INTEGER, " +
FINISHED + " INTEGER)";
private static volatile MegaphoneDatabase instance;
private final Application application;
public static @NonNull MegaphoneDatabase getInstance(@NonNull Application context) {
if (instance == null) {
synchronized (MegaphoneDatabase.class) {
if (instance == null) {
SqlCipherLibraryLoader.load();
instance = new MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context));
}
}
}
return instance;
}
public MegaphoneDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
super(application, DATABASE_NAME, databaseSecret.asString(), null, DATABASE_VERSION, 0, new SqlCipherErrorHandler(application, DATABASE_NAME), new SqlCipherDatabaseHook(), true);
this.application = application;
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.i(TAG, "onCreate()");
db.execSQL(CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.i(TAG, "onUpgrade(" + oldVersion + ", " + newVersion + ")");
}
@Override
public void onOpen(SQLiteDatabase db) {
Log.i(TAG, "onOpen()");
db.setForeignKeyConstraintsEnabled(true);
}
public void insert(@NonNull Collection<Event> events) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
for (Event event : events) {
ContentValues values = new ContentValues();
values.put(EVENT, event.getKey());
db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public @NonNull List<MegaphoneRecord> getAllAndDeleteMissing() {
SQLiteDatabase db = getWritableDatabase();
List<MegaphoneRecord> records = new ArrayList<>();
db.beginTransaction();
try {
Set<String> missingKeys = new HashSet<>();
try (Cursor cursor = db.query(TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String event = cursor.getString(cursor.getColumnIndexOrThrow(EVENT));
int seenCount = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_COUNT));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN));
long firstVisible = cursor.getLong(cursor.getColumnIndexOrThrow(FIRST_VISIBLE));
boolean finished = cursor.getInt(cursor.getColumnIndexOrThrow(FINISHED)) == 1;
if (Event.hasKey(event)) {
records.add(new MegaphoneRecord(Event.fromKey(event), seenCount, lastSeen, firstVisible, finished));
} else {
Log.w(TAG, "No in-app handing for event '" + event + "'! Deleting it from the database.");
missingKeys.add(event);
}
}
}
for (String missing : missingKeys) {
String query = EVENT + " = ?";
String[] args = new String[]{missing};
db.delete(TABLE_NAME, query, args);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return records;
}
public void markFirstVisible(@NonNull Event event, long time) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FIRST_VISIBLE, time);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markSeen(@NonNull Event event, int seenCount, long lastSeen) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(SEEN_COUNT, seenCount);
values.put(LAST_SEEN, lastSeen);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markFinished(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FINISHED, 1);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void delete(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
getWritableDatabase().delete(TABLE_NAME, query, args);
}
@Override
public @NonNull SQLiteDatabase getSqlCipherDatabase() {
return getWritableDatabase();
}
}
@@ -0,0 +1,236 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.annotation.VisibleForTesting
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.signal.core.util.delete
import org.signal.core.util.forEach
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader.load
import org.thoughtcrime.securesms.database.model.MegaphoneRecord
import org.thoughtcrime.securesms.megaphone.Megaphones
import kotlin.concurrent.Volatile
/**
* IMPORTANT: Writes should only be made through [org.thoughtcrime.securesms.megaphone.MegaphoneRepository].
*/
open class MegaphoneDatabase(
application: Application,
databaseSecret: DatabaseSecret
) : SQLiteOpenHelper(
application,
DATABASE_NAME,
databaseSecret.asString(),
null,
DATABASE_VERSION,
0,
SqlCipherErrorHandler(application, DATABASE_NAME),
SqlCipherDatabaseHook(),
true
),
SignalDatabaseOpenHelper {
companion object {
private val TAG = Log.tag(MegaphoneDatabase::class.java)
private const val DATABASE_VERSION = 2
private const val DATABASE_NAME = "signal-megaphone.db"
private const val TABLE_NAME = "megaphone"
private const val ID = "_id"
/**
* The event name, which is a key we use to tie it to views and whatnot.
*/
private const val EVENT = "event"
/**
* How many times a megaphone was interacted with. This is most commonly the "snooze" count.
*/
private const val INTERACTION_COUNT = "interaction_count"
/**
* The last time a megaphone was interacted with. This is most commonly the "snooze" timestamp.
*/
private const val LAST_INTERACTION_TIMESTAMP = "last_interaction_timestamp"
/**
* The timestamp of when the megaphone was first shown to the user.
*/
private const val FIRST_VISIBLE = "first_visible"
/**
* The timestamp of then when the last "view cycle" started. For instance, if a megaphone was
* snoozed and then shown again, this will be the timestamp of when it was first shown again.
* It is *not* updated every time a megaphone is seen, just at the start of the view cycle.
* This is largely used to determine when to auto-snooze a megaphone.
*/
private const val LAST_VISIBLE = "last_visible"
/**
* Whether a megaphone has been fully completed. When it's finished, it'll never be shown again.
*/
private const val FINISHED = "finished"
const val CREATE_TABLE: String = """CREATE TABLE $TABLE_NAME(
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$EVENT TEXT UNIQUE,
$INTERACTION_COUNT INTEGER,
$LAST_INTERACTION_TIMESTAMP INTEGER,
$FIRST_VISIBLE INTEGER,
$LAST_VISIBLE INTEGER DEFAULT 0,
$FINISHED INTEGER
)"""
@Volatile
private var instance: MegaphoneDatabase? = null
@JvmStatic
fun getInstance(context: Application): MegaphoneDatabase {
if (instance == null) {
synchronized(MegaphoneDatabase::class.java) {
if (instance == null) {
load()
instance = MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context))
}
}
}
return instance!!
}
}
@get:VisibleForTesting
internal open val database: SupportSQLiteDatabase
get() = writableDatabase
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(CREATE_TABLE)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
if (oldVersion < 2) {
db!!.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $LAST_VISIBLE INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE $TABLE_NAME RENAME COLUMN seen_count TO interaction_count")
db.execSQL("ALTER TABLE $TABLE_NAME RENAME COLUMN last_seen TO last_interaction_timestamp")
}
}
override fun onOpen(db: SQLiteDatabase) {
Log.i(TAG, "onOpen()")
db.setForeignKeyConstraintsEnabled(true)
}
fun insert(events: Collection<Megaphones.Event>) {
database.withinTransaction { db ->
for (event in events) {
db.insertInto(TABLE_NAME)
.values(EVENT to event.key)
.run(SQLiteDatabase.CONFLICT_IGNORE)
}
}
}
fun getAllAndDeleteMissing(): MutableList<MegaphoneRecord> {
val records: MutableList<MegaphoneRecord> = mutableListOf()
database.withinTransaction { db ->
val missingKeys: MutableSet<String> = mutableSetOf()
db.select()
.from(TABLE_NAME)
.run()
.forEach { cursor ->
val event = cursor.requireNonNullString(EVENT)
val interactionCount = cursor.requireInt(INTERACTION_COUNT)
val lastInteractionTime = cursor.requireLong(LAST_INTERACTION_TIMESTAMP)
val firstVisible = cursor.requireLong(FIRST_VISIBLE)
val lastVisible = cursor.requireLong(LAST_VISIBLE)
val finished = cursor.requireBoolean(FINISHED)
if (Megaphones.Event.hasKey(event)) {
records += MegaphoneRecord(
event = Megaphones.Event.fromKey(event),
interactionCount = interactionCount,
lastInteractionTime = lastInteractionTime,
firstVisible = firstVisible,
lastVisible = lastVisible,
finished = finished
)
} else {
Log.w(TAG, "No in-app handing for event '$event'! Deleting it from the database.")
missingKeys += event
}
}
for (missing in missingKeys) {
db.delete(TABLE_NAME)
.where("$EVENT = ?", missing)
.run()
}
}
return records
}
fun markFirstVisible(event: Megaphones.Event, time: Long) {
database
.update(TABLE_NAME)
.values(FIRST_VISIBLE to time)
.where("$EVENT = ?", event.key)
.run()
}
fun markLastVisible(event: Megaphones.Event, time: Long) {
database
.update(TABLE_NAME)
.values(LAST_VISIBLE to time)
.where("$EVENT = ?", event.key)
.run()
}
fun markInteractedWith(event: Megaphones.Event, interactionCount: Int, lastInteractionTimestamp: Long) {
database
.update(TABLE_NAME)
.values(
INTERACTION_COUNT to interactionCount,
LAST_INTERACTION_TIMESTAMP to lastInteractionTimestamp,
LAST_VISIBLE to 0L
)
.where("$EVENT = ?", event.key)
.run()
}
fun markFinished(event: Megaphones.Event) {
database
.update(TABLE_NAME)
.values(FINISHED to 1)
.where("$EVENT = ?", event.key)
.run()
}
fun delete(event: Megaphones.Event) {
database
.delete(TABLE_NAME)
.where("$EVENT = ?", event.key)
.run()
}
override fun getSqlCipherDatabase(): SQLiteDatabase {
return writableDatabase
}
}
@@ -32,7 +32,6 @@ import org.signal.core.util.Base64
import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.SqlUtil.buildArgs
import org.signal.core.util.SqlUtil.buildCustomCollectionQuery
import org.signal.core.util.SqlUtil.buildSingleCollectionQuery
import org.signal.core.util.SqlUtil.buildTrueUpdateQuery
import org.signal.core.util.SqlUtil.getNextAutoIncrementId
@@ -316,6 +315,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
private const val INDEX_THREAD_UNREAD_COUNT = "message_thread_unread_count_index"
private const val INDEX_STORY_TYPE = "message_story_type_index"
private const val INDEX_ARCHIVED_STORY = "message_story_archived_index"
private const val INDEX_STARRED = "message_starred_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -344,7 +344,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)",
"CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)",
"CREATE INDEX IF NOT EXISTS $INDEX_ARCHIVED_STORY ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0",
"CREATE INDEX IF NOT EXISTS message_starred_index ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0",
"CREATE INDEX IF NOT EXISTS $INDEX_STARRED ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0",
"CREATE INDEX IF NOT EXISTS message_collapsed_state_index ON $TABLE_NAME ($COLLAPSED_STATE)",
"CREATE INDEX IF NOT EXISTS message_collapsed_head_id_index ON $TABLE_NAME ($COLLAPSED_HEAD_ID)"
)
@@ -2205,7 +2205,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
args = null
}
return mmsReaderFor(queryMessages(where, args, reverse = true)).use { reader ->
return mmsReaderFor(queryMessages(where, args, reverse = true, index = INDEX_STARRED)).use { reader ->
reader.mapNotNull { it }
}.withAttachments()
}
@@ -4908,37 +4908,39 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
val byQuoteDescriptor: MutableMap<QuoteDescriptor, MessageRecord> = HashMap(records.size)
val args: MutableList<Array<String>> = ArrayList(records.size)
val timestamps: MutableList<Long> = ArrayList(records.size)
for (record in records) {
val timestamp = record.dateSent
byQuoteDescriptor[QuoteDescriptor(timestamp, record.fromRecipient.id)] = record
args.add(buildArgs(timestamp, record.fromRecipient.id, -1))
timestamps.add(timestamp)
}
val quotedIds: MutableSet<Long> = mutableSetOf()
val pastRevisionMessageIds = records.mapNotNull { it.originalMessageId?.id }.toSet()
buildCustomCollectionQuery("$QUOTE_ID = ? AND $QUOTE_AUTHOR = ? AND $SCHEDULED_DATE = ?", args).forEach { query ->
readableDatabase
.select(ID, QUOTE_ID, QUOTE_AUTHOR)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.forEach { cursor ->
val messageId = cursor.requireLong(ID)
if (messageId !in pastRevisionMessageIds) {
val quoteLocator = QuoteDescriptor(
timestamp = cursor.requireLong(QUOTE_ID),
author = RecipientId.from(cursor.requireNonNullString(QUOTE_AUTHOR))
)
val quoteIdQuery = SqlUtil.buildFastCollectionQuery(QUOTE_ID, timestamps)
readableDatabase
.select(ID, QUOTE_ID, QUOTE_AUTHOR)
.from(TABLE_NAME)
.where("${quoteIdQuery.where} AND $SCHEDULED_DATE = -1", quoteIdQuery.whereArgs)
.run()
.forEach { cursor ->
val messageId = cursor.requireLong(ID)
if (messageId !in pastRevisionMessageIds) {
val quoteLocator = QuoteDescriptor(
timestamp = cursor.requireLong(QUOTE_ID),
author = RecipientId.from(cursor.requireNonNullString(QUOTE_AUTHOR))
)
if (byQuoteDescriptor.containsKey(quoteLocator)) {
quotedIds += byQuoteDescriptor[quoteLocator]!!.id
}
}
}
}
return quotedIds
}
@@ -6135,7 +6137,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
MessageType.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
MessageType.PINNED_MESSAGE -> MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE or MessageTypes.BASE_INBOX_TYPE
MessageType.GROUP_UPDATE -> {
@@ -44,9 +44,6 @@ enum class MessageType {
/** You unverified a user's identity/safety number, resetting it to the default state */
IDENTITY_DEFAULT,
/** A manual session reset. This is no longer used and is only here for handling possible inbound/sync messages. */
END_SESSION,
/** A poll has ended **/
POLL_TERMINATE,
@@ -43,6 +43,7 @@ import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.core.util.updateAll
import org.signal.core.util.withinTransaction
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
@@ -67,6 +68,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSucce
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
@@ -77,6 +79,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchove
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupId.V1
@@ -2330,6 +2333,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
.values(NEEDS_PNI_SIGNATURE to 0)
.run()
Log.i(TAG, "Resetting KT data due to change number.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.E164, keyTransparencyStore = KeyTransparencyStore)
SignalDatabase.pendingPniSignatureMessages.deleteAll()
db.setTransactionSuccessful()
@@ -2363,6 +2369,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
rotateStorageId(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
if (id == Recipient.self().id) {
Log.i(TAG, "Resetting KT data due to username change.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.USERNAME_HASH, keyTransparencyStore = KeyTransparencyStore)
}
}
}
@@ -4099,6 +4110,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
fun clearAllKeyTransparencyData() {
Log.i(TAG, "Clearing all key transparency data.")
writableDatabase
.update(TABLE_NAME)
.values(KEY_TRANSPARENCY_DATA to null)
@@ -4107,6 +4119,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
fun clearSelfKeyTransparencyData() {
Log.i(TAG, "Clearing self key transparency data.")
writableDatabase
.update(TABLE_NAME)
.values(KEY_TRANSPARENCY_DATA to null)
@@ -4,7 +4,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.signal.network.util.Preconditions;
import java.util.Collection;
import java.util.LinkedHashSet;
@@ -305,6 +305,14 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
.run()
}
fun updateSnippetContentTypeSilently(threadId: Long, snippetMessageId: Long, mimeType: String) {
writableDatabase
.update(TABLE_NAME)
.values(SNIPPET_CONTENT_TYPE to mimeType)
.where("$ID = ? AND $SNIPPET_MESSAGE_ID = ?", threadId, snippetMessageId)
.run()
}
fun updateSnippet(threadId: Long, snippet: String?, attachment: Uri?, date: Long, type: Long, unarchive: Boolean) {
if (isSilentType(type)) {
return
@@ -8,8 +8,8 @@ package org.thoughtcrime.securesms.database
import kotlinx.serialization.json.Json
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.logging.Log
import org.signal.network.util.JsonUtil
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.whispersystems.signalservice.internal.util.JsonUtil
import java.io.IOException
import java.util.Optional
@@ -12,7 +12,7 @@ import org.signal.core.util.Base64;
import org.signal.core.util.SqlUtil;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.signal.network.util.Preconditions;
import java.io.IOException;
import java.util.ArrayList;
@@ -1,42 +0,0 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.megaphone.Megaphones;
public class MegaphoneRecord {
private final Megaphones.Event event;
private final int seenCount;
private final long lastSeen;
private final long firstVisible;
private final boolean finished;
public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, long firstVisible, boolean finished) {
this.event = event;
this.seenCount = seenCount;
this.lastSeen = lastSeen;
this.firstVisible = firstVisible;
this.finished = finished;
}
public @NonNull Megaphones.Event getEvent() {
return event;
}
public int getSeenCount() {
return seenCount;
}
public long getLastSeen() {
return lastSeen;
}
public long getFirstVisible() {
return firstVisible;
}
public boolean isFinished() {
return finished;
}
}
@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.megaphone.Megaphones
data class MegaphoneRecord(
val event: Megaphones.Event,
val interactionCount: Int,
val lastInteractionTime: Long,
val firstVisible: Long,
val lastVisible: Long,
val finished: Boolean
)
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.signal.network.util.Preconditions;
import java.util.Objects;
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
@@ -10,13 +10,27 @@ import androidx.annotation.VisibleForTesting;
import org.jetbrains.annotations.NotNull;
import org.signal.billing.BillingFactory;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.billing.BillingApi;
import org.signal.core.util.concurrent.DeadlockDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.network.api.ArchiveApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.signal.network.api.LinkDeviceApi;
import org.signal.network.api.PaymentsApi;
import org.signal.network.api.ProvisioningApi;
import org.signal.network.api.RateLimitChallengeApi;
import org.signal.network.api.RemoteConfigApi;
import org.signal.network.api.SvrBApi;
import org.signal.network.api.UsernameApi;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@@ -79,6 +93,7 @@ import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.FrameRateTracker;
import org.thoughtcrime.securesms.util.PreKeyBatcher;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache;
@@ -89,30 +104,18 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.signal.network.api.ArchiveApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.whispersystems.signalservice.api.donations.DonationsApi;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.keys.KeysApi;
import org.signal.network.api.LinkDeviceApi;
import org.whispersystems.signalservice.api.keys.PreKeyRepository;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.signal.network.api.PaymentsApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.signal.network.api.ProvisioningApi;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.network.api.RateLimitChallengeApi;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.signal.network.api.RemoteConfigApi;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.storage.StorageServiceApi;
import org.signal.network.api.SvrBApi;
import org.signal.network.api.UsernameApi;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
@@ -179,7 +182,15 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
RemoteConfig.maxIncrementalMacsPerEnvelope(),
RemoteConfig::useMessageSendRestFallback,
RemoteConfig.useBinaryId(),
BuildConfig.USE_STRING_ID);
BuildConfig.USE_STRING_ID,
new PreKeyRepository(
keysApi,
protocolStore.aci(),
new SignalProtocolAddress(pushServiceSocket.getCredentialsProvider().getAci().getLibSignalServiceId(),
pushServiceSocket.getCredentialsProvider().getDeviceId()),
PreKeyBatcher.INSTANCE
)
);
}
@Override
@@ -1,9 +1,10 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.core.util.logging.Log
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.KeyTransparency.CheckMode
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.getOrError
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.ServiceId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
@@ -14,9 +15,21 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
*/
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
companion object {
val TAG = Log.tag(KeyTransparencyApi::class.java)
fun reset(aci: ServiceId.Aci, field: KeyTransparency.AccountDataField, keyTransparencyStore: KeyTransparencyStore) {
try {
KeyTransparency.resetField(aci, field, keyTransparencyStore)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Unexpected result when trying to reset KT", e)
}
}
}
suspend fun check(checkMode: CheckMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
return unauthWebSocket.runCatchingWithChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().check(checkMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
}.getOrError()
}
}
}
@@ -11,7 +11,7 @@ import org.signal.glide.common.io.InputStreamFactory
import org.thoughtcrime.securesms.glide.DecryptableStreamFactory
object SignalGlideDependenciesProvider : SignalGlideDependencies.Provider {
override fun getUriInputStreamFactory(uri: Uri): InputStreamFactory {
return DecryptableStreamFactory(uri)
override fun getUriInputStreamFactory(uri: Uri, thumbnailTimeUs: Long): InputStreamFactory {
return DecryptableStreamFactory(uri, thumbnailTimeUs)
}
}
@@ -124,12 +124,14 @@ class WebRtcViewModel(state: WebRtcServiceState) {
val isAudioDeviceChangePending: Boolean = state.localDeviceState.isAudioDeviceChangePending
val localParticipant: CallParticipant = createLocal(
state.localDeviceState.cameraState,
(if (state.videoState.localSink != null) state.videoState.localSink else BroadcastVideoSink())!!,
state.localDeviceState.isMicrophoneEnabled,
state.localDeviceState.handRaisedTimestamp
cameraState = state.localDeviceState.cameraState,
renderer = state.videoState.localSink ?: BroadcastVideoSink(),
microphoneEnabled = state.localDeviceState.isMicrophoneEnabled,
handRaisedTimestamp = state.localDeviceState.handRaisedTimestamp
)
val isLocalScreenSharing: Boolean = state.localDeviceState.isScreenSharing
val remoteMutedBy: CallParticipant? = state.localDeviceState.remoteMutedBy
val isCellularConnection: Boolean = when (state.localDeviceState.networkConnectionType) {
@@ -15,7 +15,8 @@ import java.io.InputStream
* A factory that creates a new [InputStream] for the given [Uri] each time [create] is called.
*/
class DecryptableStreamFactory(
private val uri: Uri
private val uri: Uri,
private val thumbnailTimeUs: Long = 0
) : InputStreamFactory {
companion object {
private val TAG = Log.tag(DecryptableStreamFactory::class)
@@ -23,7 +24,7 @@ class DecryptableStreamFactory(
override fun create(): InputStream {
return try {
DecryptableStreamLocalUriFetcher(AppDependencies.application, uri).loadResource(uri, AppDependencies.application.contentResolver)
DecryptableStreamLocalUriFetcher(AppDependencies.application, uri, thumbnailTimeUs).loadResource(uri, AppDependencies.application.contentResolver)
} catch (e: Exception) {
Log.w(TAG, "Error creating input stream for URI.", e)
throw e
@@ -35,16 +35,19 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
private static final long TOTAL_PIXEL_SIZE_LIMIT = 200_000_000L; // 200 megapixels
private final Context context;
private final long thumbnailTimeUs;
DecryptableStreamLocalUriFetcher(Context context, Uri uri) {
DecryptableStreamLocalUriFetcher(Context context, Uri uri, long thumbnailTimeUs) {
super(context.getContentResolver(), uri);
this.context = context;
this.context = context;
this.thumbnailTimeUs = thumbnailTimeUs;
}
@Override
protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException {
if (MediaUtil.hasVideoThumbnail(context, uri)) {
Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, 1000);
long timeUs = thumbnailTimeUs > 0 ? thumbnailTimeUs : 1000;
Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, timeUs);
if (thumbnail != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();

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