Compare commits

...

79 Commits

Author SHA1 Message Date
Alex Hart 5a6b0cbfef Bump version to 8.18.2 2026-07-06 13:35:58 -03:00
Alex Hart 86169b7368 Update translations and other static files. 2026-07-06 13:31:40 -03:00
Michelle Tang d4d8b54232 Disable disappearing calls. 2026-07-06 11:20:02 -04:00
Michelle Tang e74ed9dbf3 Bump version to 8.18.1 2026-07-02 14:55:25 -04:00
Michelle Tang e1687715d3 Update baseline profile. 2026-07-02 14:44:57 -04:00
Michelle Tang 447f29521b Update translations and other static files. 2026-07-02 14:36:19 -04:00
Greyson Parrelli c029d12b22 Fix long-text attachments failing media constraint check when sending long messages. 2026-07-02 12:03:24 -03:00
Michelle Tang 417c0f98ad Bump version to 8.18.0 2026-07-01 15:45:37 -04:00
Michelle Tang be41a24c0c Update baseline profile. 2026-07-01 15:34:34 -04:00
Michelle Tang b95815c963 Update translations and other static files. 2026-07-01 15:25:17 -04:00
Michelle Tang 15d86c9f13 Enable KT. 2026-07-01 15:11:49 -04:00
Michelle Tang 5c233daf38 Enable disappearing calls. 2026-07-01 15:11:49 -04:00
Greyson Parrelli b91ee27620 Don't enable TLS 1.3 on devices that don't support it. 2026-07-01 15:11:49 -04:00
Jeffrey Starke c03ae68ef2 Fix system navigation bar color on older Android versions. 2026-07-01 15:11:49 -04:00
Alex Hart b6f6d1e42f Add spinner API support for inserting attachments. 2026-07-01 15:11:49 -04:00
Greyson Parrelli db899e92f5 Add contact support capability to regV5. 2026-07-01 15:11:49 -04:00
Greyson Parrelli 99b89743a7 Fix default text field colorings. 2026-07-01 15:11:49 -04:00
Cody Henthorne 2bc2ae2867 Change optimize on-device storage to Optimize Signal storage. 2026-07-01 15:11:48 -04:00
Michelle Tang 300e5bf6f6 Various fixes for disappearing calls. 2026-07-01 15:11:48 -04:00
Michelle Tang 17d6fe918d Enable 1:1 polls. 2026-07-01 15:11:48 -04:00
Greyson Parrelli e71c4734bd Fix regV5 QR code animation. 2026-07-01 15:11:48 -04:00
Jim Gustafson 7d39ad04e3 Disable custom audio config by default for internal users. 2026-07-01 15:11:48 -04:00
Cody Henthorne 1ee4c590b7 Bump java and kotlin target to JDK 21. 2026-07-01 15:10:24 -04:00
Greyson Parrelli 4a0325ea55 Fix restoration of PIN during remote restore in regV5. 2026-07-01 15:10:24 -04:00
Cody Henthorne c4faafa9ae Use device specific API for linked devices updating capabilities. 2026-07-01 15:10:24 -04:00
Cody Henthorne ee11d1fd3b Harden and improve change number flow. 2026-07-01 15:10:24 -04:00
Greyson Parrelli c1c6db2b3b Update dockerfile to latest ubuntu snapshot. 2026-07-01 15:10:24 -04:00
Alex Hart 5372814468 Hook in QR Scans in new media send flow. 2026-07-01 15:10:23 -04:00
Alex Hart e5ed84f08c Update field access properties. 2026-07-01 15:10:23 -04:00
Alex Hart ab90969b0e Show donation thanks sheet reliably after redirect-based (iDEAL/Wero) donations. 2026-07-01 15:10:23 -04:00
Greyson Parrelli ebd64305bb Add button to help enter AEP from password manager in regV5. 2026-07-01 15:10:23 -04:00
Alex Hart f3b7368043 Add donation permit failure tests and surface pre-charge errors as payment setup errors. 2026-07-01 15:10:23 -04:00
Cody Henthorne a7de53333d Enable linked device flow for debug builds. 2026-07-01 15:10:23 -04:00
Cody Henthorne 36898a1e3a Remove extra option on device link screen. 2026-07-01 15:10:23 -04:00
Michelle Tang ec18fb4317 Add group search membership. 2026-07-01 15:10:23 -04:00
Greyson Parrelli dd671a8489 Fix empty state in story archive. 2026-07-01 15:10:22 -04:00
dependabot[bot] bcb5c37ed6 ci: bump the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 15:10:22 -04:00
Greyson Parrelli a4a6117788 Improve remote restore test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli bd42444e00 Improve device transfer test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b10cd9c5d3 Ensure device transfer flag is reset when re-registering. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b87abdabfa Improve PIN loading spinners in regV5. 2026-07-01 15:10:22 -04:00
Cody Henthorne 91965d205a Add device linking support to RegV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli 55b5997a4e Improve UX for missing SVR data in regV5. 2026-07-01 15:10:21 -04:00
Greyson Parrelli bdb6b7d5f1 Use a more obvious error code for DeviceTransferBlockingInterceptor. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 97740b34eb Ensure tha regV5 is used for re-reg. 2026-07-01 15:10:21 -04:00
Jeffrey Starke 5ed1bdc26a Migrate calls tab to nav 3. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 7505ef1834 Fix CDN not being copied on duplicate attachment. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 07afbb5a8f Hide edit option for messages with undownloaded long-text attachments. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 613a07eb2c Optimize single name collision check. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 0943030efa Split update into two statements to better utilize indices. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 5b266081db Preserve video duration when populating media metadata. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 8536504318 Stop prompting system password restore on PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 495b5ef9be Fix BackupSubscriptionCheckJobTest. 2026-07-01 15:10:20 -04:00
Greyson Parrelli bfdc628dd2 Fix skip button during PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 80aa521e42 Add proxy setting to regV5. 2026-07-01 15:10:18 -04:00
Greyson Parrelli 9f1c8961f0 Deduplicate sticker search results when querying by emoji. 2026-07-01 15:09:54 -04:00
Greyson Parrelli 8eeffff923 Enable regV5 on debug builds. 2026-07-01 15:09:54 -04:00
Greyson Parrelli e07a3e05bf Store restoreDecision separately in regV5. 2026-07-01 15:09:54 -04:00
Alex Hart 2c35a6a3fe Add ability to take a photo in media-send feature module. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 248a787d74 Keep the universalSealedSender setting on re-reg in regV5. 2026-06-29 12:13:01 -04:00
Cody Henthorne e76a584bb7 Add additional InvalidSessionException checking to 1:1 send flows. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 5a9cf128a0 Fix string in regV5 captcha screen. 2026-06-29 12:13:01 -04:00
Greyson Parrelli c7ac7087f7 Fix updateVerificationMetadata task to account for IDE tooling. 2026-06-29 12:13:01 -04:00
jeffrey-signal 9e2be2796b Fix gradle sync failure. 2026-06-29 12:13:01 -04:00
adel-signal 3f850375c1 Update RingRTC version to v2.69.5 2026-06-29 12:13:01 -04:00
Greyson Parrelli c91b739c1b Improve bubble UI for sticker replies. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 600acb82bd Move improved notification management to Labs. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 7d6a31b254 Allow sending sticker replies behind a Labs flag. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 56378c7166 Make lower hand suggestions available for external users. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 987f5158d0 Add docker caching to speed up CI task. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 76a1d89954 Update verification metadata task to trim unused entries. 2026-06-29 12:13:00 -04:00
Michelle Tang 72fd5d30df Fix typo. 2026-06-29 12:13:00 -04:00
Greyson Parrelli f74f0e5109 MaybeMarkRegistrationComplete for regV5. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 6e03627c47 Fix notification prompts in regV5. 2026-06-29 12:12:59 -04:00
Michelle Tang 837598657d Bump version to 8.17.3 2026-06-29 12:02:37 -04:00
Michelle Tang cf5aa92f84 Update baseline profile. 2026-06-29 11:57:52 -04:00
Michelle Tang 1613adf088 Update translations and other static files. 2026-06-29 11:46:34 -04:00
Cody Henthorne cc01d39240 Fix missing notifications for missed one-to-one calls. 2026-06-29 10:17:43 -04:00
Cody Henthorne b29bae50af Attempt to recover from bad change number state. 2026-06-29 09:45:36 -04:00
446 changed files with 20740 additions and 27293 deletions
+3 -3
View File
@@ -22,12 +22,12 @@ jobs:
submodules: true
lfs: true
- name: set up JDK 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
java-version: 21
- 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 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
java-version: 21
- 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
+9 -3
View File
@@ -13,9 +13,15 @@ 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
run: |
cd reproducible-builds
docker build -t signal-android .
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
- 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-17.0.2
java openjdk-21.0.2
uv latest
+3 -2
View File
@@ -28,8 +28,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1712
val canonicalVersionName = "8.17.2"
val canonicalVersionCode = 1716
val canonicalVersionName = "8.18.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -670,6 +670,7 @@ 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)
@@ -0,0 +1,76 @@
/*
* 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,9 +1,11 @@
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
@@ -12,13 +14,20 @@ 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
@@ -46,8 +55,24 @@ 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)
@@ -102,6 +127,43 @@ 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(
@@ -0,0 +1,159 @@
/*
* 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,9 +10,14 @@ 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 {
@@ -977,6 +982,80 @@ 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,
@@ -992,4 +1071,10 @@ 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,6 +88,7 @@ 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,8 +236,9 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
@@ -247,12 +248,41 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
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())
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
@@ -46,7 +46,8 @@ 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);
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
val key: String
get() {
+1 -6
View File
@@ -438,12 +438,7 @@
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,6 +52,7 @@ 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;
@@ -118,14 +119,16 @@ 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.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.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;
@@ -433,6 +436,15 @@ 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,6 +9,7 @@ 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
@@ -23,6 +24,7 @@ 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
@@ -57,10 +59,13 @@ 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
@@ -70,7 +75,9 @@ 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
@@ -101,6 +108,7 @@ 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
@@ -157,7 +165,6 @@ 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
@@ -183,7 +190,6 @@ 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
@@ -196,6 +202,7 @@ 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(),
@@ -287,14 +294,6 @@ 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)
@@ -507,15 +506,6 @@ 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,
@@ -534,17 +524,22 @@ class MainActivity :
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
MainNavigationListLocation.CALLS -> {
throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.")
}
MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location)
}
}
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> {
throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.")
}
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -739,9 +734,17 @@ class MainActivity :
}
MainNavigationListLocation.CALLS -> {
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
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) }
)
}
@@ -831,6 +834,23 @@ 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
@@ -1171,7 +1191,7 @@ class MainActivity :
} else if (SignalStore.internal.useNewMediaActivity) {
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isCameraFirst = true,
isStory = destination == MainNavigationListLocation.STORIES
)
)
@@ -222,11 +222,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
private Intent getEnterSignalPinIntent() {
@@ -1250,6 +1250,7 @@ object BackupRepository {
return ImportResult.Failure
}
SignalStore.backup.hasInvalidBackupVersion = false
val selfId: RecipientId
var transactionSuccessful = false
try {
@@ -1321,7 +1322,7 @@ object BackupRepository {
SignalStore.backup.mediaRootBackupKey = mediaRootBackupKey
// Add back self after clearing data
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
selfId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
@@ -1532,7 +1533,7 @@ object BackupRepository {
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
stopwatch.stop(TAG)
return ImportResult.Success(backupTime = header.backupTimeMs)
return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId)
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
@@ -2310,19 +2311,23 @@ object BackupRepository {
forwardSecrecyToken = forwardSecrecyToken,
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[remoteRestore] Failed to import backup")
return RemoteRestoreResult.Failure
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)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[remoteRestore] Restore successful")
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2354,9 +2359,16 @@ 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(response.cdn, response.key, tempBackupFile, progressListener)) {
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -2385,16 +2397,19 @@ object BackupRepository {
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
return RemoteRestoreResult.Failure
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)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
@@ -2524,12 +2539,12 @@ data class StagedBackupKeyRotations(
)
sealed class ImportResult {
data class Success(val backupTime: Long) : ImportResult()
data class Success(val backupTime: Long, val selfRecipientId: RecipientId) : ImportResult()
data object Failure : ImportResult()
}
sealed interface RemoteRestoreResult {
data object Success : RemoteRestoreResult
data class Success(val selfRecipientId: RecipientId) : RemoteRestoreResult
data object NetworkError : RemoteRestoreResult
data object Canceled : RemoteRestoreResult
data object Failure : RemoteRestoreResult
@@ -21,6 +21,7 @@ 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
@@ -51,6 +52,11 @@ 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)
}
@@ -0,0 +1,64 @@
/*
* 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)
}
}
}
@@ -0,0 +1,64 @@
/*
* 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,7 +89,8 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId
roomId: CallLinkRoomId,
initialName: String
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
@@ -99,7 +100,7 @@ fun EditCallLinkNameScreen(
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = viewModel.nameSnapshot,
initialNameValue = initialName,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
@@ -1,71 +0,0 @@
/*
* 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,7 +113,12 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
)
}
override fun onShareClicked() {
@@ -6,7 +6,6 @@
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
@@ -194,10 +193,4 @@ 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,11 +122,10 @@ class CallLogContextMenu(
iconRes = CoreUiR.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
when {
peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId())
else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds))
}
fragment.startActivity(intent)
}
}
@@ -154,6 +153,7 @@ class CallLogContextMenu(
interface Callbacks {
fun startSelection(call: CallLogRow)
fun goToCallLinkDetails(roomId: CallLinkRoomId)
fun deleteCall(call: CallLogRow)
}
}
@@ -32,7 +32,6 @@ 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
@@ -58,6 +57,7 @@ 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,9 +242,11 @@ 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())
}
@@ -325,7 +327,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
startActivity(intent)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId())
}
}
@@ -375,6 +377,7 @@ 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())
@@ -386,6 +389,10 @@ 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,6 +11,7 @@ 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
@@ -18,7 +19,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "chats_back_stack"
@@ -29,7 +30,7 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
)
}
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
@@ -45,13 +46,10 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
}
}
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
/**
* Pushes an entry onto the stack.
*/
fun push(location: MainNavigationDetailLocation) {
override fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
@@ -63,23 +61,4 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
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,8 +274,17 @@ 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,11 +128,14 @@ 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.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
presentGenericError(outcome.result)
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
} else {
presentGenericError(outcome.result)
}
}
null -> Unit
@@ -158,6 +161,8 @@ 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.VerificationCodeWorked) {
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
handleSuccessfulPinEntry(state.enteredPin)
}
@@ -188,8 +188,7 @@ class ChangeNumberRepository(
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
@@ -197,9 +196,6 @@ 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
@@ -326,6 +322,10 @@ class ChangeNumberRepository(
}
}
if (result !is NetworkResult.Success) {
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
}
if (result is NetworkResult.StatusCodeError) {
SignalStore.misc.unlockChangeNumber()
}
@@ -336,13 +336,39 @@ 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,
@@ -433,7 +459,6 @@ class ChangeNumberRepository(
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}
@@ -43,8 +43,7 @@ data class ChangeNumberState(
)
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
data object Succeeded : 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.RecoveryPasswordWorked -> {
Log.i(TAG, "Successfully changed number with recovery password.")
is ChangeNumberOutcome.Succeeded -> {
Log.i(TAG, "Successfully changed number.")
changeNumberSuccess()
}
@@ -109,11 +109,27 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
is VerificationCodeRequestResult.RateLimited -> {
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 -> {
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)
@@ -125,11 +141,6 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
is ChangeNumberOutcome.VerificationCodeWorked -> {
Log.i(TAG, "Successfully changed number with verification code.")
changeNumberSuccess()
}
}
}
@@ -12,14 +12,12 @@ 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
@@ -36,6 +34,7 @@ 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.
@@ -50,12 +49,13 @@ 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,14 +261,24 @@ class ChangeNumberViewModel : ViewModel() {
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val sessionId = session.sessionId
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
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.") }
}
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 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.") }
}
}
val result: ChangeNumberResult = if (pin == null) {
@@ -286,7 +296,7 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
@@ -359,18 +369,26 @@ 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) {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
try {
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")
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)
}
}
}
@@ -417,7 +435,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.RecoveryPasswordWorked)
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else if (result is ChangeNumberResult.UnknownError) {
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
@@ -512,6 +530,12 @@ 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")
@@ -532,14 +556,72 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is VerificationCodeRequestResult.AlreadyVerified) {
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
resetLocalSessionState()
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
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)
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())))
}
}
}
}
@@ -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()) {
RemoteRestoreResult.Success -> {
is RemoteRestoreResult.Success -> {
_state.value = _state.value.copy(statusMessage = "Import complete!")
ThreadUtil.runOnMain { afterDbRestoreCallback() }
}
@@ -10,6 +10,7 @@ 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,15 +125,6 @@ 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,
@@ -142,6 +133,24 @@ 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,6 +13,7 @@ data class LabsSettingsState(
val storyArchive: Boolean = false,
val incognito: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
val starredMessages: Boolean = false,
val stickerReplies: Boolean = false,
val muteBreakthroughNotifications: Boolean = false
)
@@ -33,15 +33,18 @@ 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)
}
}
}
@@ -51,8 +54,9 @@ class LabsSettingsViewModel : ViewModel() {
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito,
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
starredMessages = SignalStore.labs.starredMessages,
stickerReplies = SignalStore.labs.stickerReplies,
muteBreakthroughNotifications = SignalStore.labs.muteBreakthroughNotifications
)
}
}
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -301,31 +300,29 @@ private fun AdvancedPrivacySettingsScreen(
)
}
if (RemoteConfig.internalUser) {
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
}
}
@@ -21,10 +21,12 @@ 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
/**
@@ -53,11 +55,27 @@ 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,14 +7,9 @@ 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
@@ -29,7 +24,6 @@ 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
@@ -90,14 +84,6 @@ 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() {
@@ -1,42 +0,0 @@
/*
* 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,15 +10,10 @@ 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
@@ -40,9 +35,6 @@ 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 ->
@@ -58,13 +50,6 @@ 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,7 +830,19 @@ class ConversationSettingsFragment :
} else {
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
}
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
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))
}
}
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
@@ -137,13 +137,21 @@ class DSLConfiguration {
children.add(preference)
}
fun sectionHeaderPref(title: DSLSettingsText) {
val preference = SectionHeaderPreference(title)
fun sectionHeaderPref(
title: DSLSettingsText,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(title, iconEnd, onClick)
children.add(preference)
}
fun sectionHeaderPref(title: Int) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title))
fun sectionHeaderPref(
title: Int,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title), iconEnd, onClick)
children.add(preference)
}
@@ -365,4 +373,8 @@ class ExternalLinkPreference(
@StringRes val linkId: Int
) : PreferenceModel<ExternalLinkPreference>()
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()
class SectionHeaderPreference(
override val title: DSLSettingsText,
override val iconEnd: DSLSettingsIcon? = null,
val onClick: (() -> Unit)? = null
) : PreferenceModel<SectionHeaderPreference>()
@@ -5,12 +5,14 @@ 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;
@@ -118,9 +120,15 @@ public class ContactRepository {
}
@WorkerThread
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getGroupMemberContacts()
: recipientTable.queryGroupMemberContacts(query);
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);
}
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
@@ -138,7 +138,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
fastScrollerState = fastScrollerState,
lazyListState = lazyListState,
modifier = modifier.fillMaxSize(),
modifier = modifier,
letterContent = {
Emojifier(text = it.toString()) { annotatedText, inlineContent ->
Text(
@@ -154,7 +154,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
controller = mappingCtrl,
lazyListState = it,
modifier = Modifier.fillMaxSize()
modifier = modifier
)
}
}
@@ -2,6 +2,7 @@ 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
/**
@@ -131,7 +132,10 @@ class ContactSearchConfiguration private constructor(
*/
data class GroupMembers(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false,
val showGroupsInCommon: Boolean = true,
val groupId: GroupId? = null
) : Section(SectionKey.GROUP_MEMBERS)
/**
@@ -26,8 +26,10 @@ 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
@@ -225,6 +227,17 @@ 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()
}
@@ -566,9 +579,11 @@ 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) {
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS && displayOptions.displaySecondaryInformation != ContactSearchAdapter.DisplaySecondaryInformation.NEVER) {
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,6 +15,7 @@ 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
@@ -135,7 +136,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(query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, 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)
@@ -296,8 +297,8 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section))
}
private fun getGroupMembersSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(query))
private fun getGroupMembersSearchIterator(section: ContactSearchConfiguration.Section.GroupMembers, query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(section, query))
}
private fun <R> readContactData(
@@ -445,7 +446,7 @@ class ContactSearchPagedDataSource(
@WorkerThread
private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getGroupMembersSearchIterator(query).use { records ->
return getGroupMembersSearchIterator(section, query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
@@ -454,8 +455,9 @@ class ContactSearchPagedDataSource(
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(it)
val groupsInCommon = contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient)
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon)
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)
}
)
}
@@ -43,8 +43,8 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.querySignalContacts(contactsSearchQuery)
}
open fun queryGroupMemberContacts(query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "")
open fun queryGroupMemberContacts(section: ContactSearchConfiguration.Section.GroupMembers, query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "", section.groupId)
}
open fun getGroupSearchIterator(
@@ -7,6 +7,7 @@ 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
@@ -158,7 +159,7 @@ class ContactSearchView : AbstractComposeView {
longClickCallbacks = currentLongClickCallbacks ?: rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks = currentStoryContextMenuCallbacks ?: rememberDefaultContactSearchItemStoryContextMenuCallbacks(vm),
callButtonClickCallbacks = currentCallButtonClickCallbacks ?: rememberDefaultContactSearchItemCallButtonClickCallbacks(),
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()).fillMaxSize()
)
}
}
@@ -725,6 +725,17 @@ 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++;
@@ -1102,7 +1113,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean hasNoBubble(MessageRecord messageRecord) {
return MessageRecordUtil.hasNoBubble(messageRecord, context);
return MessageRecordUtil.hasNoBubble(messageRecord, context) && !(hasSticker(messageRecord) && hasQuote(messageRecord));
}
private boolean hasOnlyThumbnail(MessageRecord messageRecord) {
@@ -1470,7 +1481,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
} else if ((hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) || isBorderless(messageRecord)) {
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
if (hasNoBubble(messageRecord)) {
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
}
stickerStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
@@ -253,6 +253,7 @@ public final class MenuState {
hasText &&
!multiSelectRecord.getConversationMessage().getOriginalMessage().isFailed() &&
!hasPoll &&
!MessageRecordUtil.hasUndownloadedTextSlide(multiSelectRecord.getConversationMessage().getOriginalMessage()) &&
MessageConstraintsUtil.isValidEditMessageSend(multiSelectRecord.getConversationMessage().getOriginalMessage(), System.currentTimeMillis()));
}
@@ -8,6 +8,7 @@ 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
@@ -161,7 +162,7 @@ private fun MultiselectForwardContent(
viewModel = contactSearchViewModel,
mapStateToConfiguration = mapStateToConfiguration,
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.weight(1f),
additionalEntries = additionalEntries,
displayOptions = remember {
@@ -2489,7 +2489,17 @@ class ConversationFragment :
?: MediaUtil.IMAGE_WEBP
)
sendMessageWithoutComposeInput(slide = slide, clearCompose = clearCompose)
val quote = if (SignalStore.labs.stickerReplies) {
inputPanel.quote.orNull()
} else {
null
}
sendMessageWithoutComposeInput(slide = slide, quote = quote, clearCompose = clearCompose)
if (quote != null) {
inputPanel.clearQuote()
}
viewModel.updateStickerLastUsedTime(stickerRecord, System.currentTimeMillis().milliseconds)
}
@@ -66,7 +66,6 @@ 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
@@ -78,7 +77,7 @@ class CreatePollFragment : ComposeDialogFragment() {
companion object {
private val TAG = Log.tag(CreatePollFragment::class)
val MAX_QUESTION_CHARACTER_LENGTH = if (RemoteConfig.pollsV2) 200 else 100
const val MAX_QUESTION_CHARACTER_LENGTH = 200
const val MAX_CHARACTER_LENGTH = 100
const val MAX_OPTIONS = 10
const val MIN_OPTIONS = 2
@@ -27,7 +27,6 @@ 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
/**
@@ -49,7 +48,6 @@ 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?) {
@@ -131,15 +129,10 @@ 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 && !isPollsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter.and(removePollFilter))
} else if (!isPaymentsAvailable) {
if (!isPaymentsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter)
} else if (!isPollsAvailable) (
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePollFilter)
) else {
} else {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
}
}
@@ -2147,6 +2147,7 @@ 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,
@@ -2630,6 +2631,8 @@ 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,13 +109,11 @@ 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) {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE, readAt: Long = System.currentTimeMillis()) {
val allUnreadMissedCalls = readableDatabase
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.where("$TIMESTAMP <= ? AND $READ != ? AND ($EVENT = ? OR $EVENT = ?)", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -131,9 +129,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 proposedExpireStarted })
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to readAt })
for ((messageId, expiresIn) in expiringCalls) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
}
}
@@ -142,14 +140,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
}
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long): Call? {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long, readAt: Long = System.currentTimeMillis()): Call? {
val latestCallAsOfTimestamp = writableDatabase.withinTransaction { db ->
val unreadMissedCalls = db
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND $EVENT = ? AND $GROUP_CALL_ACTIVE = 0", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.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))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -164,9 +161,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 proposedExpireStarted })
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to readAt })
for ((messageId, expiresIn) in expiring) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
}
}
@@ -191,7 +188,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return readableDatabase
.count()
.from(TABLE_NAME)
.where("$EVENT = ? AND $READ = ?", Event.serialize(Event.MISSED), ReadState.serialize(ReadState.UNREAD))
.where("($EVENT = ? OR $EVENT = ?) AND $READ = ?", Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE), ReadState.serialize(ReadState.UNREAD))
.run()
.readToSingleLong()
}
@@ -1057,7 +1054,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.RINGING, Event.MISSED, Event.MISSED_NOTIFICATION_PROFILE, Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.DECLINED)
Event.RINGING, Event.MISSED, 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,26 +251,41 @@ 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 payments = db.select()
val notifiable = db.select()
.from(TABLE_NAME)
.where(where, *args)
.run()
.readToList(mapper = { InAppPayment.deserialize(it) })
.filter { it.isInUserNotifiableState() }
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where(where, *args)
.run()
for (payment in notifiable) {
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where(ID_WHERE, payment.id)
.run()
}
payments
notifiable
}
}
/**
* 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,6 +915,7 @@ 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(),
@@ -923,7 +924,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to dateReceived,
DATE_SENT to timestamp,
READ to 1,
NOTIFIED to 1,
NOTIFIED to if (missed) 0 else 1,
TYPE to type,
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
@@ -938,8 +939,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
notifyConversationListeners(threadId)
TrimThreadJob.enqueueAsync(threadId)
// If inserting an outgoing call from a sync message, automatically start timer
if (expiresIn != 0L && outgoing && fromSync) {
// If inserting a call from a sync message, automatically start timer unless it was missed
if (expiresIn != 0L && !missed && 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)
@@ -959,7 +960,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.values(
TYPE to type,
READ to 1,
NOTIFIED to 1
NOTIFIED to if (MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)) 0 else 1
)
.where("$ID = ?", messageId)
.run()
@@ -997,7 +998,8 @@ 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 markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || self.id == sender
val selfCreated = self.id == sender
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || selfCreated
val updateDetails: ByteArray = GroupCallUpdateDetails(
eraId = eraId,
startedCallUuid = Recipient.resolved(sender).requireServiceId().toString(),
@@ -1024,8 +1026,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val messageId = MessageId(db.insert(TABLE_NAME, null, values))
val isActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice
if (!isActiveCall) {
// Calls ringing from a linked (not local) device could be active
val isPotentialActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice || selfCreated
if (!isPotentialActiveCall) {
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.")
@@ -1122,6 +1125,7 @@ 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()
@@ -1140,7 +1144,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
val acknowledgedCall = localJoined || event == Event.DECLINED || selfCreated
finalizeEndedGroupCallMessage(db, message, acknowledgedCall, logPrefix = "[updateGroupCall]")
}
@@ -4330,7 +4334,8 @@ 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) OR $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)")
db.execSQL("INSERT OR IGNORE INTO $batchTable SELECT $ID FROM $TABLE_NAME WHERE $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()
}
pruneCollisions()
pruneCollision(collisionId)
}
}
@@ -473,6 +473,32 @@ 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,6 +3712,26 @@ 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,11 +6,14 @@
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 {
@@ -18,5 +21,9 @@ 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
}
@@ -0,0 +1,212 @@
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
)
}
}
}
@@ -67,9 +67,7 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) :
if (aciProfile.identityKey != encodedAciPublicKey) {
Log.w(TAG, "ACI identity key on profile differed from the one we have locally! Marking ourselves unregistered.")
SignalStore.account.setRegistered(false)
SignalStore.registration.clearRegistrationComplete()
SignalStore.registration.hasUploadedProfile = false
markUnregistered()
SignalStore.misc.lastConsistencyCheckTime = System.currentTimeMillis()
return
@@ -79,11 +77,11 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) :
val encodedPniPublicKey = Base64.encodeWithPadding(SignalStore.account.pniIdentityKey.publicKey.serialize())
if (pniProfile.identityKey != encodedPniPublicKey) {
Log.w(TAG, "PNI identity key on profile differed from the one we have locally!")
Log.w(TAG, "PNI identity key on profile differed from the one we have locally! Marking ourselves unregistered.")
SignalStore.account.setRegistered(false)
SignalStore.registration.clearRegistrationComplete()
SignalStore.registration.hasUploadedProfile = false
markUnregistered()
SignalStore.misc.lastConsistencyCheckTime = System.currentTimeMillis()
return
}
@@ -92,6 +90,13 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) :
SignalStore.misc.lastConsistencyCheckTime = System.currentTimeMillis()
}
/** Marks the account unregistered so the user is prompted to re-register. */
private fun markUnregistered() {
SignalStore.account.setRegistered(false)
SignalStore.registration.clearRegistrationComplete()
SignalStore.registration.hasUploadedProfile = false
}
override fun onShouldRetry(e: Exception): Boolean {
return e is IOException
}
@@ -87,10 +87,7 @@ class CheckKeyTransparencyJob private constructor(
}
private fun canRunJob(): Boolean {
return if (!RemoteConfig.internalUser) {
Log.i(TAG, "Remote config is not on. Exiting.")
false
} else if (!SignalStore.account.isRegistered) {
return 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.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.util.PlayServicesUtil;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
@@ -13,6 +13,7 @@ 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
@@ -58,14 +59,20 @@ class InAppPaymentStripeRecurringSetupJob private constructor(
}
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
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)
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())
}
}
@@ -18,6 +18,7 @@ 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
@@ -339,6 +340,12 @@ 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,7 +6,9 @@ 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;
@@ -16,13 +18,12 @@ 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;
@@ -95,12 +96,26 @@ 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();
@@ -113,7 +128,6 @@ 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 +
@@ -135,8 +149,6 @@ 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.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.")
return
}
@@ -72,6 +72,11 @@ 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,6 +52,11 @@ 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
return (maybeEmojiQuery + searchResults).distinctBy { it.rowId }
}
@WorkerThread
@@ -327,15 +327,17 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
}
}
/** Set an identity key pair for the PNI identity via change number. */
fun setPniIdentityKeyAfterChangeNumber(key: IdentityKeyPair) {
fun setNumberAndPniIdentity(e164: String, pni: PNI, pniRegistrationId: Int, pniIdentityKeyPair: IdentityKeyPair) {
synchronized(this) {
Log.i(TAG, "Setting a new PNI identity key pair.")
Log.i(TAG, "Setting the E164, PNI, PNI registration ID, and PNI identity key pair.")
store
.beginWrite()
.putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, key.publicKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, key.privateKey.serialize())
.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())
.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, true).falseForExternalUsers()
var callingSetAudioConfig by booleanValue(CALLING_SET_AUDIO_CONFIG, false).falseForExternalUsers()
/**
* If overriding the audio settings, use the Oboe ADM or not.
@@ -8,8 +8,9 @@ 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
@@ -24,10 +25,12 @@ 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,6 +4,8 @@ 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
@@ -20,14 +22,12 @@ 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
@@ -1,69 +0,0 @@
/*
* 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()
}
}
@@ -0,0 +1,36 @@
/*
* 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,7 +114,10 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
@Parcelize
sealed class CallLinks : Calls {
@Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
data class EditCallLinkName(
val callLinkRoomId: CallLinkRoomId,
val currentName: String = ""
) : CallLinks() {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
@@ -27,7 +27,6 @@ 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
@@ -35,6 +34,7 @@ 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,6 +86,20 @@ 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
@@ -97,10 +111,10 @@ class MainNavigationViewModel(
.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()
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 internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -153,12 +167,6 @@ class MainNavigationViewModel(
performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state ->
state.copy(storyFailure = hasFailedStories)
}
viewModelScope.launch {
internalDetailLocation.collect { location ->
updateActiveStateForLocation(location)
}
}
}
fun onPaneAnchorChanged(isFullScreenPane: Boolean) {
@@ -173,7 +181,7 @@ class MainNavigationViewModel(
this@MainNavigationViewModel.isSplitPane = isSplitPane
if (!isSplitPane) {
if (chatsBackStack.isEmpty) {
if (currentTabBackStack?.isEmpty == true) {
lockPaneToSecondary = true
setFocusedPane(ThreePaneScaffoldRole.Secondary)
}
@@ -204,9 +212,8 @@ class MainNavigationViewModel(
setFocusedPane(role)
}
earlyNavigationDetailLocationRequested?.let { detail ->
earlyNavigationDetailLocationRequested?.let {
lockPaneToSecondary = false
updateActiveStateForLocation(detail)
}
return this.navigator!!
@@ -246,20 +253,6 @@ 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)
@@ -292,12 +285,13 @@ 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.CallLinkDetails,
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
is MainNavigationDetailLocation.Stories -> {
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
@@ -333,15 +327,29 @@ class MainNavigationViewModel(
setFocusedPane(ThreePaneScaffoldRole.Primary)
}
fun popChatsDetailLocation() {
chatsBackStack.pop()
if (chatsBackStack.isEmpty) {
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) {
lockPaneToSecondary = true
popDetailPane()
}
}
private fun clearDetailLocation(backStack: ChatsBackStack) {
private fun clearDetailLocation(backStack: MainDetailBackStack) {
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, 0, media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, media.getDuration(), 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, 0, media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
return new Media(media.getUri(), media.getContentType(), media.getDate(), width, height, size, media.getDuration(), media.isBorderless(), media.isVideoGif(), media.getBucketId(), media.getCaption(), null, null);
}
@VisibleForTesting
@@ -1,24 +1,16 @@
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.CursorUtil
import org.signal.core.util.ContentTypeUtil
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) {
@@ -30,7 +22,7 @@ class MediaCaptureRepository(context: Context) {
dataSupplier = { data },
getLength = { data.size.toLong() },
createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) },
mimeType = MediaUtil.IMAGE_JPEG,
mimeType = ContentTypeUtil.IMAGE_JPEG,
width = width,
height = height
)
@@ -96,76 +88,4 @@ 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,14 +13,22 @@ 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.
@@ -63,6 +71,29 @@ 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()
}
}
}
)
@@ -0,0 +1,54 @@
/*
* 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,6 +49,8 @@ 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
/**
@@ -195,6 +197,12 @@ 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)
SignalDatabase.calls.markAllCallEventsRead(timestamp, envelopeTimestamp)
}
CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION -> {
@@ -1385,7 +1385,7 @@ object SyncMessageProcessor {
return
}
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp)
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, envelopeTimestamp)
}
else -> log(envelopeTimestamp, "Synchronize call log event has an invalid type $eventType, ignoring.")
@@ -1896,11 +1896,6 @@ 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
@@ -1934,8 +1929,6 @@ 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
@@ -1947,6 +1940,8 @@ 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(
@@ -206,9 +206,10 @@ public class ApplicationMigrations {
static final int NOTIFICATION_INDEX_MIGRATION = 162;
static final int NOTIFICATION_STATE_CLEANUP = 163;
static final int KT_USERNAME_CAPABILITY = 164;
static final int FIX_CHANGE_NUMBER_ERROR_2 = 165;
}
public static final int CURRENT_VERSION = 164;
public static final int CURRENT_VERSION = 165;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -847,6 +848,10 @@ public class ApplicationMigrations {
jobs.put(Version.FIX_CHANGE_NUMBER_ERROR, new FixChangeNumberErrorMigrationJob());
}
if (lastSeenVersion < Version.FIX_CHANGE_NUMBER_ERROR_2) {
jobs.put(Version.FIX_CHANGE_NUMBER_ERROR_2, new FixChangeNumberErrorMigrationJob());
}
if (lastSeenVersion < Version.CHAT_FOLDER_STORAGE_SYNC) {
jobs.put(Version.CHAT_FOLDER_STORAGE_SYNC, new SyncChatFoldersMigrationJob());
}
@@ -1,18 +1,27 @@
package org.thoughtcrime.securesms.migrations
import kotlinx.coroutines.runBlocking
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberRepository
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.io.IOException
/**
* There was a server error during change number where a number was changed but gave back a 409 response.
* We need devices to re-fetch their E164+PNI's, save them, and then get prekeys.
* A server-side bug during change number can commit the number/PNI change but return an error, leaving the client desynced
* (local still on the old number/PNI). This detects that via whoami and reconciles the local number/PNI. Before adopting
* the PNI identity from the pending metadata, it verifies that key against the server's published PNI identity, so
* a stale/overwritten metadata key is never blindly applied.
*/
internal class FixChangeNumberErrorMigrationJob(
parameters: Parameters = Parameters.Builder().build()
@@ -46,14 +55,22 @@ internal class FixChangeNumberErrorMigrationJob(
when (val result = SignalNetwork.account.whoAmI()) {
is NetworkResult.Success<WhoAmIResponse> -> {
val pni = result.result.pni?.let { ServiceId.PNI.parseOrNull(it) } ?: return
val serverPni = result.result.pni?.let { ServiceId.PNI.parseOrNull(it) } ?: return
if (result.result.number != SignalStore.account.e164 || pni != SignalStore.account.pni) {
Log.w(TAG, "Detected a number or PNI mismatch! Fixing...")
ChangeNumberRepository().changeLocalNumber(result.result.number, pni)
if (result.result.number == SignalStore.account.e164 && serverPni == SignalStore.account.pni) {
Log.i(TAG, "No number or PNI mismatch detected.")
return
}
Log.w(TAG, "Detected a number or PNI mismatch! Verifying PNI identity key against the server before fixing...")
if (pendingPniIdentityMatchesServer(pendingChangeNumberMetadata, serverPni)) {
Log.w(TAG, "PNI identity key matches server. Fixing local number/PNI...")
ChangeNumberRepository().changeLocalNumber(result.result.number, serverPni)
Log.w(TAG, "Done!")
} else {
Log.i(TAG, "No number or PNI mismatch detected.")
Log.w(TAG, "Server PNI identity does not match pending metadata (or could not be verified); cannot safely reconcile. Enqueuing AccountConsistencyWorkerJob.")
AppDependencies.jobManager.add(AccountConsistencyWorkerJob())
return
}
}
@@ -63,6 +80,43 @@ internal class FixChangeNumberErrorMigrationJob(
}
}
private fun pendingPniIdentityMatchesServer(metadata: PendingChangeNumberMetadata, pni: ServiceId.PNI): Boolean {
val metadataIdentityKey: IdentityKey = try {
IdentityKeyPair(metadata.pniIdentityKeyPair.toByteArray()).publicKey
} catch (e: Exception) {
Log.w(TAG, "Could not parse PNI identity key from pending metadata.", e)
return false
}
val serverIdentityKey: IdentityKey = when (val profileResult = runBlocking { SignalNetwork.profile.getUnversionedProfile(pni, null) }) {
is NetworkResult.Success -> {
val identityKey = profileResult.result.identityKey
if (identityKey == null) {
Log.w(TAG, "Server profile for PNI has no identity key; cannot verify.")
return false
}
try {
IdentityKey(Base64.decode(identityKey), 0)
} catch (e: Exception) {
Log.w(TAG, "Could not parse server PNI identity key.", e)
return false
}
}
is NetworkResult.NetworkError -> throw profileResult.exception
is NetworkResult.StatusCodeError -> {
if (profileResult.code == 404) {
Log.w(TAG, "Could not fetch server profile for PNI (code=${profileResult.code}); cannot verify identity key.")
return false
} else {
throw profileResult.exception
}
}
is NetworkResult.ApplicationError -> throw profileResult.throwable
}
return serverIdentityKey.serialize().contentEquals(metadataIdentityKey.serialize())
}
override fun shouldRetry(e: Exception): Boolean = e is IOException
class Factory : Job.Factory<FixChangeNumberErrorMigrationJob> {
@@ -45,7 +45,7 @@ public final class DeviceTransferBlockingInterceptor implements Interceptor {
.receivedResponseAtMillis(System.currentTimeMillis())
.message("")
.body(ResponseBody.create(null, ""))
.code(500)
.code(555)
.build();
}
@@ -60,6 +60,6 @@ public final class DeviceTransferBlockingInterceptor implements Interceptor {
public void unblockNetwork() {
blockNetworking = false;
SignalExecutors.UNBOUNDED.execute(AppDependencies::startNetwork);
AppDependencies.resetNetwork();
}
}
@@ -110,7 +110,7 @@ public class MarkReadReceiver extends BroadcastReceiver {
.collect(Collectors.toList()));
for (RecipientId peer : peers) {
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp);
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, System.currentTimeMillis());
if (lastCallInThread != null) {
AppDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsReadInConversation(lastCallInThread));
}
@@ -51,7 +51,6 @@ 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
@@ -339,11 +338,11 @@ class Recipient(
/** Whether calls should break through mute for this recipient. */
val callNotificationSetting: NotificationSetting
get() = if (RemoteConfig.internalUser) callNotificationSettingValue else NotificationSetting.ALWAYS_NOTIFY
get() = if (SignalStore.labs.muteBreakthroughNotifications) 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 (RemoteConfig.internalUser) replyNotificationSettingValue else mentionSetting
get() = if (groupIdValue == null) NotificationSetting.DO_NOT_NOTIFY else if (SignalStore.labs.muteBreakthroughNotifications) replyNotificationSettingValue else mentionSetting
/** The state around whether we can send sealed sender to this user. */
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {

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