mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-19 12:04:41 +01:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a6b0cbfef | |||
| 86169b7368 | |||
| d4d8b54232 | |||
| e74ed9dbf3 | |||
| e1687715d3 | |||
| 447f29521b | |||
| c029d12b22 | |||
| 417c0f98ad | |||
| be41a24c0c | |||
| b95815c963 | |||
| 15d86c9f13 | |||
| 5c233daf38 | |||
| b91ee27620 | |||
| c03ae68ef2 | |||
| b6f6d1e42f | |||
| db899e92f5 | |||
| 99b89743a7 | |||
| 2bc2ae2867 | |||
| 300e5bf6f6 | |||
| 17d6fe918d | |||
| e71c4734bd | |||
| 7d39ad04e3 | |||
| 1ee4c590b7 | |||
| 4a0325ea55 | |||
| c4faafa9ae | |||
| ee11d1fd3b | |||
| c1c6db2b3b | |||
| 5372814468 | |||
| e5ed84f08c | |||
| ab90969b0e | |||
| ebd64305bb | |||
| f3b7368043 | |||
| a7de53333d | |||
| 36898a1e3a | |||
| ec18fb4317 | |||
| dd671a8489 | |||
| bcb5c37ed6 | |||
| a4a6117788 | |||
| bd42444e00 | |||
| b10cd9c5d3 | |||
| b87abdabfa | |||
| 91965d205a | |||
| 55b5997a4e | |||
| bdb6b7d5f1 | |||
| 97740b34eb | |||
| 5ed1bdc26a | |||
| 7505ef1834 | |||
| 07afbb5a8f | |||
| 613a07eb2c | |||
| 0943030efa | |||
| 5b266081db | |||
| 8536504318 | |||
| 495b5ef9be | |||
| bfdc628dd2 | |||
| 80aa521e42 | |||
| 9f1c8961f0 | |||
| 8eeffff923 | |||
| e07a3e05bf | |||
| 2c35a6a3fe | |||
| 248a787d74 | |||
| e76a584bb7 | |||
| 5a9cf128a0 | |||
| c7ac7087f7 | |||
| 9e2be2796b | |||
| 3f850375c1 | |||
| c91b739c1b | |||
| 600acb82bd | |||
| 7d6a31b254 | |||
| 56378c7166 | |||
| 987f5158d0 | |||
| 76a1d89954 | |||
| 72fd5d30df | |||
| f74f0e5109 | |||
| 6e03627c47 | |||
| 837598657d | |||
| cf5aa92f84 | |||
| 1613adf088 | |||
| cc01d39240 | |||
| b29bae50af |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
java openjdk-17.0.2
|
||||
java openjdk-21.0.2
|
||||
uv latest
|
||||
|
||||
@@ -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)
|
||||
|
||||
+76
@@ -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()))
|
||||
}
|
||||
}
|
||||
+62
@@ -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(
|
||||
|
||||
+159
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+33
-3
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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)
|
||||
|
||||
-71
@@ -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()
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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() {
|
||||
|
||||
-7
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+32
-7
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+19
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-22
@@ -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())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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() }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
+18
-9
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
)
|
||||
|
||||
+11
-7
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-23
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-14
@@ -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() {
|
||||
|
||||
-42
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-15
@@ -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()
|
||||
|
||||
+13
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+8
-6
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
+2
-9
@@ -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 =
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
|
||||
+15
-8
@@ -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()
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-82
@@ -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());
|
||||
}
|
||||
|
||||
+61
-7
@@ -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> {
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user