Compare commits

..

2 Commits

Author SHA1 Message Date
Michelle Tang 4bba533649 Bump version to 8.17.4 2026-07-02 14:24:17 -04:00
Greyson Parrelli a45cf2d33f Fix long-text attachments failing media constraint check when sending long messages. 2026-07-02 14:01:03 -04:00
442 changed files with 18524 additions and 13833 deletions
+3 -3
View File
@@ -22,12 +22,12 @@ jobs:
submodules: true
lfs: true
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
- name: set up JDK 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
+4 -4
View File
@@ -22,12 +22,12 @@ jobs:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
- name: set up JDK 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
@@ -47,7 +47,7 @@ jobs:
- name: Cache base apk
id: cache-base
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
+3 -9
View File
@@ -13,15 +13,9 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: reproducible-builds
tags: signal-android
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
run: |
cd reproducible-builds
docker build -t signal-android .
- name: Test build
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
+1 -1
View File
@@ -1,2 +1,2 @@
java openjdk-21.0.2
java openjdk-17.0.2
uv latest
+3 -4
View File
@@ -28,9 +28,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 = 1716
val canonicalVersionName = "8.18.2"
val currentHotfixVersion = 0
val canonicalVersionCode = 1713
val canonicalVersionName = "8.17.4"
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
@@ -670,7 +670,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
@@ -1,76 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
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
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__OneTimeDonations {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
startJobLoopForTests()
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
@@ -1,11 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isSelected
@@ -14,20 +12,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -55,24 +46,8 @@ class CheckoutFlowActivityTest__RecurringDonations {
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
@Before
fun setUp() {
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
@@ -127,43 +102,6 @@ class CheckoutFlowActivityTest__RecurringDonations {
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
}
@Test
fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
@@ -1,159 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.gms.wallet.PaymentData
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.rest.RestStatusCodeError
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
*/
class GooglePayTestRule : ExternalResource() {
override fun before() {
val paymentData = mockk<PaymentData> {
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
}
mockkConstructor(GooglePayApi::class)
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
}
}
override fun after() {
unmockkConstructor(GooglePayApi::class)
}
}
/**
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
*/
private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*/
fun startJobLoopForTests() {
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
/**
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
val deadline = SystemClock.uptimeMillis() + 10_000
var present = false
while (SystemClock.uptimeMillis() < deadline && !present) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
present = try {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
} catch (e: IllegalStateException) {
false
}
if (!present) {
Thread.sleep(100)
}
}
check(present) { "Google Pay button never appeared in the gateway selector." }
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
pump(scheduler, iterations = 20)
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
GooglePayComponent.GooglePayResult(
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
resultCode = Activity.RESULT_OK,
data = Intent()
)
)
}
}
/**
* Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is
* displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering.
*/
fun awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) {
val deadline = SystemClock.uptimeMillis() + timeoutMillis
var lastFailure: Throwable? = null
while (SystemClock.uptimeMillis() < deadline) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
return
} catch (t: Throwable) {
lastFailure = t
}
Thread.sleep(100)
}
throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure)
}
private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) {
repeat(iterations) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(intervalMs)
}
}
@@ -10,14 +10,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@@ -982,80 +977,6 @@ class CallTableTest {
// assertEquals(0, allCallEvents.size)
}
@Test
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
val callId = 1L
val peer = harness.others[0]
val timestamp = System.currentTimeMillis()
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
val call = SignalDatabase.calls.getCallById(callId, peer)
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertNotEquals(0L, message.expireStarted)
}
private fun insertTwoCallEvents() {
SignalDatabase.calls.insertAcceptedGroupCall(
1,
@@ -1071,10 +992,4 @@ class CallTableTest {
2000
)
}
private fun insertExpiringThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
SignalDatabase.threads.update(threadId, false)
}
}
@@ -88,7 +88,6 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription()
)
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
@@ -236,9 +236,8 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
}
@Test
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
@@ -248,39 +247,10 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = timestamp
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun reappliesWhenServerTimestampIsNewer() {
sendPniChangeNumber()
val secondPniUuid = UUID.randomUUID()
val secondPni = ServiceId.PNI.from(secondPniUuid)
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
val secondIdentity = IdentityKeyPair.generate()
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
val secondE164 = "+15555550100"
val secondRegistrationId = 7777
sendPniChangeNumber(
identityKeyPair = secondIdentity.serialize().toByteString(),
signedPreKey = secondSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey = null,
registrationId = secondRegistrationId,
e164 = secondE164,
envelopePniBinary = secondPniBytes,
timestamp = messageHelper.nextStartTime() + 1000
)
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
assertOriginalStatePreserved(afterFirstApply)
}
@Test
@@ -46,8 +46,7 @@ annotation class RawFlag(val key: String, val value: String)
*/
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
INTERNAL_USER(RemoteConfig::internalUser),
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff);
val key: String
get() {
+6 -1
View File
@@ -438,7 +438,12 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".calls.links.details.CallLinkDetailsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".calls.new.NewCallActivity"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -52,7 +52,6 @@ import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
@@ -119,16 +118,14 @@ import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.signal.core.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -436,15 +433,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
},
context -> {
context.startActivity(AppSettingsActivity.proxy(context));
return Unit.INSTANCE;
},
(context, subject) -> {
String body = SupportEmailUtil.generateSupportEmailBody(context, subject, null, null);
CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body);
return Unit.INSTANCE;
}
)
);
@@ -9,7 +9,6 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
@@ -24,7 +23,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
@@ -59,13 +57,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
@@ -75,9 +70,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -108,7 +101,6 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.callsNavEntries
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@@ -165,6 +157,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
@@ -190,6 +183,7 @@ import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.TopToastPopup
@@ -202,7 +196,6 @@ import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
PassphraseRequiredActivity(),
@@ -294,6 +287,14 @@ class MainActivity :
AppStartup.getInstance().onCriticalRenderEventStart()
enableEdgeToEdge(
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
SystemBarStyle.dark(0)
} else {
SystemBarStyle.light(0, 0)
}
)
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
@@ -506,6 +507,15 @@ class MainActivity :
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val callsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.CALLS }
) {
callNavGraphBuilder(it)
}
val storiesNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
@@ -524,22 +534,17 @@ class MainActivity :
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> {
throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.")
}
MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location)
}
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> {
throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -734,17 +739,9 @@ class MainActivity :
}
MainNavigationListLocation.CALLS -> {
NavDisplay(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
)
}
@@ -834,23 +831,6 @@ class MainActivity :
SignalTheme.colors.colorSurface1
}
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val navBarColor = if (isSplitPane) backgroundColor.toArgb() else ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface2)
LaunchedEffect(isDarkTheme, navBarColor) {
if (Build.VERSION.SDK_INT >= 26) {
enableEdgeToEdge(
navigationBarStyle = if (isDarkTheme) {
SystemBarStyle.dark(navBarColor)
} else {
SystemBarStyle.light(navBarColor, navBarColor)
}
)
} else {
enableEdgeToEdge()
}
}
val modifier = when {
isSplitPane -> {
Modifier
@@ -1191,7 +1171,7 @@ class MainActivity :
} else if (SignalStore.internal.useNewMediaActivity) {
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = true,
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
)
)
@@ -222,7 +222,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -1250,7 +1250,6 @@ object BackupRepository {
return ImportResult.Failure
}
SignalStore.backup.hasInvalidBackupVersion = false
val selfId: RecipientId
var transactionSuccessful = false
try {
@@ -1322,7 +1321,7 @@ object BackupRepository {
SignalStore.backup.mediaRootBackupKey = mediaRootBackupKey
// Add back self after clearing data
selfId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
@@ -1533,7 +1532,7 @@ object BackupRepository {
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
stopwatch.stop(TAG)
return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId)
return ImportResult.Success(backupTime = header.backupTimeMs)
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
@@ -2311,23 +2310,19 @@ object BackupRepository {
forwardSecrecyToken = forwardSecrecyToken,
cancellationSignal = cancellationSignal
)
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[remoteRestore] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[remoteRestore] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
if (result == ImportResult.Failure) {
Log.w(TAG, "[remoteRestore] Failed to import backup")
return RemoteRestoreResult.Failure
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[remoteRestore] Restore successful")
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2359,16 +2354,9 @@ object BackupRepository {
override fun shouldCancel() = cancellationSignal()
}
val cdn = response.cdn
val key = response.key
if (cdn == null || key == null) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
return RemoteRestoreResult.Failure
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -2397,19 +2385,16 @@ object BackupRepository {
cancellationSignal = cancellationSignal
)
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
if (result == ImportResult.Failure) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
return RemoteRestoreResult.Failure
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
@@ -2539,12 +2524,12 @@ data class StagedBackupKeyRotations(
)
sealed class ImportResult {
data class Success(val backupTime: Long, val selfRecipientId: RecipientId) : ImportResult()
data class Success(val backupTime: Long) : ImportResult()
data object Failure : ImportResult()
}
sealed interface RemoteRestoreResult {
data class Success(val selfRecipientId: RecipientId) : RemoteRestoreResult
data object Success : RemoteRestoreResult
data object NetworkError : RemoteRestoreResult
data object Canceled : RemoteRestoreResult
data object Failure : RemoteRestoreResult
@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -52,11 +51,6 @@ private fun Banner(contentPadding: PaddingValues) {
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
if (SignalStore.misc.isOldDeviceTransferLocked) {
SignalStore.misc.isOldDeviceTransferLocked = false
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
}
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
context.startActivity(registrationIntent)
}
@@ -1,64 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the calls screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "calls_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeCallId: CallLogRow.Id?
get() = entries.asReversed().firstNotNullOfOrNull { location ->
when (location) {
is MainNavigationDetailLocation.Calls -> location.controllerKey
is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when {
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
location.isContentRoot -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -1,64 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
fun EntryProviderScope<NavKey>.callsNavEntries(isSplitPane: Boolean) {
entry<MainNavigationDetailLocation.Empty> {
NoCallSelectedEntry()
}
entry<MainNavigationDetailLocation.CallLinkDetails>(
metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap()
) { route ->
CallLinkDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName> { route ->
EditCallLinkNameEntry(route)
}
}
@Composable
private fun NoCallSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) {
informNavigatorWeAreReady()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
@Composable
private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) {
informNavigatorWeAreReady()
EditCallLinkNameScreen(
roomId = route.callLinkRoomId,
initialName = route.currentName
)
}
@Composable
private fun informNavigatorWeAreReady() {
val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider
LaunchedEffect(navigator) {
navigator?.onFirstRender()
}
}
@@ -89,8 +89,7 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId,
initialName: String
roomId: CallLinkRoomId
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
@@ -100,7 +99,7 @@ fun EditCallLinkNameScreen(
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = initialName,
initialNameValue = viewModel.nameSnapshot,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
@@ -0,0 +1,71 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
class CallLinkDetailsActivity : FragmentActivity() {
companion object {
private const val ARG_ROOM_ID = "room.id"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(ARG_ROOM_ID, callLinkRoomId)
}
}
private val roomId: CallLinkRoomId
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
private val viewModel: CallLinkDetailsViewModel by viewModel {
CallLinkDetailsViewModel(roomId)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
CallLinkDetailsScreen(
roomId = roomId,
viewModel = viewModel,
router = remember { Router() }
)
}
}
}
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
}
}
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -113,12 +113,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goToCallDetail(
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
)
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -193,4 +194,10 @@ class CallLinkDetailsViewModel(
private fun toastCouldNotDeleteCallLink() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}
@@ -9,13 +9,13 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.signal.core.ui.R as CoreUiR
@@ -122,10 +122,11 @@ class CallLogContextMenu(
iconRes = CoreUiR.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
when {
peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId())
else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds))
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
}
fragment.startActivity(intent)
}
}
@@ -153,7 +154,6 @@ class CallLogContextMenu(
interface Callbacks {
fun startSelection(call: CallLogRow)
fun goToCallLinkDetails(roomId: CallLinkRoomId)
fun deleteCall(call: CallLogRow)
}
}
@@ -32,6 +32,7 @@ import org.signal.core.util.orNull
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -57,7 +58,6 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
@@ -242,11 +242,9 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MainToolbarViewModel.Event.Search.Close -> {
viewModel.setSearchQuery("")
}
MainToolbarViewModel.Event.Search.Open -> {
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
}
is MainToolbarViewModel.Event.Search.Query -> {
viewModel.setSearchQuery(it.query.trim())
}
@@ -327,7 +325,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
startActivity(intent)
} else {
goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId())
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
}
}
@@ -377,7 +375,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
}
}
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
@@ -389,10 +386,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
viewModel.toggleSelected(call.id)
}
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId))
}
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
@@ -11,7 +11,6 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -19,7 +18,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
companion object {
private const val KEY = "chats_back_stack"
@@ -30,7 +29,7 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
@@ -46,10 +45,13 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
}
}
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
@@ -61,4 +63,23 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
else -> entries.add(location)
}
}
/**
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
*/
fun pop(): Boolean {
if (entries.size <= 1) return false
entries.removeAt(entries.lastIndex)
return true
}
/**
* Resets the stack to its base empty state.
*/
fun reset() {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
if (entries.isEmpty()) {
entries.add(MainNavigationDetailLocation.Empty)
}
}
}
@@ -274,17 +274,8 @@ class DividerPreferenceViewHolder(itemView: View) : MappingViewHolder<DividerPre
class SectionHeaderPreferenceViewHolder(itemView: View) : MappingViewHolder<SectionHeaderPreference>(itemView) {
private val sectionHeader: TextView = itemView.findViewById(R.id.section_header)
private val iconEndView: ImageView = itemView.findViewById(R.id.icon_end)
override fun bind(model: SectionHeaderPreference) {
sectionHeader.text = model.title.resolve(context)
val iconEnd = model.iconEnd?.resolve(context)
iconEndView.setImageDrawable(iconEnd)
iconEndView.visible = iconEnd != null
if (model.onClick != null) {
iconEndView.setOnClickListener { model.onClick() }
}
}
}
@@ -128,14 +128,11 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp.milliseconds)
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp.milliseconds)
when (val outcome = state.changeNumberOutcome) {
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
is ChangeNumberOutcome.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
} else {
presentGenericError(outcome.result)
}
presentGenericError(outcome.result)
}
null -> Unit
@@ -161,8 +158,6 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
when (result) {
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> presentRateLimitedDialog(retryAfterSeconds = (result.nextSmsTimestamp - System.currentTimeMillis().milliseconds).inWholeSeconds.coerceAtLeast(0))
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -113,7 +113,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
}
private fun onStateUpdate(state: ChangeNumberState) {
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
handleSuccessfulPinEntry(state.enteredPin)
}
@@ -188,7 +188,8 @@ class ChangeNumberRepository(
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
@@ -196,6 +197,9 @@ class ChangeNumberRepository(
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
@@ -322,10 +326,6 @@ class ChangeNumberRepository(
}
}
if (result !is NetworkResult.Success) {
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
}
if (result is NetworkResult.StatusCodeError) {
SignalStore.misc.unlockChangeNumber()
}
@@ -336,39 +336,13 @@ class ChangeNumberRepository(
NumberChangeResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number
)
}
)
}
/**
* The server can commit a number change but still return an error. Before trusting a non-success result, check
* with the server and see if it already reports [newE164] as our number. If it does, then the change actually
* went through, so treat it as a success instead of surfacing the error.
*/
private suspend fun verifyChangeAppliedDespiteError(newE164: String, originalResult: NetworkResult<VerifyAccountResponse>): NetworkResult<VerifyAccountResponse> {
return try {
val whoAmI = withContext(Dispatchers.IO) { whoAmI() }
if (whoAmI.number == newE164 && whoAmI.pni != null) {
Log.w(TAG, "Change number request did not succeed, but whoami reports the new number is already active. Treating the change as successful.")
NetworkResult.Success(
VerifyAccountResponse().apply {
uuid = whoAmI.aci
pni = whoAmI.pni
number = whoAmI.number
}
)
} else {
Log.i(TAG, "Change number request did not succeed and whoami does not report the new number; treating as a genuine failure.")
originalResult
}
} catch (e: IOException) {
Log.w(TAG, "Failed to query whoami while verifying change number outcome; treating as a genuine failure.", e)
originalResult
}
}
@WorkerThread
private fun createChangeNumberRequest(
sessionId: String? = null,
@@ -459,6 +433,7 @@ class ChangeNumberRepository(
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}
@@ -43,7 +43,8 @@ data class ChangeNumberState(
)
sealed interface ChangeNumberOutcome {
data object Succeeded : ChangeNumberOutcome
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
}
@@ -89,8 +89,8 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) {
Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}")
when (changeNumberOutcome) {
is ChangeNumberOutcome.Succeeded -> {
Log.i(TAG, "Successfully changed number.")
is ChangeNumberOutcome.RecoveryPasswordWorked -> {
Log.i(TAG, "Successfully changed number with recovery password.")
changeNumberSuccess()
}
@@ -109,27 +109,11 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
if (castResult.willBeAbleToRequestAgain) {
Log.i(TAG, "Verification code request rate limited; proceeding to code entry screen so the user can wait/resend rather than bailing.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
} else {
Log.i(TAG, "Verification code request rate limited with no pending resend; showing rate limit error.")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
}
is VerificationCodeRequestResult.RateLimited,
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> {
is VerificationCodeRequestResult.RateLimited -> {
Log.i(TAG, "Unable to request sms code due to rate limit")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Destination number is registration locked; navigating to PIN entry.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberRegistrationLock(castResult.timeRemaining))
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
@@ -141,6 +125,11 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
is ChangeNumberOutcome.VerificationCodeWorked -> {
Log.i(TAG, "Successfully changed number with verification code.")
changeNumberSuccess()
}
}
}
@@ -12,12 +12,14 @@ import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -34,7 +36,6 @@ import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
/**
* [ViewModel] for the change number flow.
@@ -49,13 +50,12 @@ class ChangeNumberViewModel : ViewModel() {
private val repository = ChangeNumberRepository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
private val initialLocalNumber = SignalStore.account.e164
private val password = SignalStore.account.servicePassword!!
private val changeNumberSessionInFlight = AtomicBoolean(false)
val uiState = store.asLiveData()
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
val liveNewNumberState = store.map { it.number }.asLiveData()
@@ -261,24 +261,14 @@ class ChangeNumberViewModel : ViewModel() {
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val sessionId = session.sessionId
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
if (!session.verified) {
if (store.value.enteredCode == null) {
Log.w(TAG, "Session is not verified and no code is available to submit; cannot complete change number.")
handleVerificationError(VerificationCodeRequestResult.UnknownError(IllegalStateException("No verification code available for an unverified session")), verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to missing code for unverified session.") }
}
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
val registrationData = getRegistrationData(context)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
val result: ChangeNumberResult = if (pin == null) {
@@ -296,7 +286,7 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
@@ -369,26 +359,18 @@ class ChangeNumberViewModel : ViewModel() {
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.E164VerificationMode) {
Log.v(TAG, "changeNumber()")
if (!changeNumberSessionInFlight.compareAndSet(false, true)) {
Log.i(TAG, "A change number session is already in progress; ignoring duplicate request.")
return
}
store.update { it.copy(inProgress = true) }
viewModelScope.launch(Dispatchers.IO) {
try {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
}
} finally {
changeNumberSessionInFlight.set(false)
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
}
}
}
@@ -435,7 +417,7 @@ class ChangeNumberViewModel : ViewModel() {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
} else if (result is ChangeNumberResult.UnknownError) {
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
@@ -530,12 +512,6 @@ class ChangeNumberViewModel : ViewModel() {
return
}
if (validSession.verified) {
Log.i(TAG, "Existing session is already verified; completing the change with the verified session instead of requesting a new code.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
val result = if (!validSession.allowedToRequestCode) {
val challenges = validSession.challengesRequested.joinToString()
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
@@ -556,72 +532,14 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is VerificationCodeRequestResult.AlreadyVerified) {
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
resetLocalSessionState()
}
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
val (nextSmsTimestamp, nextCallTimestamp) = when (result) {
is VerificationCodeRequestResult.Success -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
else -> store.value.nextSmsTimestamp to store.value.nextCallTimestamp
}
store.update {
it.copy(
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result),
challengesRequested = challengesRequested,
inProgress = false,
nextSmsTimestamp = nextSmsTimestamp,
nextCallTimestamp = nextCallTimestamp
)
}
}
/**
* Completes a number change using a registration session that the service already considers verified.
*/
private suspend fun changeNumberWithVerifiedSession(sessionId: String) {
Log.v(TAG, "changeNumberWithVerifiedSession()")
when (val result = repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)) {
is ChangeNumberResult.Success -> {
handleSuccessfulChangedRemoteNumber(
e164 = result.numberChangeResult.number,
pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni),
changeNumberOutcome = ChangeNumberOutcome.Succeeded
)
}
is ChangeNumberResult.RegistrationLocked -> {
if (result.svr2Credentials != null) {
Log.i(TAG, "Destination number is registration locked; prompting for PIN.")
store.update {
it.copy(
inProgress = false,
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials,
lockedTimeRemaining = result.timeRemaining,
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(
VerificationCodeRequestResult.RegistrationLocked(result.getCause(), result.timeRemaining, result.svr2Credentials, result.svr3Credentials)
)
)
}
} else {
Log.w(TAG, "Destination number is registration locked but SVR credentials were missing, cannot prompt for PIN.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
else -> {
Log.w(TAG, "Unable to complete change number with verified session.", result.getCause())
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
}
}
@@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.PlayServicesUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -336,7 +336,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
viewModelScope.launch {
when (val result = BackupRepository.restoreRemoteBackup()) {
is RemoteRestoreResult.Success -> {
RemoteRestoreResult.Success -> {
_state.value = _state.value.copy(statusMessage = "Import complete!")
ThreadUtil.runOnMain { afterDbRestoreCallback() }
}
@@ -10,7 +10,6 @@ sealed interface LabsSettingsEvents {
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
data class ToggleIncognito(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
data class ToggleStickerReplies(val enabled: Boolean) : LabsSettingsEvents
data class ToggleMuteBreakthroughNotifications(val enabled: Boolean) : LabsSettingsEvents
}
@@ -125,6 +125,15 @@ private fun LabsSettingsContent(
)
}
item {
Rows.ToggleRow(
checked = state.autoLowerHand,
text = "Auto Lower Hand Suggestion",
label = "Show a prompt to lower your raised hand when you are speaking in a group call.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleAutoLowerHand(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.starredMessages,
@@ -133,24 +142,6 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStarredMessages(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.stickerReplies,
text = "Sticker Replies",
label = "Keep a pending quote when sending a sticker so the sticker is sent as a reply. Normally the quote is dropped when you send a sticker.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStickerReplies(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.muteBreakthroughNotifications,
text = "Improved Notification Management",
label = "Adds per-conversation controls to let calls and replies break through mute. New options in the sounds & notifications settings for a chat.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleMuteBreakthroughNotifications(it)) }
)
}
}
}
}
@@ -13,7 +13,6 @@ data class LabsSettingsState(
val storyArchive: Boolean = false,
val incognito: Boolean = false,
val betterSearch: Boolean = false,
val starredMessages: Boolean = false,
val stickerReplies: Boolean = false,
val muteBreakthroughNotifications: Boolean = false
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
)
@@ -33,18 +33,15 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.betterSearch = event.enabled
_state.value = _state.value.copy(betterSearch = event.enabled)
}
is LabsSettingsEvents.ToggleAutoLowerHand -> {
SignalStore.labs.autoLowerHand = event.enabled
_state.value = _state.value.copy(autoLowerHand = event.enabled)
}
is LabsSettingsEvents.ToggleStarredMessages -> {
SignalStore.labs.starredMessages = event.enabled
_state.value = _state.value.copy(starredMessages = event.enabled)
}
is LabsSettingsEvents.ToggleStickerReplies -> {
SignalStore.labs.stickerReplies = event.enabled
_state.value = _state.value.copy(stickerReplies = event.enabled)
}
is LabsSettingsEvents.ToggleMuteBreakthroughNotifications -> {
SignalStore.labs.muteBreakthroughNotifications = event.enabled
_state.value = _state.value.copy(muteBreakthroughNotifications = event.enabled)
}
}
}
@@ -54,9 +51,8 @@ class LabsSettingsViewModel : ViewModel() {
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito,
betterSearch = SignalStore.labs.betterSearch,
starredMessages = SignalStore.labs.starredMessages,
stickerReplies = SignalStore.labs.stickerReplies,
muteBreakthroughNotifications = SignalStore.labs.muteBreakthroughNotifications
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
)
}
}
@@ -45,6 +45,7 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -300,29 +301,31 @@ private fun AdvancedPrivacySettingsScreen(
)
}
item {
Dividers.Default()
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
if (RemoteConfig.internalUser) {
item {
Dividers.Default()
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
}
}
}
@@ -21,12 +21,10 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
@@ -55,27 +53,11 @@ class InAppPaymentsBottomSheetDelegate(
private val badgeRepository = TerminalDonationRepository()
/**
* Fires while resumed whenever an in-app payment changes, so terminal payments (e.g. a redirect/iDEAL
* payment that finishes redeeming after we've already resumed) surface their sheet live rather than
* waiting for the next resume.
*/
private val inAppPaymentObserver = InAppPaymentObserver {
handleInAppPaymentDonationSheets()
handleInAppPaymentBackupsSheets()
}
override fun onResume(owner: LifecycleOwner) {
handleLegacyTerminalDonationSheets()
handleLegacyVerifiedMonthlyDonationSheets()
handleInAppPaymentDonationSheets()
handleInAppPaymentBackupsSheets()
AppDependencies.databaseObserver.registerInAppPaymentObserver(inAppPaymentObserver)
}
override fun onPause(owner: LifecycleOwner) {
AppDependencies.databaseObserver.unregisterObserver(inAppPaymentObserver)
}
/**
@@ -7,9 +7,14 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.util.dp
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
@@ -24,6 +29,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
@@ -84,6 +90,14 @@ class ManageDonationsFragment :
if (savedInstanceState == null && args.directToCheckoutType != InAppPaymentType.UNKNOWN) {
launcher.launch(args.directToCheckoutType)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.displayThanksBottomSheetPulse.collectLatest {
ThanksForYourSupportBottomSheetDialogFragment.create(it).show(parentFragmentManager, ThanksForYourSupportBottomSheetDialogFragment.SHEET_TAG)
}
}
}
}
override fun onResume() {
@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
object ManageDonationsRepository {
/**
* Emits any time we see a successfully completed IDEAL payment that we've not notified the user about.
*/
fun consumeSuccessfulIdealPayments(): Flow<InAppPaymentTable.InAppPayment> {
return callbackFlow {
val observer = InAppPaymentObserver {
if (it.state == InAppPaymentTable.State.END &&
it.data.error == null &&
it.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL &&
!it.notified
) {
trySendBlocking(it)
SignalDatabase.inAppPayments.update(
it.copy(notified = true)
)
}
}
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
awaitClose { AppDependencies.databaseObserver.unregisterObserver(observer) }
}
}
}
@@ -10,10 +10,15 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -35,6 +40,9 @@ class ManageDonationsViewModel : ViewModel() {
private val networkDisposable: Disposable
val state: LiveData<ManageDonationsState> = store.stateLiveData
private val internalDisplayThanksBottomSheetPulse = MutableSharedFlow<Badge>()
val displayThanksBottomSheetPulse: SharedFlow<Badge> = internalDisplayThanksBottomSheetPulse
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
@@ -50,6 +58,13 @@ class ManageDonationsViewModel : ViewModel() {
}
}
viewModelScope.launch {
ManageDonationsRepository.consumeSuccessfulIdealPayments()
.collectLatest {
internalDisplayThanksBottomSheetPulse.emit(Badges.fromDatabaseBadge(it.data.badge!!))
}
}
viewModelScope.launch(Dispatchers.IO) {
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.asFlow()
@@ -830,19 +830,7 @@ class ConversationSettingsFragment :
} else {
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
}
if (RemoteConfig.internalUser) {
sectionHeaderPref(
title = DSLSettingsText.from(memberHeaderText),
iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24),
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId)
navController.safeNavigate(action)
}
)
} else {
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
}
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
}
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
@@ -137,21 +137,13 @@ class DSLConfiguration {
children.add(preference)
}
fun sectionHeaderPref(
title: DSLSettingsText,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(title, iconEnd, onClick)
fun sectionHeaderPref(title: DSLSettingsText) {
val preference = SectionHeaderPreference(title)
children.add(preference)
}
fun sectionHeaderPref(
title: Int,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title), iconEnd, onClick)
fun sectionHeaderPref(title: Int) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title))
children.add(preference)
}
@@ -373,8 +365,4 @@ class ExternalLinkPreference(
@StringRes val linkId: Int
) : PreferenceModel<ExternalLinkPreference>()
class SectionHeaderPreference(
override val title: DSLSettingsText,
override val iconEnd: DSLSettingsIcon? = null,
val onClick: (() -> Unit)? = null
) : PreferenceModel<SectionHeaderPreference>()
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()
@@ -5,14 +5,12 @@ import android.database.CursorWrapper;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.CursorUtil;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.util.SignalE164Util;
import org.signal.core.util.Util;
@@ -120,15 +118,9 @@ public class ContactRepository {
}
@WorkerThread
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query, @Nullable GroupId groupId) {
Cursor cursor;
if (groupId != null) {
cursor = recipientTable.queryGroupMemberContactsForGroup(groupId, query);
} else if (TextUtils.isEmpty(query)) {
cursor = recipientTable.getGroupMemberContacts();
} else {
cursor = recipientTable.queryGroupMemberContacts(query);
}
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getGroupMemberContacts()
: recipientTable.queryGroupMemberContacts(query);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
@@ -138,7 +138,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
fastScrollerState = fastScrollerState,
lazyListState = lazyListState,
modifier = modifier,
modifier = modifier.fillMaxSize(),
letterContent = {
Emojifier(text = it.toString()) { annotatedText, inlineContent ->
Text(
@@ -154,7 +154,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
controller = mappingCtrl,
lazyListState = it,
modifier = modifier
modifier = Modifier.fillMaxSize()
)
}
}
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.search.SearchFilter
/**
@@ -132,10 +131,7 @@ class ContactSearchConfiguration private constructor(
*/
data class GroupMembers(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false,
val showGroupsInCommon: Boolean = true,
val groupId: GroupId? = null
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUP_MEMBERS)
/**
@@ -26,10 +26,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
@@ -227,17 +225,6 @@ object ContactSearchModels {
R.layout.contact_search_chat_type_item
).createViewHolder(FrameLayout(ctx))
}
entry<EmptyModel>(
key = { "EmptyModel" }
) { model ->
Text(
text = stringResource(R.string.SearchFragment_no_results, model.empty.query ?: ""),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
}.build()
}
@@ -579,11 +566,9 @@ object ContactSearchModels {
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS && displayOptions.displaySecondaryInformation != ContactSearchAdapter.DisplaySecondaryInformation.NEVER) {
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
number.visible = true
} else if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.visible = false
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
@@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient
import org.thoughtcrime.securesms.groups.GroupsInCommonSummary
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.recipients.Recipient
@@ -136,7 +135,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.")
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSizeAndClose(section, query, null)
@@ -297,8 +296,8 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section))
}
private fun getGroupMembersSearchIterator(section: ContactSearchConfiguration.Section.GroupMembers, query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(section, query))
private fun getGroupMembersSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(query))
}
private fun <R> readContactData(
@@ -446,7 +445,7 @@ class ContactSearchPagedDataSource(
@WorkerThread
private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getGroupMembersSearchIterator(section, query).use { records ->
return getGroupMembersSearchIterator(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
@@ -455,9 +454,8 @@ class ContactSearchPagedDataSource(
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(it)
val groupsInCommon = if (section.showGroupsInCommon) contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient) else GroupsInCommonSummary(listOf())
val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it) else null
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter)
val groupsInCommon = contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient)
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon)
}
)
}
@@ -43,8 +43,8 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.querySignalContacts(contactsSearchQuery)
}
open fun queryGroupMemberContacts(section: ContactSearchConfiguration.Section.GroupMembers, query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "", section.groupId)
open fun queryGroupMemberContacts(query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "")
}
open fun getGroupSearchIterator(
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
@@ -159,7 +158,7 @@ class ContactSearchView : AbstractComposeView {
longClickCallbacks = currentLongClickCallbacks ?: rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks = currentStoryContextMenuCallbacks ?: rememberDefaultContactSearchItemStoryContextMenuCallbacks(vm),
callButtonClickCallbacks = currentCallButtonClickCallbacks ?: rememberDefaultContactSearchItemCallButtonClickCallbacks(),
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()).fillMaxSize()
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
)
}
}
@@ -725,17 +725,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
if (hasSticker(messageRecord) && hasQuote(messageRecord) && stickerStub.resolved()) {
int stickerWidth = stickerStub.get().getMeasuredWidth();
if (stickerWidth > 0 && bodyBubble.getMeasuredWidth() > stickerWidth) {
bodyBubble.getLayoutParams().width = stickerWidth;
updatingFooter = false;
lastFooterDecisionLineWidth = -1;
lastFooterWasCollapsed = false;
needsMeasure = true;
}
}
if (needsMeasure) {
if (measureCalls < MAX_MEASURE_CALLS) {
measureCalls++;
@@ -1113,7 +1102,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean hasNoBubble(MessageRecord messageRecord) {
return MessageRecordUtil.hasNoBubble(messageRecord, context) && !(hasSticker(messageRecord) && hasQuote(messageRecord));
return MessageRecordUtil.hasNoBubble(messageRecord, context);
}
private boolean hasOnlyThumbnail(MessageRecord messageRecord) {
@@ -1481,9 +1470,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
} else if ((hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) || isBorderless(messageRecord)) {
if (hasNoBubble(messageRecord)) {
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
}
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
stickerStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
@@ -253,7 +253,6 @@ public final class MenuState {
hasText &&
!multiSelectRecord.getConversationMessage().getOriginalMessage().isFailed() &&
!hasPoll &&
!MessageRecordUtil.hasUndownloadedTextSlide(multiSelectRecord.getConversationMessage().getOriginalMessage()) &&
MessageConstraintsUtil.isValidEditMessageSend(multiSelectRecord.getConversationMessage().getOriginalMessage(), System.currentTimeMillis()));
}
@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -162,7 +161,7 @@ private fun MultiselectForwardContent(
viewModel = contactSearchViewModel,
mapStateToConfiguration = mapStateToConfiguration,
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.weight(1f),
additionalEntries = additionalEntries,
displayOptions = remember {
@@ -2489,17 +2489,7 @@ class ConversationFragment :
?: MediaUtil.IMAGE_WEBP
)
val quote = if (SignalStore.labs.stickerReplies) {
inputPanel.quote.orNull()
} else {
null
}
sendMessageWithoutComposeInput(slide = slide, quote = quote, clearCompose = clearCompose)
if (quote != null) {
inputPanel.clearQuote()
}
sendMessageWithoutComposeInput(slide = slide, clearCompose = clearCompose)
viewModel.updateStickerLastUsedTime(stickerRecord, System.currentTimeMillis().milliseconds)
}
@@ -66,6 +66,7 @@ import org.signal.core.ui.compose.list.reorderableList
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.time.Duration.Companion.milliseconds
@@ -77,7 +78,7 @@ class CreatePollFragment : ComposeDialogFragment() {
companion object {
private val TAG = Log.tag(CreatePollFragment::class)
const val MAX_QUESTION_CHARACTER_LENGTH = 200
val MAX_QUESTION_CHARACTER_LENGTH = if (RemoteConfig.pollsV2) 200 else 100
const val MAX_CHARACTER_LENGTH = 100
const val MAX_OPTIONS = 10
const val MIN_OPTIONS = 2
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.conversation.ManageContextMenu
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.function.Predicate
/**
@@ -48,6 +49,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
private val lifecycleDisposable = LifecycleDisposable()
private val removePaymentFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.PAYMENT }
private val removePollFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.POLL }
@Suppress("ReplaceGetOrSet")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -129,10 +131,15 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
private fun updateButtonsAvailable(recipient: Recipient) {
val paymentsValues = SignalStore.payments
val isPaymentsAvailable = paymentsValues.paymentsAvailability.isSendAllowed && !recipient.isSelf && !recipient.isGroup && recipient.isRegistered
val isPollsAvailable = recipient.isPushV2Group || RemoteConfig.pollsV2
if (!isPaymentsAvailable) {
if (!isPaymentsAvailable && !isPollsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter.and(removePollFilter))
} else if (!isPaymentsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter)
} else {
} else if (!isPollsAvailable) (
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePollFilter)
) else {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
}
}
@@ -2147,7 +2147,6 @@ class AttachmentTable(
REMOTE_INCREMENTAL_DIGEST to duplicateAttachment.incrementalDigest?.takeIf { it.isNotEmpty() },
REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to duplicateAttachment.incrementalMacChunkSize,
UPLOAD_TIMESTAMP to duplicateAttachment.uploadTimestamp,
CDN_NUMBER to duplicateAttachment.cdn.serialize(),
ARCHIVE_CDN to duplicateAttachment.archiveCdn,
ARCHIVE_TRANSFER_STATE to duplicateAttachment.archiveTransferState.value,
THUMBNAIL_FILE to dataFileInfo.thumbnailFile,
@@ -2631,8 +2630,6 @@ class AttachmentTable(
REMOTE_KEY to Base64.encodeWithPadding(Util.getSecretBytes(64)),
REMOTE_DIGEST to Util.getSecretBytes(64)
)
.where("$ID = ?", attachmentId.id)
.run()
}
private fun deleteDataFiles(filePaths: Set<String>, contentTypes: Set<String>) {
@@ -109,11 +109,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
"CREATE INDEX $CALL_LOG_INDEX ON $TABLE_NAME ($TIMESTAMP, $PEER, $EVENT, $TYPE, $DELETION_TIMESTAMP)"
)
}
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE, readAt: Long = System.currentTimeMillis()) {
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE) {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
val allUnreadMissedCalls = readableDatabase
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$TIMESTAMP <= ? AND $READ != ? AND ($EVENT = ? OR $EVENT = ?)", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE))
.where("$TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -129,9 +131,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (expiringCalls.isNotEmpty()) {
Log.i(TAG, "Found ${expiringCalls.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to readAt })
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to proposedExpireStarted })
for ((messageId, expiresIn) in expiringCalls) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
}
}
@@ -140,13 +142,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
}
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long, readAt: Long = System.currentTimeMillis()): Call? {
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long): Call? {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
val latestCallAsOfTimestamp = writableDatabase.withinTransaction { db ->
val unreadMissedCalls = db
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND ($EVENT = ? OR $EVENT = ?) AND $GROUP_CALL_ACTIVE = 0", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE))
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND $EVENT = ? AND $GROUP_CALL_ACTIVE = 0", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -161,9 +164,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (expiring.isNotEmpty()) {
Log.i(TAG, "Found ${expiring.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to readAt })
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to proposedExpireStarted })
for ((messageId, expiresIn) in expiring) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
}
}
@@ -188,7 +191,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return readableDatabase
.count()
.from(TABLE_NAME)
.where("($EVENT = ? OR $EVENT = ?) AND $READ = ?", Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE), ReadState.serialize(ReadState.UNREAD))
.where("$EVENT = ? AND $READ = ?", Event.serialize(Event.MISSED), ReadState.serialize(ReadState.UNREAD))
.run()
.readToSingleLong()
}
@@ -1054,7 +1057,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.RINGING, Event.MISSED, Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.DECLINED)
Event.RINGING, Event.MISSED, Event.MISSED_NOTIFICATION_PROFILE, Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.DECLINED)
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED)
Event.OUTGOING_RING -> Log.w(TAG, "Received DECLINED_ON_ANOTHER_DEVICE while in OUTGOING_RING state.")
else -> Unit
@@ -251,41 +251,26 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
)
private fun consumePaymentsToNotifyUser(where: String, args: Array<Any>): List<InAppPayment> {
val hasUnnotified = readableDatabase.exists(TABLE_NAME)
.where(where, *args)
.run()
if (!hasUnnotified) return emptyList()
return writableDatabase.withinTransaction { db ->
val notifiable = db.select()
val payments = db.select()
.from(TABLE_NAME)
.where(where, *args)
.run()
.readToList(mapper = { InAppPayment.deserialize(it) })
.filter { it.isInUserNotifiableState() }
for (payment in notifiable) {
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where(ID_WHERE, payment.id)
.run()
}
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where(where, *args)
.run()
notifiable
payments
}
}
/**
* A payment is only worth notifying the user about once it has reached a state we have something to
* display for. That is either:
*
* - [State.END]: the pipeline is complete (success when there's no error, a terminal failure or
* cancellation when there is), or
* - [State.PENDING] with an error: the long-running "your payment is processing" case. Per
* [validateInAppPayment] a PENDING payment may only carry the keep-alive error.
*
* Payments still moving through the pipeline (CREATED, TRANSACTING, REQUIRES_ACTION,
* REQUIRED_ACTION_COMPLETED, WAITING_FOR_AUTHORIZATION, or PENDING without an error) have nothing to
* show yet and must not be consumed/marked notified, or the eventual terminal sheet would be suppressed.
*/
private fun InAppPayment.isInUserNotifiableState(): Boolean {
return state == State.END || (state == State.PENDING && data.error != null)
}
fun getById(id: InAppPaymentId): InAppPayment? {
return readableDatabase.select()
.from(TABLE_NAME)
@@ -915,7 +915,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threadId = threadIdResult.threadId
val dateReceived = System.currentTimeMillis()
val expiresIn = if (RemoteConfig.disappearMore) threads.getExpiresIn(threadId) else 0
val missed = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)
val values = contentValuesOf(
FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else recipientId.serialize(),
@@ -924,7 +923,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to dateReceived,
DATE_SENT to timestamp,
READ to 1,
NOTIFIED to if (missed) 0 else 1,
NOTIFIED to if (MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)) 0 else 1,
TYPE to type,
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
@@ -939,8 +938,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
notifyConversationListeners(threadId)
TrimThreadJob.enqueueAsync(threadId)
// If inserting a call from a sync message, automatically start timer unless it was missed
if (expiresIn != 0L && !missed && fromSync) {
// If inserting an outgoing call from a sync message, automatically start timer
if (expiresIn != 0L && outgoing && fromSync) {
Log.i(TAG, "Starting expiration timer after inserting a call from a sync message.")
markExpireStarted(messageId, timestamp)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, timestamp, expiresIn)
@@ -998,8 +997,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val expiresIn = if (RemoteConfig.disappearMore) recipient.expiresInSeconds.seconds.inWholeMilliseconds else 0
val messageId: MessageId = writableDatabase.withinTransaction { db ->
val self = Recipient.self()
val selfCreated = self.id == sender
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || selfCreated
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || self.id == sender
val updateDetails: ByteArray = GroupCallUpdateDetails(
eraId = eraId,
startedCallUuid = Recipient.resolved(sender).requireServiceId().toString(),
@@ -1026,9 +1024,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val messageId = MessageId(db.insert(TABLE_NAME, null, values))
// Calls ringing from a linked (not local) device could be active
val isPotentialActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice || selfCreated
if (!isPotentialActiveCall) {
val isActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice
if (!isActiveCall) {
maybeCollapseMessage(db = db, messageId = messageId.id, threadId = threadId, dateReceived = timestamp, messageExtras = null, messageType = MessageTypes.GROUP_CALL_TYPE)
if (markRead && expiresIn != 0L) {
Log.d(TAG, "[insertGroupCall] Starting expiration timer for group call.")
@@ -1125,7 +1122,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updateDetail = GroupCallUpdateDetailsUtil.parse(message.body)
val containsSelf = joinedUuids.contains(SignalStore.account.requireAci().rawUuid)
val selfCreated = updateDetail.startedCallUuid == SignalStore.account.requireAci().rawUuid.toString()
// Treat empty eraId from ring requests as matching for updating
val sameEraId = (updateDetail.eraId == eraId || updateDetail.eraId.isEmpty()) && !Util.isEmpty(eraId)
val inCallUuids = if (sameEraId) joinedUuids.map { it.toString() } else emptyList()
@@ -1144,7 +1140,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0
if (inCallUuids.isEmpty()) {
val acknowledgedCall = localJoined || event == Event.DECLINED || selfCreated
val acknowledgedCall = localJoined || event == Event.DECLINED
finalizeEndedGroupCallMessage(db, message, acknowledgedCall, logPrefix = "[updateGroupCall]")
}
@@ -4334,8 +4330,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
db.execSQL("DELETE FROM $batchTable")
db.execSQL("INSERT INTO $batchTable SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT $DELETE_BATCH_SIZE")
// Expand to include revision chain members so they're always deleted together
db.execSQL("INSERT OR IGNORE INTO $batchTable SELECT $ID FROM $TABLE_NAME WHERE $LATEST_REVISION_ID IN (SELECT $ID FROM $batchTable)")
db.execSQL("INSERT OR IGNORE INTO $batchTable SELECT $ID FROM $TABLE_NAME WHERE $ORIGINAL_MESSAGE_ID IN (SELECT $ID FROM $batchTable)")
db.execSQL("INSERT OR IGNORE INTO $batchTable SELECT $ID FROM $TABLE_NAME WHERE $LATEST_REVISION_ID IN (SELECT $ID FROM $batchTable) OR $ORIGINAL_MESSAGE_ID IN (SELECT $ID FROM $batchTable)")
db.delete(StorySendTable.TABLE_NAME)
.where("${StorySendTable.TABLE_NAME}.${StorySendTable.MESSAGE_ID} IN ($batchSelect)")
@@ -220,7 +220,7 @@ class NameCollisionTables(
.run()
}
pruneCollision(collisionId)
pruneCollisions()
}
}
@@ -473,32 +473,6 @@ class NameCollisionTables(
)
}
/**
* Removes the given collision if it has fewer than two members.
*
* Unlike [pruneCollisions], this is scoped to a single collision so it can be used on hot paths
* (e.g. opening a conversation) without scanning the entire [NameCollisionTable] while holding the
* write lock. Callers that may have modified the membership of more than one collision should
* continue to use [pruneCollisions].
*/
private fun pruneCollision(collisionId: Long) {
check(writableDatabase.inTransaction())
writableDatabase.execSQL(
"""
DELETE FROM ${NameCollisionTable.TABLE_NAME}
WHERE $ID = ? AND $ID NOT IN (
SELECT ${NameCollisionMembershipTable.COLLISION_ID}
FROM ${NameCollisionMembershipTable.TABLE_NAME}
WHERE ${NameCollisionMembershipTable.COLLISION_ID} = ?
GROUP BY ${NameCollisionMembershipTable.COLLISION_ID}
HAVING COUNT(*) >= 2
)
""".trimIndent(),
arrayOf(collisionId, collisionId)
)
}
private fun getDuplicatedGroupRecipients(groupId: V2, toCheck: Set<RecipientId>): List<ReviewRecipient> {
if (toCheck.isEmpty()) {
return emptyList()
@@ -3712,26 +3712,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args, null, null, orderBy)
}
fun queryGroupMemberContactsForGroup(groupId: GroupId, inputQuery: String): Cursor? {
val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + E164
val queryFilter = if (inputQuery.isNotEmpty()) "AND ($SORT_NAME GLOB ? OR $USERNAME GLOB ?)" else ""
val selection = """
$ID != ? AND
$ID IN (SELECT ${GroupTable.MembershipTable.RECIPIENT_ID} FROM ${GroupTable.MembershipTable.TABLE_NAME} WHERE ${GroupTable.MembershipTable.GROUP_ID} = ?)
$queryFilter
"""
val args = if (queryFilter.isBlank()) {
mutableListOf(Recipient.self().id.serialize(), groupId.toString())
} else {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
mutableListOf(Recipient.self().id.serialize(), groupId.toString(), query, query)
}
return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args.toTypedArray(), null, null, orderBy)
}
fun queryAllContacts(inputQuery: String, includeSelfMode: IncludeSelfMode): Cursor? {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
val selection =
@@ -6,14 +6,11 @@
package org.thoughtcrime.securesms.dependencies
import androidx.media3.exoplayer.ExoPlayer
import org.signal.core.util.contentproviders.BlobProvider
import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.MediaSendQrRepository
import org.signal.mediasend.MediaSendRepository
import org.signal.mediasend.preupload.PreUploadRepository
import org.signal.video.exo.ExoPlayerPool
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3PreUploadRepository
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3QrRepository
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Repository
object MediaSendDependenciesProvider : MediaSendDependencies.Provider {
@@ -21,9 +18,5 @@ object MediaSendDependenciesProvider : MediaSendDependencies.Provider {
override fun providePreUploadRepository(): PreUploadRepository = MediaSendV3PreUploadRepository
override fun provideQrRepository(): MediaSendQrRepository = MediaSendV3QrRepository
override fun provideExoPlayerPool(): ExoPlayerPool<ExoPlayer> = AppDependencies.exoPlayerPool
override fun provideBlobs(): BlobProvider = AppDependencies.blobs
}
@@ -1,212 +0,0 @@
package org.thoughtcrime.securesms.groups.ui
import android.os.Bundle
import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.logging.Log
import org.signal.core.util.requireParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearch
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.conversation.RecipientSearchBar
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.fragments.findListener
/**
* Fragment that shows all members in a group (excluding self)
*/
class MemberSearchFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(MemberSearchFragment::class.java)
private const val ARG_GROUP_ID = "group_id"
fun newInstance(groupId: GroupId.V2): MemberSearchFragment {
return MemberSearchFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_GROUP_ID, groupId)
}
}
}
}
private val groupId: GroupId.V2 by lazy {
requireArguments().requireParcelableCompat(ARG_GROUP_ID, GroupId.V2::class.java)
}
private val contactViewModel: ContactSearchViewModel by viewModels {
ContactSearchViewModel.Factory(
selectionLimits = SelectionLimits(0, 0),
isMultiSelect = false,
repository = ContactSearchRepository(),
performSafetyNumberChecks = false,
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository(),
searchRepository = SearchRepository(requireContext().getString(R.string.Recipient_you)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
)
}
@Composable
override fun FragmentContent() {
CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) {
MemberSearchScreen(
contactViewModel = contactViewModel,
mapStateToConfiguration = this::getConfiguration,
contactSearchCallbacks = remember {
SearchCallbacks(
fragmentManager = childFragmentManager,
groupId = groupId
)
}
)
}
}
class SearchCallbacks(
private val fragmentManager: FragmentManager,
private val groupId: GroupId.V2
) : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val recipientId = contactSearchKeys.filterIsInstance<ContactSearchKey.RecipientSearchKey>().firstOrNull()?.recipientId
if (recipientId != null) {
RecipientBottomSheetDialogFragment.show(fragmentManager, recipientId, groupId)
}
return emptySet()
}
}
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
return findListener<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
query = contactSearchState.query
addSection(
ContactSearchConfiguration.Section.GroupMembers(
includeHeader = false,
includeLetterHeaders = true,
groupId = groupId,
showGroupsInCommon = false
)
)
withEmptyState {
addSection(ContactSearchConfiguration.Section.Empty)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemberSearchScreen(
contactViewModel: ContactSearchViewModel,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
contactSearchCallbacks: MemberSearchFragment.SearchCallbacks
) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.MemberSearchFragment__search_members)) },
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
imageVector = SignalIcons.ArrowStart.imageVector,
contentDescription = stringResource(R.string.DSLSettingsToolbar__navigate_up)
)
}
}
)
}
) {
MemberSearchContent(
contactViewModel = contactViewModel,
mapStateToConfiguration = mapStateToConfiguration,
contactSearchCallbacks = contactSearchCallbacks,
modifier = Modifier.padding(it)
)
}
}
@Composable
private fun MemberSearchContent(
contactViewModel: ContactSearchViewModel,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
modifier: Modifier = Modifier,
contactSearchCallbacks: MemberSearchFragment.SearchCallbacks
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Row(modifier = modifier) {
Column(
modifier = Modifier
.weight(1f)
) {
val query by contactViewModel.query.collectAsStateWithLifecycle()
RecipientSearchBar(
hint = stringResource(R.string.MemberSearchFragment__search_members),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.focusRequester(focusRequester)
.horizontalGutters(),
query = query ?: "",
onQueryChange = { contactViewModel.setQuery(it) },
onSearch = { contactViewModel.setQuery(it) }
)
ContactSearch(
viewModel = contactViewModel,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = remember {
ContactSearchAdapter.DisplayOptions(
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
)
},
callbacks = contactSearchCallbacks
)
}
}
}
@@ -87,7 +87,10 @@ class CheckKeyTransparencyJob private constructor(
}
private fun canRunJob(): Boolean {
return if (!SignalStore.account.isRegistered) {
return if (!RemoteConfig.internalUser) {
Log.i(TAG, "Remote config is not on. Exiting.")
false
} else if (!SignalStore.account.isRegistered) {
Log.i(TAG, "Account not registered. Exiting.")
false
} else if (TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)) {
@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.signal.core.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
@@ -13,7 +13,6 @@ import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
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
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
@@ -59,20 +58,14 @@ class InAppPaymentStripeRecurringSetupJob private constructor(
}
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
try {
info("Ensuring the subscriber id is set on the server.")
RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType())
info("Canceling active subscription (if necessary).")
RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType())
info("Creating and confirming setup intent.")
return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) {
is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action)
is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action)
}
} catch (e: DonationError) {
throw e
} catch (e: Exception) {
throw DonationError.getPaymentSetupError(inAppPayment.type.toErrorSource(), e, inAppPayment.data.paymentMethodType.toPaymentSourceType())
info("Ensuring the subscriber id is set on the server.")
RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType())
info("Canceling active subscription (if necessary).")
RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType())
info("Creating and confirming setup intent.")
return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) {
is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action)
is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action)
}
}
@@ -18,7 +18,6 @@ import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.libsignal.net.ChallengeOption
import org.signal.libsignal.protocol.InvalidSessionException
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.network.service.MessageService
import org.thoughtcrime.securesms.BuildConfig
@@ -340,12 +339,6 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
}
is MessageService.SendError.ApplicationError -> when (val cause = error.exception) {
// InvalidSessionException is a RuntimeException, must check before fatal runtime check
is InvalidSessionException -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Session was invalidated mid-send. Retrying.", cause)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is RuntimeException -> {
Log.e(TAG, "${logPrefix(message.sentTimeMillis)} Encountered a fatal application error. Crash imminent.", cause)
Result.fatalFailure(cause)
@@ -6,15 +6,15 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import kotlin.time.Duration.Companion.days
@@ -6,9 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.Base64;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.core.util.logging.Log;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.thoughtcrime.securesms.AppCapabilities;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -18,12 +16,13 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.keyvalue.SvrValues;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
import org.thoughtcrime.securesms.registration.data.RegistrationRepository;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.RequestResultUtil;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -96,26 +95,12 @@ public class RefreshAttributesJob extends BaseJob {
return;
}
SvrValues svrValues = SignalStore.svr();
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut());
if (SignalStore.account().isPrimaryDevice()) {
setPrimaryDeviceAttributes(svrValues, capabilities);
} else {
Log.i(TAG, "Linked device, refreshing device capabilities only. Capabilities: " + capabilities);
RequestResultUtil.successOrThrow(SignalNetwork.account().setCapabilities(capabilities));
}
hasRefreshedThisAppCycle = true;
}
private void setPrimaryDeviceAttributes(@NonNull SvrValues svrValues, @NonNull AccountAttributes.Capabilities capabilities) throws IOException {
int registrationId = SignalStore.account().getRegistrationId();
boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.settings().getForceWebsocketMode().isEnabled();
byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey());
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
String registrationLockV2 = null;
SvrValues svrValues = SignalStore.svr();
int pniRegistrationId = RegistrationRepository.getPniRegistrationId();
String recoveryPassword = svrValues.getMasterKey().deriveRegistrationRecoveryPassword();
@@ -128,6 +113,7 @@ public class RefreshAttributesJob extends BaseJob {
String deviceName = SignalStore.account().getDeviceName();
byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey());
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut());
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", restoredAEP? " + SignalStore.account().restoredAccountEntropyPool() +
"\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) +
"\n Phone number discoverable : " + phoneNumberDiscoverable +
@@ -149,6 +135,8 @@ public class RefreshAttributesJob extends BaseJob {
);
NetworkResultUtil.toBasicLegacy(SignalNetwork.account().setAccountAttributes(accountAttributes));
hasRefreshedThisAppCycle = true;
}
@Override
@@ -86,7 +86,7 @@ class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob
return
}
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.")
return
}
@@ -72,11 +72,6 @@ class ResetSvrGuessCountJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (SignalStore.account.isLinkedDevice) {
Log.i(TAG, "Not primary device. Skipping.")
return Result.success()
}
SvrRepository.operationLock.withLock {
val pin = SignalStore.svr.pin
@@ -52,11 +52,6 @@ class Svr3MirrorJob private constructor(parameters: Parameters, private var seri
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (SignalStore.account.isLinkedDevice) {
Log.i(TAG, "Not primary device. Skipping.")
return Result.success()
}
if (!Svr3Migration.shouldWriteToSvr3) {
Log.w(TAG, "Writes to SVR3 are disabled. Skipping.")
return Result.success()
@@ -27,7 +27,7 @@ class StickerSearchRepository {
.map { findStickersForEmoji(it) }
.flatten()
return (maybeEmojiQuery + searchResults).distinctBy { it.rowId }
return maybeEmojiQuery + searchResults
}
@WorkerThread
@@ -327,17 +327,15 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
}
}
fun setNumberAndPniIdentity(e164: String, pni: PNI, pniRegistrationId: Int, pniIdentityKeyPair: IdentityKeyPair) {
/** Set an identity key pair for the PNI identity via change number. */
fun setPniIdentityKeyAfterChangeNumber(key: IdentityKeyPair) {
synchronized(this) {
Log.i(TAG, "Setting the E164, PNI, PNI registration ID, and PNI identity key pair.")
Log.i(TAG, "Setting a new PNI identity key pair.")
store
.beginWrite()
.putString(KEY_E164, e164)
.putString(KEY_PNI, pni.toString())
.putInteger(KEY_PNI_REGISTRATION_ID, pniRegistrationId)
.putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, pniIdentityKeyPair.publicKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, pniIdentityKeyPair.privateKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, key.publicKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, key.privateKey.serialize())
.commit()
}
}
@@ -144,7 +144,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
/**
* Whether or not to override the audio settings from the remote configuration.
*/
var callingSetAudioConfig by booleanValue(CALLING_SET_AUDIO_CONFIG, false).falseForExternalUsers()
var callingSetAudioConfig by booleanValue(CALLING_SET_AUDIO_CONFIG, true).falseForExternalUsers()
/**
* If overriding the audio settings, use the Oboe ADM or not.
@@ -8,9 +8,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
const val STORY_ARCHIVE: String = "labs.story_archive"
const val INCOGNITO: String = "labs.incognito"
const val BETTER_SEARCH: String = "labs.better_search"
const val AUTO_LOWER_HAND: String = "labs.auto_lower_hand"
const val STARRED_MESSAGES: String = "labs.starred_messages"
const val STICKER_REPLIES: String = "labs.sticker_replies"
const val MUTE_BREAKTHROUGH_NOTIFICATIONS: String = "labs.mute_breakthrough_notifications"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -25,12 +24,10 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
var betterSearch by booleanValue(BETTER_SEARCH, true).falseForExternalUsers()
var autoLowerHand by booleanValue(AUTO_LOWER_HAND, true).falseForExternalUsers()
var starredMessages by booleanValue(STARRED_MESSAGES, true).falseForExternalUsers()
var stickerReplies by booleanValue(STICKER_REPLIES, false).falseForExternalUsers()
var muteBreakthroughNotifications by booleanValue(MUTE_BREAKTHROUGH_NOTIFICATIONS, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}
@@ -4,8 +4,6 @@ import android.net.Uri
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logD
@@ -22,12 +20,14 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob
import org.thoughtcrime.securesms.jobs.E164FormattingJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.createAndUploadArchive
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse
import org.whispersystems.signalservice.api.link.TransferArchiveError
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse
@@ -0,0 +1,69 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalResources
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import kotlin.reflect.typeOf
private val callLinkRoomIdType = typeOf<CallLinkRoomId>()
fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
composable<MainNavigationDetailLocation.Empty> {
if (LocalResources.current.rememberIsSplitPane()) {
EmptyDetailScreen()
}
}
composable<MainNavigationDetailLocation.CallLinkDetails>(
typeMap = mapOf(
callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
informNavigatorWeAreReady()
val route = it.toRoute<MainNavigationDetailLocation.CallLinkDetails>()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
composable<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName>(
typeMap = mapOf(
callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
informNavigatorWeAreReady()
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName>()
val parent = navHostController.previousBackStackEntry ?: return@composable
CompositionLocalProvider(LocalViewModelStoreOwner provides parent) {
EditCallLinkNameScreen(roomId = route.callLinkRoomId)
}
}
}
@Composable
private fun informNavigatorWeAreReady() {
val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider
LaunchedEffect(navigator) {
navigator?.onFirstRender()
}
}
@@ -1,36 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.snapshots.SnapshotStateList
interface MainDetailBackStack {
val entries: SnapshotStateList<MainNavigationDetailLocation>
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
fun push(location: MainNavigationDetailLocation)
/**
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
*/
fun pop(): Boolean {
if (entries.size <= 1) return false
entries.removeAt(entries.lastIndex)
return true
}
/**
* Resets the stack to its base empty state.
*/
fun reset() {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
if (entries.isEmpty()) {
entries.add(MainNavigationDetailLocation.Empty)
}
}
}
@@ -114,10 +114,7 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
@Parcelize
sealed class CallLinks : Calls {
@Serializable
data class EditCallLinkName(
val callLinkRoomId: CallLinkRoomId,
val currentName: String = ""
) : CallLinks() {
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
@@ -34,7 +35,6 @@ import kotlinx.coroutines.rx3.asObservable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.calls.CallsBackStack
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.chats.ChatsBackStack
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
@@ -86,20 +86,6 @@ class MainNavigationViewModel(
val chatsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = chatsBackStack.entries
private val callsBackStack: CallsBackStack = CallsBackStack(savedStateHandle)
val callsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = callsBackStack.entries
private val currentTabBackStack: MainDetailBackStack?
get() {
val currentListLocation = internalMainNavigationState.value.currentListLocation
return when {
currentListLocation.isChatsTab -> chatsBackStack
currentListLocation == MainNavigationListLocation.CALLS -> callsBackStack
else -> null
}
}
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
@@ -111,10 +97,10 @@ class MainNavigationViewModel(
.combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) }
.asObservable()
val observableActiveCallId: Observable<Optional<out CallLogRow.Id>> =
snapshotFlow { callsBackStack.activeCallId }
.combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) }
.asObservable()
private val internalActiveCallId = MutableStateFlow<CallLogRow.Id?>(null)
val observableActiveCallId: Observable<Optional<out CallLogRow.Id>> = internalActiveCallId.map { Optional.ofNullable(it) }.combine(isFullScreenPane) { id, expanded ->
if (expanded) Optional.ofNullable(null) else id
}.asObservable()
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -167,6 +153,12 @@ class MainNavigationViewModel(
performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state ->
state.copy(storyFailure = hasFailedStories)
}
viewModelScope.launch {
internalDetailLocation.collect { location ->
updateActiveStateForLocation(location)
}
}
}
fun onPaneAnchorChanged(isFullScreenPane: Boolean) {
@@ -181,7 +173,7 @@ class MainNavigationViewModel(
this@MainNavigationViewModel.isSplitPane = isSplitPane
if (!isSplitPane) {
if (currentTabBackStack?.isEmpty == true) {
if (chatsBackStack.isEmpty) {
lockPaneToSecondary = true
setFocusedPane(ThreePaneScaffoldRole.Secondary)
}
@@ -212,8 +204,9 @@ class MainNavigationViewModel(
setFocusedPane(role)
}
earlyNavigationDetailLocationRequested?.let {
earlyNavigationDetailLocationRequested?.let { detail ->
lockPaneToSecondary = false
updateActiveStateForLocation(detail)
}
return this.navigator!!
@@ -253,6 +246,20 @@ class MainNavigationViewModel(
*/
override fun goTo(location: MainNavigationDetailLocation) = setDetailLocation(location)
private fun updateActiveStateForLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.CallLinkDetails -> {
internalActiveCallId.update { location.controllerKey }
}
is MainNavigationDetailLocation.Calls -> {
internalActiveCallId.update { location.controllerKey }
}
else -> Unit
}
}
private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation {
val args = conversationArgs
val liveRecipient = Recipient.live(args.recipientId)
@@ -285,13 +292,12 @@ class MainNavigationViewModel(
when (location) {
is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> clearDetailLocation(chatsBackStack)
is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.CALLS -> clearDetailLocation(callsBackStack)
is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location)
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
is MainNavigationDetailLocation.Calls, is MainNavigationDetailLocation.CallLinkDetails -> pushCallsDetailLocation(location)
is MainNavigationDetailLocation.Empty,
is MainNavigationDetailLocation.Stories -> {
is MainNavigationDetailLocation.CallLinkDetails,
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
@@ -327,29 +333,15 @@ class MainNavigationViewModel(
setFocusedPane(ThreePaneScaffoldRole.Primary)
}
fun popChatsDetailLocation() = popDetailLocation(chatsBackStack)
private fun pushCallsDetailLocation(location: MainNavigationDetailLocation) {
if (location is MainNavigationDetailLocation.Calls && callsBackStack.activeCallId != location.controllerKey) {
callsBackStack.reset()
}
callsBackStack.push(location)
setFocusedPane(ThreePaneScaffoldRole.Primary)
}
fun popCallsDetailLocation() = popDetailLocation(callsBackStack)
private fun popDetailLocation(backStack: MainDetailBackStack) {
backStack.pop()
if (backStack.isEmpty) {
fun popChatsDetailLocation() {
chatsBackStack.pop()
if (chatsBackStack.isEmpty) {
lockPaneToSecondary = true
popDetailPane()
}
}
private fun clearDetailLocation(backStack: MainDetailBackStack) {
private fun clearDetailLocation(backStack: ChatsBackStack) {
backStack.reset()
if (!isSplitPane) {
lockPaneToSecondary = true
@@ -378,7 +378,7 @@ public class MediaRepository {
height = dimens.getSecond();
}
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, media.getDuration(), media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, 0, media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
}
private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException {
@@ -405,7 +405,7 @@ public class MediaRepository {
height = dimens.getSecond();
}
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, media.getDuration(), media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, 0, media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
}
@VisibleForTesting
@@ -1,16 +1,24 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.WorkerThread
import org.signal.core.models.media.Media
import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.CursorUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.contentproviders.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.IOException
import java.util.LinkedList
class MediaCaptureRepository(context: Context) {
@@ -22,7 +30,7 @@ class MediaCaptureRepository(context: Context) {
dataSupplier = { data },
getLength = { data.size.toLong() },
createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) },
mimeType = ContentTypeUtil.IMAGE_JPEG,
mimeType = MediaUtil.IMAGE_JPEG,
width = width,
height = height
)
@@ -88,4 +96,76 @@ class MediaCaptureRepository(context: Context) {
return null
}
}
companion object {
@SuppressLint("VisibleForTests")
@WorkerThread
private fun getMediaInBucket(context: Context, bucketId: String, contentUri: Uri, isImage: Boolean): List<Media> {
val media: MutableList<Media> = LinkedList()
var selection: String? = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending()
var selectionArgs: Array<String>? = arrayOf(bucketId)
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val projection: Array<String> = if (isImage) {
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.ORIENTATION, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE)
} else {
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE, MediaStore.Video.Media.DURATION)
}
if (Media.ALL_MEDIA_BUCKET_ID == bucketId) {
selection = isNotPending()
selectionArgs = null
}
context.contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = CursorUtil.requireLong(cursor, projection[0])
val uri = ContentUris.withAppendedId(contentUri, rowId)
val mimetype = CursorUtil.requireString(cursor, MediaStore.Images.Media.MIME_TYPE)
val date = CursorUtil.requireLong(cursor, MediaStore.Images.Media.DATE_MODIFIED)
val orientation = if (isImage) CursorUtil.requireInt(cursor, MediaStore.Images.Media.ORIENTATION) else 0
val width = CursorUtil.requireInt(cursor, getWidthColumn(orientation))
val height = CursorUtil.requireInt(cursor, getHeightColumn(orientation))
val size = CursorUtil.requireLong(cursor, MediaStore.Images.Media.SIZE)
val duration = if (!isImage) CursorUtil.requireInt(cursor, MediaStore.Video.Media.DURATION).toLong() else 0.toLong()
media.add(
MediaRepository.fixMimeType(
context,
Media(
uri = uri,
contentType = mimetype,
date = date,
width = width,
height = height,
size = size,
duration = duration,
isBorderless = false,
isVideoGif = false,
bucketId = bucketId,
caption = null,
transformProperties = null,
fileName = null
)
)
)
}
}
return media
}
private fun getWidthColumn(orientation: Int): String {
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
}
private fun getHeightColumn(orientation: Int): String {
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
}
@Suppress("DEPRECATION")
private fun isNotPending(): String {
return if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"
}
}
}
@@ -13,22 +13,14 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.AndroidFragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.mediasend.HudCommand
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendScreen
import org.signal.mediasend.edit.LocalAddAMessageRowTextField
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Encapsulates the media send flow for v3.
@@ -71,29 +63,6 @@ class MediaSendV3Activity : PassphraseRequiredActivity() {
}
)
}
is HudCommand.GoToConversation -> {
lifecycleScope.launch(Dispatchers.Default) {
val recipient = Recipient.resolved(RecipientId.from(it.recipientId.id))
withContext(Dispatchers.Main) {
CommunicationActions.startConversation(
this@MediaSendV3Activity,
recipient,
null
)
}
}
}
HudCommand.GoToLinkedDevices -> {
startActivity(AppSettingsActivity.linkedDevices(this))
finish()
}
is HudCommand.GoToQuickTransfer -> {
startActivity(QuickTransferOldDeviceActivity.intent(this, it.qrData))
finish()
}
}
}
)
@@ -1,54 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import kotlinx.coroutines.rx3.await
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendQrRepository
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
object MediaSendV3QrRepository : MediaSendQrRepository {
private val TAG = Log.tag(MediaSendV3QrRepository::class)
override suspend fun checkQrData(qrData: String): MediaSendQrRepository.QrCheckResult {
return when {
UsernameRepository.isValidLink(qrData) -> handleUsernameLink(qrData)
qrData.startsWith("sgnl://linkdevice") -> handleLinkDevice()
qrData.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(qrData) -> handleReReg(qrData)
else -> MediaSendQrRepository.QrCheckResult.None
}
}
private suspend fun handleUsernameLink(qrData: String): MediaSendQrRepository.QrCheckResult {
return when (val result = UsernameRepository.fetchUsernameAndAciFromLink(qrData).await()) {
is UsernameRepository.UsernameLinkConversionResult.Success -> {
val username = result.username.toString()
val recipient = Recipient.externalUsername(result.aci, result.username.toString())
MediaSendQrRepository.QrCheckResult.Username(
recipientId = MediaRecipientId(recipient.id.toLong()),
username = username
)
}
else -> {
Log.w(TAG, "Failed to scan QR code")
MediaSendQrRepository.QrCheckResult.None
}
}
}
private fun handleLinkDevice(): MediaSendQrRepository.QrCheckResult {
return MediaSendQrRepository.QrCheckResult.LinkDevice
}
private fun handleReReg(qrData: String): MediaSendQrRepository.QrCheckResult {
return MediaSendQrRepository.QrCheckResult.ReRegistration(qrData)
}
}
@@ -49,8 +49,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.InputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@@ -197,12 +195,6 @@ object MediaSendV3Repository : MediaSendRepository {
return !RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL)
}
override fun getMediaConstraints(): MediaConstraints {
return PushMediaConstraints(null)
}
override var storyMaxVideoDuration: Duration = Stories.MAX_VIDEO_DURATION_MILLIS.milliseconds
private fun resolveSendType(sendType: Int): MessageSendType {
return when (sendType) {
else -> MessageSendType.SignalMessageSendType
@@ -1376,7 +1376,7 @@ object SyncMessageProcessor {
}
CallLogEvent.Type.MARKED_AS_READ -> {
SignalDatabase.calls.markAllCallEventsRead(timestamp, envelopeTimestamp)
SignalDatabase.calls.markAllCallEventsRead(timestamp)
}
CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION -> {
@@ -1385,7 +1385,7 @@ object SyncMessageProcessor {
return
}
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, envelopeTimestamp)
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp)
}
else -> log(envelopeTimestamp, "Synchronize call log event has an invalid type $eventType, ignoring.")
@@ -1896,6 +1896,11 @@ object SyncMessageProcessor {
return
}
if (SignalStore.account.pni == PNI(pni)) {
log(timestamp, "PniChangeNumber sync already applied locally. Skipping.")
return
}
val identityKeyPairBytes = pniChangeNumber.identityKeyPair
val signedPreKeyBytes = pniChangeNumber.signedPreKey
val registrationId = pniChangeNumber.registrationId
@@ -1929,6 +1934,8 @@ object SyncMessageProcessor {
pniRegistrationId = registrationId
)
SignalStore.misc.lastAppliedPniChangeServerTimestamp = envelopeServerTimestamp
// The primary already submitted these per-device prekeys to the server as part of the
// change-number request, so they are registered server-side from this device's perspective.
val pniMetadataStore = SignalStore.account.pniPreKeys
@@ -1940,8 +1947,6 @@ object SyncMessageProcessor {
// Rotate the primary-generated keys as soon as possible so we don't rely on them long-term.
SignalStore.misc.forcePniSignedPreKeyRotation = true
AppDependencies.jobManager.add(PreKeysSyncJob.create(forceRotationRequested = true))
SignalStore.misc.lastAppliedPniChangeServerTimestamp = envelopeServerTimestamp
}
private fun applyAttachmentData(
@@ -45,7 +45,7 @@ public final class DeviceTransferBlockingInterceptor implements Interceptor {
.receivedResponseAtMillis(System.currentTimeMillis())
.message("")
.body(ResponseBody.create(null, ""))
.code(555)
.code(500)
.build();
}
@@ -60,6 +60,6 @@ public final class DeviceTransferBlockingInterceptor implements Interceptor {
public void unblockNetwork() {
blockNetworking = false;
AppDependencies.resetNetwork();
SignalExecutors.UNBOUNDED.execute(AppDependencies::startNetwork);
}
}
@@ -110,7 +110,7 @@ public class MarkReadReceiver extends BroadcastReceiver {
.collect(Collectors.toList()));
for (RecipientId peer : peers) {
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, System.currentTimeMillis());
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp);
if (lastCallInThread != null) {
AppDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsReadInConversation(lastCallInThread));
}
@@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient.Companion.external
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.UsernameUtil.isValidUsernameForSearch
@@ -338,11 +339,11 @@ class Recipient(
/** Whether calls should break through mute for this recipient. */
val callNotificationSetting: NotificationSetting
get() = if (SignalStore.labs.muteBreakthroughNotifications) callNotificationSettingValue else NotificationSetting.ALWAYS_NOTIFY
get() = if (RemoteConfig.internalUser) callNotificationSettingValue else NotificationSetting.ALWAYS_NOTIFY
/** Whether replies should break through mute for this recipient. Only applicable to groups. */
val replyNotificationSetting: NotificationSetting
get() = if (groupIdValue == null) NotificationSetting.DO_NOT_NOTIFY else if (SignalStore.labs.muteBreakthroughNotifications) replyNotificationSettingValue else mentionSetting
get() = if (groupIdValue == null) NotificationSetting.DO_NOT_NOTIFY else if (RemoteConfig.internalUser) replyNotificationSettingValue else mentionSetting
/** The state around whether we can send sealed sender to this user. */
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {
@@ -26,7 +26,6 @@ import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.util.KeyHelper
@@ -71,13 +70,13 @@ import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionC
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.PinHashUtil
@@ -98,7 +97,6 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
/**
@@ -490,46 +488,23 @@ object RegistrationRepository {
val aci = message.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(message.aci)
val pni = message.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(message.pni)
return registerAsLinkedDevice(
context = context,
deviceName = deviceName,
number = message.number!!,
provisioningCode = message.provisioningCode!!,
aci = aci,
pni = pni,
accountEntropyPool = AccountEntropyPool(message.accountEntropyPool!!),
registrationData = registrationData,
aciIdentityKeyPair = aciIdentityKeyPair,
pniIdentityKeyPair = pniIdentityKeyPair
)
}
val universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
/**
* Registers this device as a linked (secondary) device using discrete fields rather than a raw
* [ProvisionMessage]. This is the form used by the regv5 registration module, which works with a
* decoupled provisioning message.
*/
@WorkerThread
fun registerAsLinkedDevice(
context: Context,
deviceName: String,
number: String,
provisioningCode: String,
aci: ACI,
pni: PNI,
accountEntropyPool: AccountEntropyPool,
registrationData: RegistrationData,
aciIdentityKeyPair: IdentityKeyPair,
pniIdentityKeyPair: IdentityKeyPair
): NetworkResult<RegisterAsLinkedDeviceResponse> {
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair)
val deviceAttributes = DeviceAttributes(
fetchesMessages = registrationData.fcmToken == null,
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = getRegistrationId(),
pniRegistrationId = getPniRegistrationId(),
fetchesMessages = registrationData.fcmToken == null,
registrationLock = null,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(false),
discoverableByPhoneNumber = false,
name = Base64.encodeWithPadding(encryptedDeviceName),
capabilities = AppCapabilities.getCapabilities(false)
pniRegistrationId = getPniRegistrationId(),
recoveryPassword = null
)
val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys)
@@ -537,18 +512,20 @@ object RegistrationRepository {
return AccountManagerFactory
.getInstance()
.createUnauthenticated(context, number, -1, registrationData.password)
.createUnauthenticated(context, message.number!!, -1, registrationData.password)
.registrationApi
.registerAsSecondaryDevice(provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { response ->
.registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { respone ->
val aep = AccountEntropyPool(message.accountEntropyPool!!)
RegisterAsLinkedDeviceResponse(
deviceId = response.deviceId.toInt(),
deviceId = respone.deviceId.toInt(),
accountRegistrationResult = AccountRegistrationResult(
uuid = aci.toString(),
pni = pni.toString(),
storageCapable = false,
number = number,
masterKey = accountEntropyPool.deriveMasterKey(),
number = message.number!!,
masterKey = aep.deriveMasterKey(),
pin = null,
aciPreKeyCollection = aciPreKeys,
pniPreKeyCollection = pniPreKeys,
@@ -697,7 +674,7 @@ object RegistrationRepository {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? {
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
@@ -706,8 +683,7 @@ object RegistrationRepository {
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
// The primary has responded: either with an archive location, or an error telling us not to expect one.
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})")
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary")
return result.result
}
is NetworkResult.ApplicationError -> {
@@ -134,7 +134,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu
class SubmitVerificationCodeRateLimited(cause: Throwable) : VerificationCodeRequestResult(cause)
class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials, val svr3Credentials: Svr3Credentials?) : VerificationCodeRequestResult(cause)
class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials, val svr3Credentials: Svr3Credentials) : VerificationCodeRequestResult(cause)
class NoSuchSession(cause: Throwable) : VerificationCodeRequestResult(cause)
@@ -1,4 +1,4 @@
package org.signal.core.util.crypto
package org.thoughtcrime.securesms.registration.secondary
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
@@ -7,6 +7,8 @@ import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.util.ByteUtil
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import java.nio.charset.Charset
import java.security.GeneralSecurityException
import java.security.MessageDigest
@@ -20,7 +22,7 @@ import javax.crypto.spec.SecretKeySpec
*/
object DeviceNameCipher {
private val TAG = Log.tag(DeviceNameCipher::class)
private val TAG = Log.tag(DeviceNameCipher::class.java)
private const val SYNTHETIC_IV_LENGTH = 16
@@ -33,7 +35,7 @@ object DeviceNameCipher {
val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16)))
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(createEmptyByteArray(16)))
val cipherText = cipher.doFinal(plaintext)
return DeviceName(
@@ -59,13 +61,23 @@ object DeviceNameCipher {
val ephemeralPublic = ECPublicKey(deviceName.ephemeralPublic.toByteArray())
val masterSecret = identityKey.calculateAgreement(ephemeralPublic)
val cipherKey = computeCipherKey(masterSecret, syntheticIv)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val cipherKeyPart1 = mac.doFinal("cipher".toByteArray())
mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256"))
val cipherKey = mac.doFinal(syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16)))
val plaintext = cipher.doFinal(cipherText)
val ourSyntheticIv = computeSyntheticIv(masterSecret, plaintext)
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val verificationPart1 = mac.doFinal("auth".toByteArray())
mac.init(SecretKeySpec(verificationPart1, "HmacSHA256"))
val verificationPart2 = mac.doFinal(plaintext)
val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16)
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.")
@@ -104,4 +116,6 @@ object DeviceNameCipher {
ivMac.init(SecretKeySpec(syntheticIvKey, "HmacSHA256"))
return ivMac.doFinal(plaintext).sliceArray(0 until SYNTHETIC_IV_LENGTH)
}
private fun createEmptyByteArray(length: Int): ByteArray = ByteArray(length)
}

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