mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-09 16:56:21 +01:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 955b8c382e | |||
| 6f7dce47db | |||
| 93077ac457 | |||
| c069eb1b88 | |||
| e5cd18bf1e | |||
| 9e8ae7e26a | |||
| 00042b9579 | |||
| e750b81a31 | |||
| daec317f52 | |||
| 112514c221 | |||
| f43db8ace0 | |||
| 54df95727b | |||
| 022b4d9508 | |||
| 7411e725ec | |||
| 83a279f422 | |||
| 523066d093 | |||
| de27343c24 | |||
| c36179293e | |||
| a79a91bafb | |||
| 13de1ede90 | |||
| b94f420393 | |||
| 4909f130cc | |||
| 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,9 +27,9 @@ plugins {
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1687
|
||||
val canonicalVersionName = "8.10.2"
|
||||
val currentHotfixVersion = 0
|
||||
val canonicalVersionCode = 1692
|
||||
val canonicalVersionName = "8.11.5"
|
||||
val currentHotfixVersion = 1
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
|
||||
@@ -463,8 +463,8 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
@@ -686,6 +686,7 @@ dependencies {
|
||||
implementation(libs.google.play.services.maps)
|
||||
implementation(libs.google.play.services.auth)
|
||||
implementation(libs.google.signin)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.bundles.media3)
|
||||
implementation(libs.conscrypt.android)
|
||||
implementation(libs.signal.aesgcmprovider)
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
@@ -111,6 +112,7 @@ import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -439,7 +441,24 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
}
|
||||
|
||||
private void initializeFcmCheck() {
|
||||
if (SignalStore.account().isRegistered()) {
|
||||
if (!SignalStore.account().isRegistered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
|
||||
|
||||
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
|
||||
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(true);
|
||||
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
|
||||
.then(new RefreshAttributesJob())
|
||||
.enqueue();
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
|
||||
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(false);
|
||||
SignalStore.account().setFcmToken(null);
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
} else if (SignalStore.account().isFcmEnabled()) {
|
||||
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
|
||||
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -447,6 +466,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
|
||||
public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = Log.tag(ShortcutLauncherActivity.class);
|
||||
|
||||
private static final String KEY_RECIPIENT = "recipient_id";
|
||||
|
||||
public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
|
||||
@@ -30,9 +33,18 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
|
||||
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
|
||||
RecipientId recipientId = null;
|
||||
|
||||
if (rawId == null) {
|
||||
if (rawId != null) {
|
||||
try {
|
||||
recipientId = RecipientId.from(rawId);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Failed to parse recipientId from intent.", t);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipientId == null) {
|
||||
Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
|
||||
// TODO [greyson] Navigation
|
||||
startActivity(MainActivity.clearTop(this));
|
||||
@@ -40,7 +52,7 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
|
||||
Recipient recipient = Recipient.live(recipientId).get();
|
||||
// TODO [greyson] Navigation
|
||||
TaskStackBuilder backStack = TaskStackBuilder.create(this)
|
||||
.addNextIntent(MainActivity.clearTop(this));
|
||||
|
||||
@@ -66,7 +66,11 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.ApplicationErrorAction
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.StatusCodeErrorAction
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -145,9 +149,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.ApplicationErrorAction
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
@@ -161,7 +162,6 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
|
||||
+2
-3
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
|
||||
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
|
||||
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
|
||||
bioText = selfRecord.about ?: "",
|
||||
bioEmoji = selfRecord.aboutEmoji ?: "",
|
||||
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
|
||||
bioEmoji = selfRecord.aboutEmoji ?: ""
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
|
||||
SignalStore.account.usernameLink = null
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
|
||||
SignalDatabase.recipients.clearSelfKeyTransparencyData()
|
||||
|
||||
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
|
||||
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
+2
-2
@@ -236,8 +236,8 @@ private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
when (inAppPayment.type) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.ONE_TIME_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.RECURRING_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
+20
-7
@@ -607,6 +607,8 @@ class ConversationFragment :
|
||||
private var releaseNotesLayoutApplied: Boolean = false
|
||||
private var releaseNotesWallpaperApplied: Boolean = false
|
||||
|
||||
private var applyToolbarPaddingRunnable: Runnable? = null
|
||||
|
||||
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
|
||||
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
|
||||
ScrollToPositionDelegate.JumpToPositionStrategy.performScroll(recyclerView, layoutManager, position, smooth)
|
||||
@@ -761,19 +763,30 @@ class ConversationFragment :
|
||||
split.second
|
||||
}
|
||||
|
||||
binding.conversationItemRecycler.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
binding.conversationItemRecycler.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
|
||||
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
|
||||
}
|
||||
|
||||
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
|
||||
binding.conversationItemRecycler.padding(top = bottom)
|
||||
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
|
||||
val newMargin = bottom + 16.dp
|
||||
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
|
||||
conversationHeaderPositionDecoration.toolbarMargin = newMargin
|
||||
binding.conversationItemRecycler.invalidateItemDecorations()
|
||||
// Bug: ConstraintLayout can provide a negative value for the toolbar causing RV layout problems
|
||||
if (bottom < 0) return@addOnLayoutChangeListener
|
||||
|
||||
// Bug: LinearLayoutManger can get stuck and not layout children under Compose's AndroidFragment if updated too quickly.
|
||||
val rv = binding.conversationItemRecycler
|
||||
applyToolbarPaddingRunnable?.let { rv.removeCallbacks(it) }
|
||||
val runnable = Runnable {
|
||||
if (view == null) return@Runnable
|
||||
rv.padding(top = bottom)
|
||||
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
|
||||
val newMargin = bottom + 16.dp
|
||||
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
|
||||
conversationHeaderPositionDecoration.toolbarMargin = newMargin
|
||||
rv.invalidateItemDecorations()
|
||||
}
|
||||
}
|
||||
}
|
||||
applyToolbarPaddingRunnable = runnable
|
||||
rv.post(runnable)
|
||||
}
|
||||
|
||||
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
|
||||
|
||||
+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,
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.updateAll
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.net.KeyTransparency
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
@@ -67,6 +68,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSucce
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
||||
@@ -77,6 +79,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchove
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupId.V1
|
||||
@@ -2330,6 +2333,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
.values(NEEDS_PNI_SIGNATURE to 0)
|
||||
.run()
|
||||
|
||||
Log.i(TAG, "Resetting KT data due to change number.")
|
||||
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.E164, keyTransparencyStore = KeyTransparencyStore)
|
||||
|
||||
SignalDatabase.pendingPniSignatureMessages.deleteAll()
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
@@ -2363,6 +2369,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
rotateStorageId(id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
if (id == Recipient.self().id) {
|
||||
Log.i(TAG, "Resetting KT data due to username change.")
|
||||
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.USERNAME_HASH, keyTransparencyStore = KeyTransparencyStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4099,6 +4110,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
}
|
||||
|
||||
fun clearAllKeyTransparencyData() {
|
||||
Log.i(TAG, "Clearing all key transparency data.")
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(KEY_TRANSPARENCY_DATA to null)
|
||||
@@ -4107,6 +4119,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
}
|
||||
|
||||
fun clearSelfKeyTransparencyData() {
|
||||
Log.i(TAG, "Clearing self key transparency data.")
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(KEY_TRANSPARENCY_DATA to null)
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.signal.network.util.Preconditions;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
@@ -305,6 +305,14 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
.run()
|
||||
}
|
||||
|
||||
fun updateSnippetContentTypeSilently(threadId: Long, snippetMessageId: Long, mimeType: String) {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(SNIPPET_CONTENT_TYPE to mimeType)
|
||||
.where("$ID = ? AND $SNIPPET_MESSAGE_ID = ?", threadId, snippetMessageId)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun updateSnippet(threadId: Long, snippet: String?, attachment: Uri?, date: Long, type: Long, unarchive: Boolean) {
|
||||
if (isSilentType(type)) {
|
||||
return
|
||||
|
||||
@@ -8,8 +8,8 @@ package org.thoughtcrime.securesms.database
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.util.JsonUtil
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.signal.network.util.Preconditions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
|
||||
public class MegaphoneRecord {
|
||||
|
||||
private final Megaphones.Event event;
|
||||
private final int seenCount;
|
||||
private final long lastSeen;
|
||||
private final long firstVisible;
|
||||
private final boolean finished;
|
||||
|
||||
public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, long firstVisible, boolean finished) {
|
||||
this.event = event;
|
||||
this.seenCount = seenCount;
|
||||
this.lastSeen = lastSeen;
|
||||
this.firstVisible = firstVisible;
|
||||
this.finished = finished;
|
||||
}
|
||||
|
||||
public @NonNull Megaphones.Event getEvent() {
|
||||
return event;
|
||||
}
|
||||
|
||||
public int getSeenCount() {
|
||||
return seenCount;
|
||||
}
|
||||
|
||||
public long getLastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
public long getFirstVisible() {
|
||||
return firstVisible;
|
||||
}
|
||||
|
||||
public boolean isFinished() {
|
||||
return finished;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
|
||||
data class MegaphoneRecord(
|
||||
val event: Megaphones.Event,
|
||||
val interactionCount: Int,
|
||||
val lastInteractionTime: Long,
|
||||
val firstVisible: Long,
|
||||
val lastVisible: Long,
|
||||
val finished: Boolean
|
||||
)
|
||||
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.signal.network.util.Preconditions;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.keytrans.KeyTransparencyException
|
||||
import org.signal.libsignal.net.KeyTransparency
|
||||
import org.signal.libsignal.net.KeyTransparency.CheckMode
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.getOrError
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.ServiceId
|
||||
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
|
||||
@@ -14,9 +15,21 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
*/
|
||||
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(KeyTransparencyApi::class.java)
|
||||
|
||||
fun reset(aci: ServiceId.Aci, field: KeyTransparency.AccountDataField, keyTransparencyStore: KeyTransparencyStore) {
|
||||
try {
|
||||
KeyTransparency.resetField(aci, field, keyTransparencyStore)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Unexpected result when trying to reset KT", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun check(checkMode: CheckMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
|
||||
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
|
||||
return unauthWebSocket.runCatchingWithChatConnection { chatConnection ->
|
||||
chatConnection.keyTransparencyClient().check(checkMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
|
||||
}.getOrError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user