mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-09 16:56:21 +01:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0010386b9e | |||
| 02c760945d | |||
| a0247bb8cc | |||
| bcfec5de50 | |||
| b2215915ef | |||
| a0577cd8a2 | |||
| 9438646814 | |||
| 9dcf68581d | |||
| 4dd57460de | |||
| 6339b38dee | |||
| 1ce41edc7f | |||
| 3087116618 | |||
| 8fd2065253 | |||
| 09822d3ae9 | |||
| 10b0221e98 | |||
| db4def45f9 | |||
| 7dd6829bfa | |||
| 0e40acfdaa | |||
| c2e8cec042 | |||
| 04d2b3b0fe | |||
| 64cdff4638 | |||
| 59b42ac546 | |||
| 2e9fd87b06 | |||
| 0cf7705d4f | |||
| 5bcbbdf339 | |||
| a796316ad6 | |||
| 5655fcf973 | |||
| f4bd5fbe8b | |||
| fc448ecb59 | |||
| c4e7841ea3 | |||
| e248aee25c | |||
| 7c9268e326 | |||
| 8ffc2e7ab8 | |||
| b4404bb5b4 | |||
| 155bba2f81 | |||
| 639438b863 | |||
| b374a90ffe | |||
| d333503838 | |||
| 2efc115410 | |||
| 43a1c93961 | |||
| 39529af4e9 | |||
| d1e2fc0423 | |||
| 5e4865be73 | |||
| 5903c1bbf5 | |||
| 45da9fbfc0 | |||
| 0dacc4e8dc | |||
| 74935c963a | |||
| 02d245ac0c | |||
| e100ffbc14 | |||
| d4b3328151 | |||
| 0e82a43be7 | |||
| 0960c0dfea | |||
| 8503c49db0 | |||
| 88b95ce6a5 | |||
| 6a1d06486c | |||
| 824f0af00b | |||
| 03a6d8c12f | |||
| f1b231ca38 | |||
| dca4351b8b | |||
| b2a18f7202 |
@@ -27,8 +27,8 @@ plugins {
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1687
|
||||
val canonicalVersionName = "8.10.2"
|
||||
val canonicalVersionCode = 1689
|
||||
val canonicalVersionName = "8.11.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("mAttachInfo");"
|
||||
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."
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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()));
|
||||
|
||||
+36
-36
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
}
|
||||
|
||||
-1
@@ -15,6 +15,5 @@ data class LabsSettingsState(
|
||||
val groupSuggestionsForMembers: Boolean = false,
|
||||
val betterSearch: Boolean = false,
|
||||
val autoLowerHand: Boolean = false,
|
||||
|
||||
val starredMessages: Boolean = false
|
||||
)
|
||||
|
||||
-1
@@ -57,7 +57,6 @@ class LabsSettingsViewModel : ViewModel() {
|
||||
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers,
|
||||
betterSearch = SignalStore.labs.betterSearch,
|
||||
autoLowerHand = SignalStore.labs.autoLowerHand,
|
||||
|
||||
starredMessages = SignalStore.labs.starredMessages
|
||||
)
|
||||
}
|
||||
|
||||
+20
-23
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -299,31 +298,29 @@ private fun AdvancedPrivacySettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
|
||||
+31
-7
@@ -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(
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+62
-12
@@ -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 {
|
||||
|
||||
+40
-53
@@ -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()
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+9
-2
@@ -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
|
||||
}
|
||||
|
||||
+31
-3
@@ -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()
|
||||
}
|
||||
|
||||
+23
-8
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -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 {
|
||||
|
||||
+12
-5
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
+8
-10
@@ -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
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+25
-14
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.dependencies
|
||||
import org.signal.libsignal.keytrans.KeyTransparencyException
|
||||
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
|
||||
@@ -15,8 +14,8 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+6
-3
@@ -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();
|
||||
|
||||
@@ -175,7 +175,12 @@ final class GroupManagerV2 {
|
||||
|
||||
Map<UUID, UuidCiphertext> uuidCipherTexts = new HashMap<>();
|
||||
for (Recipient recipient : recipients) {
|
||||
uuidCipherTexts.put(recipient.requireServiceId().getRawUuid(), clientZkGroupCipher.encrypt(recipient.requireServiceId().getLibSignalServiceId()));
|
||||
Optional<ServiceId> serviceId = recipient.getServiceId();
|
||||
if (serviceId.isPresent()) {
|
||||
uuidCipherTexts.put(serviceId.get().getRawUuid(), clientZkGroupCipher.encrypt(serviceId.get().getLibSignalServiceId()));
|
||||
} else {
|
||||
Log.w(TAG, "Recipient " + recipient.getId() + " has no ServiceId, skipping for group call peek");
|
||||
}
|
||||
}
|
||||
|
||||
return uuidCipherTexts;
|
||||
|
||||
+1
-1
@@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
@@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.keyvalue.UiHintValues
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
/**
|
||||
* Handles the retrieval and modification of group member labels.
|
||||
|
||||
+1
-1
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
@@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveStat
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberOrder
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
private val MEMBER_ORDER: Comparator<GroupMemberWithLabel> = GroupMemberOrder.comparator(
|
||||
isSelf = { it.recipient.isSelf },
|
||||
|
||||
@@ -15,11 +15,10 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.text.util.LinkifyCompat;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.util.LinkUtil;
|
||||
import org.thoughtcrime.securesms.util.Linkification;
|
||||
import org.thoughtcrime.securesms.util.LongClickCopySpan;
|
||||
|
||||
public final class GroupDescriptionUtil {
|
||||
@@ -38,21 +37,16 @@ public final class GroupDescriptionUtil {
|
||||
SpannableString descriptionSpannable = new SpannableString(scrubbedDescription);
|
||||
|
||||
if (linkify) {
|
||||
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
|
||||
boolean hasLinks = LinkifyCompat.addLinks(descriptionSpannable, linkPattern);
|
||||
LinkifyCompat.addLinks(descriptionSpannable, Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS);
|
||||
Linkification.applyWebUrlSpans(descriptionSpannable);
|
||||
|
||||
if (hasLinks) {
|
||||
Stream.of(descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class))
|
||||
.filter(url -> !LinkUtil.isLegalUrl(url.getURL()))
|
||||
.forEach(descriptionSpannable::removeSpan);
|
||||
|
||||
URLSpan[] urlSpans = descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class);
|
||||
|
||||
for (URLSpan urlSpan : urlSpans) {
|
||||
int start = descriptionSpannable.getSpanStart(urlSpan);
|
||||
int end = descriptionSpannable.getSpanEnd(urlSpan);
|
||||
URLSpan span = new LongClickCopySpan(urlSpan.getURL());
|
||||
descriptionSpannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
for (URLSpan urlSpan : descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class)) {
|
||||
String url = urlSpan.getURL();
|
||||
int start = descriptionSpannable.getSpanStart(urlSpan);
|
||||
int end = descriptionSpannable.getSpanEnd(urlSpan);
|
||||
descriptionSpannable.removeSpan(urlSpan);
|
||||
if (LinkUtil.isLegalUrl(url)) {
|
||||
descriptionSpannable.setSpan(new LongClickCopySpan(url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,6 +16,7 @@ import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
|
||||
|
||||
+1
-1
@@ -21,6 +21,7 @@ import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.forEach
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
@@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.wallpaper.WallpaperStorage
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
/**
|
||||
* Reserves backupIds for both text+media. The intention is that every registered user should be doing this, so it should happen post-registration
|
||||
|
||||
+1
-1
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.jobs
|
||||
import org.signal.core.models.backup.MediaId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable
|
||||
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import java.lang.RuntimeException
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.jobs
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.util.ImageCompressionUtil
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.InvalidMacException
|
||||
import org.signal.libsignal.protocol.InvalidMessageException
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.signal.network.exceptions.PushNetworkException
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -44,8 +46,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RangeException
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.File
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.I
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -21,7 +22,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.signal.core.util.logging.logW
|
||||
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import org.signal.libsignal.net.SvrBStoreResponse
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -52,7 +53,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
@@ -7,12 +7,12 @@ package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigResult
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.exceptions.PushNetworkException
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint
|
||||
import org.thoughtcrime.securesms.jobs.protos.CallLinkUpdateSendJobData
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage.CallLinkUpdate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.network.exceptions.PushNetworkException
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint
|
||||
import org.thoughtcrime.securesms.jobs.protos.CallLogEventSendJobData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user