Compare commits

..

66 Commits

Author SHA1 Message Date
Cody Henthorne 1043851423 Bump version to 8.9.1 2026-04-27 16:40:08 -04:00
Cody Henthorne 9bcbacc3d8 Update translations and other static files. 2026-04-27 16:40:08 -04:00
Cody Henthorne c2d7ee6926 Update release notes chat styling. 2026-04-27 16:39:59 -04:00
jeffrey-signal ceecacb47e Fix pull-to-refresh triggering when attempting to scroll up in contact search.
Resolves signalapp/Signal-Android#14742
2026-04-27 16:09:56 -04:00
Greyson Parrelli f4986273e4 Fix quote resolution failing for sent sync messages. 2026-04-27 13:34:52 -04:00
Greyson Parrelli 5f60adbe69 Update emoji_data.json 2026-04-27 13:34:52 -04:00
Alex Hart db6efeaf3d Revert "Migrate VerifyScanFragment to compose."
This reverts commit 9fa587b7e4.
2026-04-23 16:29:35 -03:00
Alex Hart 9b98b03971 Bump version to 8.9.0 2026-04-22 16:00:26 -03:00
Alex Hart dfbdf30535 Update baseline profile. 2026-04-22 15:49:27 -03:00
Alex Hart d567555047 Update translations and other static files. 2026-04-22 15:18:32 -03:00
Alex Hart 7658f6c36c Migrate ideal icon and copy. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51bd2d51c6 Add missing remember keys for pane anchors and preferred width to fix stale layout on resize. 2026-04-22 15:12:47 -03:00
Jesse Weinstein a00978d96e Two trivial parameter renaming fixes
Closes signalapp/Signal-Android#14736
2026-04-22 15:12:47 -03:00
jeffrey-signal b700529c3b Fix stuck outgoing messages when there a no remaining linked devices. 2026-04-22 15:12:47 -03:00
jeffrey-signal 4051cf739c Fix crash when long-pressing a message when in conversation bubble mode. 2026-04-22 15:12:47 -03:00
Alex Hart 6031fc9113 Modify heuristic for split-pane determination. 2026-04-22 15:12:47 -03:00
Michelle Tang 454fe86dda Adjust spinner in phone number screen. 2026-04-22 15:12:47 -03:00
Michelle Tang 92927ec69b Clear existing key transparency data. 2026-04-22 15:12:47 -03:00
Alex Hart 9fa587b7e4 Migrate VerifyScanFragment to compose. 2026-04-22 15:12:47 -03:00
Jesse Weinstein 552361dff4 Remove unused StickyListHeaders dependency.
Closes signalapp/Signal-Android#14729
2026-04-22 15:12:47 -03:00
Greyson Parrelli 78a25a6186 Restrict setExactAndAllowWhileIdle to API >= 31. 2026-04-22 15:12:47 -03:00
Cody Henthorne 58fcc07578 Convert flakey MessageTable story instrumentation tests to unit tests. 2026-04-22 15:12:47 -03:00
Cody Henthorne 8cd92a400c Add debug and testing apis to Spinner. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 5d207932c9 Check instrumentation compilation in qa task. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 7c147982c4 Supply size to attachment form endpoint for archive backfill. 2026-04-22 15:12:47 -03:00
Alex Hart bde1a94122 Parallelize file deletion when turning off local backups. 2026-04-22 15:12:47 -03:00
Alex Hart 2b66d7485a Fix PiP on-back behavior.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-22 15:12:47 -03:00
Greyson Parrelli 017b902c3c Increase regV5 test coverage. 2026-04-22 15:12:47 -03:00
dependabot[bot] 357fbfa8aa Update reproducible build dependencies. 2026-04-22 15:12:47 -03:00
Michelle Tang 0ce667f4af Update enter aep for large screens. 2026-04-22 15:12:47 -03:00
jeffrey-signal c4d78243c8 Fix message backups education screen typo. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51e12b2c76 Add flag to try different alarm for scheduled messages. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 4dea1d8aa1 Move storage service operations into the network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 89c645dea3 Create a network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli cd01d5f0b7 Enable remote mute for external users. 2026-04-17 17:00:09 -04:00
Michelle Tang 8730e28282 Update restore selection for large screens. 2026-04-17 16:31:59 -04:00
Greyson Parrelli 82046dd55f Add support for remote backup restore to regV5. 2026-04-17 15:54:24 -04:00
Cody Henthorne 76e30ab09f Add verified group title tracking and syncing. 2026-04-17 15:52:56 -04:00
Greyson Parrelli f680256f1d Remove range from copyright. 2026-04-17 15:26:52 -04:00
Michelle Tang da590a3241 Update verification code screen. 2026-04-17 15:26:52 -04:00
Alex Hart 91f73b473f Sanitize donations webview intents.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:52 -04:00
Alex Hart 53023517b3 Add authority check to VoiceCallShare. 2026-04-17 15:26:52 -04:00
Alex Hart 7f831e6806 Convert SafetyNumberReview dialogs to compose. 2026-04-17 15:26:51 -04:00
Alex Hart 77a18111e1 Convert search mediator to compose / viewmodel pattern. 2026-04-17 15:26:51 -04:00
Alex Hart 2a699a23dd Fix backup key verification megaphone ignoring snooze by using lastSeenTime.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:51 -04:00
Alex Hart 5643ffc1a9 Trust zero bottom inset when gesture navigation is detected on API <= 29. 2026-04-17 15:26:51 -04:00
Cody Henthorne 90207b7dd7 Convert handful of recipient/db heavy androidTests to regular unit tests. 2026-04-17 15:26:50 -04:00
andrew-signal 5b7f668251 Bump libsignal to v0.92.2 2026-04-17 15:26:50 -04:00
adel-signal 798bf3ec3e Update to RingRTC v2.68.0 2026-04-17 15:26:50 -04:00
Michelle Tang 1c77c9d3fb Check for valid phone number. 2026-04-17 15:26:50 -04:00
Michelle Tang dd52d78ee0 Update country picker for large screen. 2026-04-17 15:26:50 -04:00
Michelle Tang 4b1acca119 Scroll to initial country. 2026-04-17 15:26:49 -04:00
Michelle Tang 195fe60927 Update phone entry for large screen. 2026-04-17 15:26:49 -04:00
Cody Henthorne f427f31303 Improve group change defensive checks and update logic. 2026-04-17 15:26:49 -04:00
Greyson Parrelli fa19ed7ffc Use viewmodel entry scoping in regV5. 2026-04-17 15:26:49 -04:00
jeffrey-signal e5e99d4e03 Bump version to 8.8.2 2026-04-17 15:21:14 -04:00
jeffrey-signal 26d1a7ada7 Update baseline profile. 2026-04-17 15:00:00 -04:00
jeffrey-signal 5dd11e26e4 Update translations and other static files. 2026-04-17 14:53:54 -04:00
Alex Hart 9877b13c6e Add ability to launch into message backups checkout.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-04-17 12:33:52 -03:00
Greyson Parrelli d7d0fd3622 Rotate backup megaphone flag. 2026-04-17 10:09:12 -04:00
Sten Tijhuis 2439506c05 Update GitHub Actions versions and pin to commit SHAs.
Closes signalapp/Signal-Android#14715
2026-04-16 19:07:34 -04:00
jeffrey-signal 6088024f76 Revert "Use existing okhttp client + package checks for web apk."
This reverts commit df406633ff.
2026-04-16 19:01:09 -04:00
jeffrey-signal 9decd81cfc Bump version to 8.8.1 2026-04-16 14:25:08 -04:00
jeffrey-signal f27773a4e3 Update baseline profile. 2026-04-16 13:27:52 -04:00
jeffrey-signal 8d8c974a19 Update translations and other static files. 2026-04-16 13:20:10 -04:00
Cody Henthorne 1a3e81dcb0 Fix bad apostrophe escaping in new safety tip strings. 2026-04-16 13:08:59 -04:00
487 changed files with 23212 additions and 11717 deletions
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
# while leaving any extra documentation comments intact.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "actions/*"
gradle-actions:
patterns:
- "gradle/*"
peter-evans:
patterns:
- "peter-evans/*"
usefulness:
patterns:
- "usefulness/*"
+8 -4
View File
@@ -16,26 +16,30 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Build with Gradle
run: ./gradlew qa
- name: Archive reports for failed build
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
+19 -10
View File
@@ -14,15 +14,17 @@ jobs:
assemble-base:
if: ${{ github.repository != 'signalapp/Signal-Android' }}
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
@@ -32,11 +34,13 @@ jobs:
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
key: diffuse-${{ github.event.pull_request.base.sha }}
@@ -49,7 +53,8 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
clean: 'false'
@@ -61,18 +66,21 @@ jobs:
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
- id: diffuse
uses: usefulness/diffuse-action@v1
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
with:
old-file-path: diffuse-base.apk
new-file-path: diffuse-new.apk
- uses: peter-evans/find-comment@v2
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: Diffuse output
- uses: peter-evans/create-or-update-comment@v3
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
with:
body: |
Diffuse output:
@@ -83,7 +91,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: diffuse-output
path: ${{ steps.diffuse.outputs.diff-file }}
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
cd reproducible-builds
+2 -1
View File
@@ -14,7 +14,8 @@ jobs:
actions: write
steps:
- uses: actions/stale@v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
days-before-close: 7
+2 -2
View File
@@ -1,6 +1,6 @@
# Signal Android
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
@@ -63,7 +63,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2025 Signal Messenger, LLC
Copyright 2013 Signal Messenger, LLC
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
+3 -6
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1680
val canonicalVersionName = "8.8.0"
val canonicalVersionCode = 1684
val canonicalVersionName = "8.9.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -597,6 +597,7 @@ dependencies {
implementation(project(":lib:archive"))
implementation(project(":lib:libsignal-service"))
implementation(project(":lib:network"))
implementation(project(":lib:paging"))
implementation(project(":core:util"))
implementation(project(":lib:glide"))
@@ -678,7 +679,6 @@ dependencies {
implementation(libs.mobilecoin)
implementation(libs.signal.ringrtc)
implementation(libs.leolin.shortcutbadger)
implementation(libs.emilsjolander.stickylistheaders)
implementation(libs.glide.glide)
implementation(libs.roundedimageview)
implementation(libs.materialish.progress)
@@ -689,9 +689,6 @@ dependencies {
implementation(libs.subsampling.scale.image.view) {
exclude(group = "com.android.support", module = "support-annotations")
}
implementation(libs.android.tooltips) {
exclude(group = "com.android.support", module = "appcompat-v7")
}
implementation(libs.lottie)
implementation(libs.lottie.compose)
implementation(libs.signal.android.database.sqlcipher)
@@ -4,12 +4,12 @@ import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import org.signal.core.util.billing.BillingApi
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
@@ -60,10 +63,12 @@ import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
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.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -86,6 +92,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
@@ -118,7 +125,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private RecyclerView recyclerView;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
@@ -127,8 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@@ -239,7 +248,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
handleContactPermissionGranted();
} else {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
}
@@ -247,29 +256,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
recyclerView.setAlpha(1f);
}
});
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
lifecycleDisposable = new LifecycleDisposable();
@@ -284,12 +278,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
if (fragmentArgs.getRecyclerPadBottom() != -1) {
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
}
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
@@ -303,6 +291,26 @@ public final class ContactSelectionListFragment extends LoggingFragment {
currentSelection = getCurrentSelection();
Set<ContactSearchKey> fixedContacts = currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(Collectors.toSet());
contactSearchViewModel = new ViewModelProvider(
this,
new ContactSearchViewModel.Factory(
selectionLimit,
isMulti,
new ContactSearchRepository(),
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
@@ -311,24 +319,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (hideLetterHeaders()) {
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = layoutManager.findFirstVisibleItemPosition();
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = recyclerView.getChildAt(0);
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
@@ -337,13 +341,104 @@ public final class ContactSelectionListFragment extends LoggingFragment {
headerActionView.setEnabled(false);
}
contactSearchMediator = new ContactSearchMediator(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(Collectors.toSet()),
selectionLimit,
isMulti,
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
@@ -351,94 +446,31 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false
),
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
new ContactSearchCallbacks.Simple() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchMediator.refresh();
}
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
),
new ContactSelectionListAdapter.ArbitraryRepository()
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
}
});
}
);
return view;
@@ -461,30 +493,30 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return Collections.emptyList();
}
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(Collectors.toList());
return contactSearchViewModel.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(Collectors.toList());
}
public int getSelectedContactsCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return contactSearchMediator.getSelectedContacts().size();
return contactSearchViewModel.getSelectedContacts().size();
}
public int getTotalMemberCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize();
}
private Set<RecipientId> getCurrentSelection() {
@@ -500,36 +532,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
.request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
.ifNecessary()
.onAllGranted(() -> {
recyclerView.setAlpha(0.5f);
contactSearchView.setAlpha(0.5f);
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
if (onRefreshListener != null) {
swipeRefresh.setRefreshing(true);
onRefreshListener.onRefresh();
}
}
})
.onAnyDenied(() -> contactSearchMediator.refresh())
.onAnyDenied(() -> contactSearchViewModel.refresh())
.withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager())
.execute();
}
private void initializeCursor() {
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
if (scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
}
});
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
@@ -547,7 +566,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.resetPositionOnCommit = true;
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
contactSearchViewModel.setQuery(filter);
}
public void resetQueryFilter() {
@@ -558,7 +577,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void onDataRefreshed() {
this.resetPositionOnCommit = true;
swipeRefresh.setRefreshing(false);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
public boolean hasQueryFilter() {
@@ -574,26 +593,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void reset() {
contactSearchMediator.clearSelection();
contactSearchMediator.refresh();
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit) {
if (resetPositionOnCommit && innerRecyclerView != null) {
resetPositionOnCommit = false;
recyclerView.scrollToPosition(0);
innerRecyclerView.scrollToPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(recyclerView);
fastScroller.setRecyclerView(innerRecyclerView);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
@@ -660,8 +678,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.filter(r -> !contactSearchViewModel.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(Collectors.toSet());
@@ -688,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
if (allowed) {
@@ -705,7 +723,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@@ -772,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
} else {
return false;
}
@@ -793,7 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void markContactSelected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@@ -803,7 +821,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@@ -865,8 +883,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
AutoTransition transition = new AutoTransition();
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
transition.excludeChildren(recyclerView, true);
transition.excludeTarget(recyclerView, true);
transition.excludeChildren(contactSearchView, true);
transition.excludeTarget(contactSearchView, true);
TransitionManager.beginDelayedTransition(constraintLayout, transition);
ConstraintSet constraintSet = new ConstraintSet();
@@ -44,7 +44,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.fragment.app.DialogFragment
@@ -73,7 +73,6 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import androidx.window.core.layout.WindowSizeClass
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
@@ -88,6 +87,7 @@ import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
@@ -429,15 +429,15 @@ class MainActivity :
)
}
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val wrappedNavigator = rememberNavigator(isSplitPane, contentLayoutData, maxWidth)
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val navigationType = NavigationType.rememberNavigationType()
val anchors = remember(contentLayoutData, mainToolbarState) {
val anchors = remember(contentLayoutData, mainToolbarState, listPaneWidth, navigationType) {
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val detailOffset = when {
@@ -465,7 +465,7 @@ class MainActivity :
anchors.indexOf(paneExpansionState.currentAnchor)
}
LaunchedEffect(windowSizeClass) {
LaunchedEffect(anchors) {
val index = when {
paneAnchorIndex < 0 -> 1
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
@@ -478,7 +478,7 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
@@ -624,7 +624,7 @@ class MainActivity :
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
if (!LocalResources.current.rememberIsSplitPane()) {
Spacer(Modifier.navigationBarsPadding())
}
}
@@ -640,7 +640,7 @@ class MainActivity :
}
},
secondaryContent = {
val listContainerColor = if (windowSizeClass.isSplitPane()) {
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
@@ -781,12 +781,12 @@ class MainActivity :
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun rememberNavigator(
windowSizeClass: WindowSizeClass,
isSplitPane: Boolean,
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): AppScaffoldNavigator<Any> {
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane = windowSizeClass.isSplitPane(),
isSplitPane = isSplitPane,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
@@ -800,18 +800,18 @@ class MainActivity :
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) {
SignalTheme {
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
val backgroundColor = if (!isSplitPane) {
MaterialTheme.colorScheme.surface
} else {
SignalTheme.colors.colorSurface1
}
val modifier = when {
windowSizeClass.isSplitPane() -> {
isSplitPane -> {
Modifier
.systemBarsPadding()
.displayCutoutPadding()
@@ -99,8 +99,6 @@ object ApkUpdateInstaller {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -66,6 +66,7 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -161,7 +162,6 @@ import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
@@ -1690,10 +1690,10 @@ object BackupRepository {
*
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
*/
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
fun getAttachmentUploadForm(uploadLength: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess)
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength)
}
}
@@ -2094,7 +2094,7 @@ object BackupRepository {
}
/**
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
* See [org.signal.network.api.ArchiveApi.getSvrBAuthorization].
*/
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
return initBackupAndFetchAuth()
@@ -14,6 +14,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import org.signal.archive.local.ArchivedFilesReader
import org.signal.core.models.backup.MediaName
import org.signal.core.util.Stopwatch
@@ -122,6 +123,57 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
fun openInputStream(context: Context, uri: Uri): InputStream? {
return context.contentResolver.openInputStream(uri)
}
/**
* Recursively delete the entire SignalBackups directory using parallelized SAF calls.
*/
@JvmStatic
@JvmOverloads
fun deleteAll(signalBackupsDir: DocumentFile, progressListener: AllFilesProgressListener? = null) {
Log.i(TAG, "Deleting all backup data")
val units = mutableListOf<DocumentFile>()
for (child in signalBackupsDir.listFiles()) {
if (child.isDirectory && child.name == "files") {
units += child.listFiles()
} else {
units += child
}
}
if (units.isEmpty()) {
signalBackupsDir.delete()
return
}
val total = units.size
val completed = AtomicInteger(0)
val deleted = AtomicInteger(0)
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
runBlocking {
coroutineScope {
units.chunked(chunkSize).map { chunk ->
async(Dispatchers.IO) {
for (unit in chunk) {
if (unit.delete()) {
deleted.incrementAndGet()
}
progressListener?.onProgress(completed.incrementAndGet(), total)
}
}
}.awaitAll()
}
}
for (child in signalBackupsDir.listFiles()) {
child.delete()
}
signalBackupsDir.delete()
Log.d(TAG, "Deleted ${deleted.get()}/$total top-level units")
}
}
private val signalBackups: DocumentFile
@@ -236,8 +288,14 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
/**
* Clean up unused files in the shared files directory leveraged across all current snapshots. A file
* is unused if it is not referenced directly by any current snapshots.
*
* @param allFilesProgressListener reports progress of the enumeration phase (fast, 256 shards)
* @param deletionProgressListener reports progress of the deletion phase (slow, potentially thousands of SAF calls). Fires from multiple threads.
*/
fun deleteUnusedFiles(allFilesProgressListener: AllFilesProgressListener? = null) {
fun deleteUnusedFiles(
allFilesProgressListener: AllFilesProgressListener? = null,
deletionProgressListener: AllFilesProgressListener? = null
) {
Log.i(TAG, "Deleting unused files")
val allFiles: MutableMap<String, DocumentFileInfo> = filesFileSystem.allFiles(allFilesProgressListener).toMutableMap()
@@ -251,16 +309,38 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
}
}
var deleted = 0
allFiles
.values
.forEach {
if (it.documentFile.delete()) {
deleted++
}
}
val toDelete = allFiles.values.toList()
val total = toDelete.size
if (total == 0) {
Log.d(TAG, "Cleanup removed 0/0 files")
return
}
Log.d(TAG, "Cleanup removed $deleted/${allFiles.size} files")
val deleted = AtomicInteger(0)
val completed = AtomicInteger(0)
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
runBlocking {
supervisorScope {
toDelete.chunked(chunkSize).map { chunk ->
async(Dispatchers.IO) {
try {
for (info in chunk) {
if (info.documentFile.delete()) {
deleted.incrementAndGet()
}
deletionProgressListener?.onProgress(completed.incrementAndGet(), total)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clean up a chunk.", e)
}
}
}.awaitAll()
}
}
Log.d(TAG, "Cleanup removed ${deleted.get()}/$total files")
}
/** Useful metadata for a given archive snapshot */
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -27,6 +26,7 @@ import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
@@ -43,7 +43,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeDialogFragment
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
@@ -109,7 +109,7 @@ fun EditCallLinkNameScreen(
onNavigationClick = {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
},
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
)
}
@@ -15,12 +15,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
@@ -37,7 +37,7 @@ import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.ringrtc.CallLinkState.Restrictions
@@ -83,7 +83,7 @@ fun CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
)
}
@@ -23,7 +23,6 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
@@ -133,7 +132,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val filteredCount = callLogAdapter.submitCallRows(
data,
selected,
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
activeCallLogRowId = activeRowId.orNull().takeIf { resources.isSplitPane() },
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
scrollToPositionDelegate::notifyListCommitted
)
@@ -187,7 +186,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
if (!resources.getWindowSizeClass().isSplitPane()) {
if (!resources.isSplitPane()) {
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
}
@@ -152,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29 && !ViewUtil.isGestureNavigation(resources, insets)) {
ViewUtil.getNavigationBarHeight(resources)
} else {
windowInsets.bottom
@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Shader
import android.graphics.drawable.Drawable
/**
* Draws [bitmap] as a repeating tiled pattern rotated by [rotationDegrees].
*/
class RotatedTiledDrawable(
private val bitmap: Bitmap,
private val rotationDegrees: Float
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
override fun onBoundsChange(bounds: android.graphics.Rect) {
paint.shader.setLocalMatrix(
Matrix().apply { setRotate(rotationDegrees, bounds.exactCenterX(), bounds.exactCenterY()) }
)
}
override fun draw(canvas: Canvas) {
canvas.drawRect(bounds, paint)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
@@ -83,7 +83,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
)
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
@@ -233,7 +233,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
@JvmStatic
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups)
@JvmOverloads
fun backupsSettings(context: Context, launchCheckoutFlow: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups(launchCheckoutFlow = launchCheckoutFlow))
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
@@ -417,7 +417,7 @@ private fun AppSettingsContent(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
@@ -62,6 +63,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.getValue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
@@ -76,11 +78,16 @@ class BackupsSettingsFragment : ComposeFragment() {
private lateinit var checkoutLauncher: ActivityResultLauncher<MessageBackupTier?>
private val viewModel: BackupsSettingsViewModel by viewModels()
private val args: BackupsSettingsFragmentArgs by navArgs()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
checkoutLauncher = createBackupsCheckoutLauncher {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
checkoutLauncher.launch(null)
}
}
@Composable
@@ -233,7 +233,15 @@ internal fun LocalBackupsSettingsScreen(
}
if (state.isDeleting) {
Dialogs.IndeterminateProgressDialog(message = stringResource(id = R.string.BackupDialog_deleting_local_backup))
val message = stringResource(id = R.string.BackupDialog_deleting_local_backup)
if (state.deleteTotal > 0) {
Dialogs.DeterminateProgressDialog(
message = message,
progress = { state.deleteCompleted.toFloat() / state.deleteTotal }
)
} else {
Dialogs.IndeterminateProgressDialog(message = message)
}
}
}
@@ -19,5 +19,7 @@ data class LocalBackupsSettingsState(
val folderDisplayName: String? = null,
val scheduleTimeLabel: String? = null,
val progress: LocalBackupCreationProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()),
val isDeleting: Boolean = false
val isDeleting: Boolean = false,
val deleteCompleted: Int = 0,
val deleteTotal: Int = 0
)
@@ -113,7 +113,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
fun turnOffAndDelete(context: Context) {
internalSettingsState.update { it.copy(isDeleting = true) }
internalSettingsState.update { it.copy(isDeleting = true, deleteCompleted = 0, deleteTotal = 0) }
viewModelScope.launch {
withContext(Dispatchers.IO) {
@@ -121,10 +121,12 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val path = SignalStore.backup.newLocalBackupsDirectory
SignalStore.backup.newLocalBackupsDirectory = null
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
BackupUtil.deleteUnifiedBackups(context, path)
BackupUtil.deleteUnifiedBackups(context, path) { completed, total ->
internalSettingsState.update { it.copy(deleteCompleted = completed, deleteTotal = total) }
}
}
internalSettingsState.update { it.copy(isDeleting = false) }
internalSettingsState.update { it.copy(isDeleting = false, deleteCompleted = 0, deleteTotal = 0) }
}
}
@@ -49,7 +49,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.database.model.addButton
import org.thoughtcrime.securesms.database.model.addLink
import org.thoughtcrime.securesms.database.model.addStyle
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -48,9 +49,12 @@ class InternalSettingsRepository(context: Context) {
val title = "Release Note Title"
val bodyText = "Release note body. Aren't I awesome?"
val body = "$title\n\n$bodyText"
val linkUrl = "https://signal.org"
val body = "$title\n\n$bodyText\n\n$linkUrl"
val linkStart = body.length - linkUrl.length
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
.addLink(linkUrl, linkStart, linkUrl.length)
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
@@ -37,6 +37,7 @@ import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.roundedString
import org.signal.core.util.stream.LimitedInputStream
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -58,7 +59,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.svr.SvrBApi
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@@ -16,11 +16,11 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.network.service.StorageServiceService
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
class InternalStorageServicePlaygroundViewModel : ViewModel() {
@@ -47,12 +47,12 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
fun onViewTabSelected() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
val repository = StorageServiceService(AppDependencies.storageServiceApi)
val storageKey = SignalStore.storageService.storageKeyForInitialDataRestore ?: SignalStore.storageService.storageKey
val manifest = when (val result = repository.getStorageManifest(storageKey)) {
is StorageServiceRepository.ManifestResult.Success -> result.manifest
is StorageServiceRepository.ManifestResult.NotFoundError -> {
is StorageServiceService.ManifestResult.Success -> result.manifest
is StorageServiceService.ManifestResult.NotFoundError -> {
Log.w(TAG, "Manifest not found!")
_oneOffEvents.value = OneOffEvent.ManifestNotFoundError
return@withContext
@@ -66,7 +66,7 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
_manifest.value = manifest
val records = when (val result = repository.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.Success -> result.records
else -> {
Log.w(TAG, "Failed to fetch records!")
_oneOffEvents.value = OneOffEvent.StorageRecordDecryptionError
@@ -75,6 +75,7 @@ class AdvancedPrivacySettingsViewModel(
viewModelScope.launch(SignalDispatchers.IO) {
if (!enabled) {
SignalDatabase.recipients.clearAllKeyTransparencyData()
SignalStore.account.distinguishedHead = null
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
@@ -63,9 +63,11 @@ sealed interface AppSettingsRoute : Parcelable {
@Parcelize
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
data class Backups(
val launchCheckoutFlow: Boolean = false
) : BackupsRoute
data class Local(val triggerUpdateFlow: Boolean = false) : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
data class Remote(val forQuickRestore: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}
@@ -348,7 +348,7 @@ class DonateToSignalFragment :
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
}
@@ -356,7 +356,7 @@ class DonateToSignalFragment :
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PaymentMethod.SEPA_DEBIT) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
} else if (state.monthlyDonationState.nonVerifiedMonthlyDonation != null) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
}
@@ -18,10 +18,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.IdealWeroButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
@@ -51,6 +51,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
GooglePayButton.register(adapter)
PayPalButton.register(adapter)
IndeterminateLoadingCircle.register(adapter)
IdealWeroButton.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
@@ -190,17 +191,16 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
if (state.isIDEALAvailable) {
space(16.dp)
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
disableOnClick = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
customPref(
IdealWeroButton.Model(
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
}
}
@@ -10,11 +10,13 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
import java.net.URISyntaxException
/**
* Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback
@@ -30,18 +32,31 @@ object ExternalNavigationHelper {
return false
}
val intent = try {
Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME).sanitizeWebIntent()
} catch (e: URISyntaxException) {
Log.w(TAG, "Failed to parse web intent URI.", e)
return false
}
val targetLabel = resolveTargetLabel(context, intent)
val message = if (targetLabel != null) {
context.getString(R.string.ExternalNavigationHelper__once_payment_confirmed_in_app, targetLabel)
} else {
context.getString(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment)
.setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) }
.setMessage(message)
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, intent, launchIntent) }
.setNegativeButton(android.R.string.cancel, null)
.show()
return true
}
private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) {
val intent = Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
private fun attemptIntentLaunch(context: Context, intent: Intent, launchIntent: (Intent) -> Unit) {
try {
launchIntent(intent)
} catch (e: ActivityNotFoundException) {
@@ -50,7 +65,7 @@ object ExternalNavigationHelper {
val fallback = intent.getStringExtra("browser_fallback_url")
if (fallback.isNotNullOrBlank()) {
try {
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME))
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME).sanitizeWebIntent())
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Failed to launch fallback URL.", e)
toastOnActivityNotFound(context)
@@ -59,6 +74,30 @@ object ExternalNavigationHelper {
}
}
private fun resolveTargetLabel(context: Context, intent: Intent): CharSequence? {
val resolveInfo = context.packageManager.resolveActivity(intent, 0) ?: return null
return resolveInfo.loadLabel(context.packageManager).toString().takeIf { it.isNotBlank() }
}
/**
* Sanitize an intent parsed from a web-originated URI to prevent targeting
* non-exported or internal activities. This mirrors the sanitization that
* browsers apply to intent:// URIs before dispatching them.
*/
@VisibleForTesting
fun Intent.sanitizeWebIntent(): Intent {
component = null
selector = null
addCategory(Intent.CATEGORY_BROWSABLE)
flags = flags and (
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
).inv()
return this
}
private fun toastOnActivityNotFound(context: Context) {
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show()
}
@@ -153,7 +153,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
if (state.inAppPayment!!.type.recurring) { // TODO [message-requests] -- handle backup
val formattedMoney = FiatMoneyUtil.format(requireContext().resources, state.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal))
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal_wero))
.setMessage(getString(R.string.IdealTransferDetailsFragment__to_setup_your_recurring_donation, formattedMoney))
.setPositiveButton(R.string.IdealTransferDetailsFragment__continue) { _, _ ->
continueTransfer()
@@ -218,7 +218,7 @@ private fun IdealTransferDetailsContent(
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal_wero),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector
) {
@@ -130,7 +130,7 @@ class ManageDonationsFragment :
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed, amount))
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed_ideal_wero, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
} else if (state.pendingOneTimeDonation?.pendingVerification == true &&
@@ -143,7 +143,7 @@ class ManageDonationsFragment :
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed, amount))
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed_ideal_wero, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
}
@@ -440,7 +440,7 @@ class ManageDonationsFragment :
else -> {
val message = if (isIdeal) {
R.string.DonationsErrors__your_ideal_couldnt_be_processed
R.string.DonationsErrors__your_ideal_wero_couldnt_be_processed
} else {
R.string.DonationsErrors__try_another_payment_method
}
@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* DSL Ideal | Wero button for the payments gateway.
*/
object IdealWeroButton {
@Stable
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder<Model>(itemView) {
@Composable
override fun Content(model: Model) {
IdealWeroButton(model)
}
}
fun register(adapter: MappingAdapter) {
DSLComposePreference.register(adapter) { ViewHolder(it) }
}
}
@Composable
private fun IdealWeroButton(model: IdealWeroButton.Model) {
var enabled by remember { mutableStateOf(true) }
Buttons.LargeTonal(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
onClick = {
enabled = false
model.onClick()
},
enabled = enabled,
modifier = Modifier
.height(44.dp)
.horizontalGutters()
.fillMaxWidth(),
colors = ButtonColors(
containerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer),
disabledContainerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
disabledContentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer)
)
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.logo_ideal_wero),
contentDescription = stringResource(R.string.GatewaySelectorBottomSheet__ideal_wero)
)
}
}
@DayNightPreviews
@Composable
private fun IdealWeroButtonPreview() {
Previews.Preview {
IdealWeroButton(model = remember { IdealWeroButton.Model(onClick = {}) })
}
}
@@ -30,7 +30,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.DimensionUnit
@@ -277,7 +276,7 @@ class ConversationSettingsFragment :
views = listOf(toolbar!!),
lifecycleOwner = viewLifecycleOwner,
setStatusBarColor = { color ->
if (!resources.getWindowSizeClass().isSplitPane() || activity is ConversationSettingsActivity) {
if (!resources.isSplitPane() || activity is ConversationSettingsActivity) {
WindowUtil.setStatusBarColor(requireActivity().window, color)
}
}
@@ -44,7 +44,7 @@ object BioTextPreference {
override fun getSubhead1Text(context: Context): String? {
return if (recipient.isReleaseNotes) {
context.getString(R.string.ReleaseNotes__signal_release_notes_and_news)
null
} else {
recipient.combinedAboutAndEmoji
}
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.models
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Allows hosting compose code in a DSL adapter.
*/
object DSLComposePreference {
/**
* Initializes the ComposeView to play nice with RecyclerView and manages the Model in a State.
*/
abstract class ViewHolder<T : MappingModel<T>>(composeView: ComposeView) : MappingViewHolder<T>(composeView) {
private var model: T? by mutableStateOf(null)
init {
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
)
composeView.setContent {
val model = this.model ?: return@setContent
SignalTheme {
Content(model)
}
}
}
override fun bind(model: T) {
this.model = model
}
@Composable
abstract fun Content(model: T)
}
/**
* Does not need to be used directly, but does need to be non-private so that the inline register method can see it.
*/
class ComposeFactory<T : MappingModel<T>>(
private val create: (ComposeView) -> MappingViewHolder<T>
) : Factory<T> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<T> {
return create(ComposeView(parent.context))
}
}
inline fun <reified T : MappingModel<T>> register(adapter: MappingAdapter, noinline create: (ComposeView) -> MappingViewHolder<T>) {
adapter.registerFactory(T::class.java, ComposeFactory(create))
}
}
@@ -69,7 +69,6 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
@@ -120,7 +119,6 @@ object CallInfoView {
onContactDetails = callbacks::onContactDetails,
onViewSafetyNumber = callbacks::onViewSafetyNumber,
onGoToChat = callbacks::onGoToChat,
isInternalUser = RemoteConfig.internalUser,
modifier = modifier
)
}
@@ -169,7 +167,6 @@ private fun CallInfo(
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {},
isInternalUser: Boolean = false,
modifier: Modifier = Modifier
) {
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
@@ -278,14 +275,10 @@ private fun CallInfo(
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
isCallLink = controlAndInfoState.callLink != null,
onBlockClicked = onBlock,
onParticipantClicked = if (isInternalUser) {
{ participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
onParticipantClicked = { participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
} else {
null
}
)
}
@@ -14,11 +14,17 @@ import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
@@ -33,7 +39,7 @@ fun CallParticipantsPager(
pagerState: PagerState,
modifier: Modifier = Modifier,
onTap: (() -> Unit)? = null,
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
onParticipantLongPress: ((CallParticipant, Offset) -> Unit)? = null
) {
if (callParticipantsPagerState.focusedParticipant == null) {
return
@@ -57,12 +63,15 @@ fun CallParticipantsPager(
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
itemModifier.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { currentOnLongPress.value?.invoke(participant) }
)
}
var itemWindowOrigin by remember(participant.callParticipantId) { mutableStateOf(Offset.Zero) }
itemModifier
.onGloballyPositioned { coords -> itemWindowOrigin = coords.positionInRoot() }
.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { local -> currentOnLongPress.value?.invoke(participant, itemWindowOrigin + local) }
)
}
} else {
itemModifier
}
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
@@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
@@ -55,6 +57,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -127,7 +130,6 @@ fun CallScreen(
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {},
isInternalUser: Boolean = false,
isSelfAdmin: Boolean = false,
isCallLink: Boolean = false,
onMuteAudio: (CallParticipant) -> Unit = {},
@@ -329,13 +331,20 @@ fun CallScreen(
}
} else if (webRtcCallState.isPassedPreJoin) {
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
var longPressWindowOffset by remember { mutableStateOf(Offset.Zero) }
var anchorWindowOrigin by remember { mutableStateOf(Offset.Zero) }
val longPressedParticipant = longPressedParticipantId?.let { id ->
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
}
val density = LocalDensity.current
val contextMenuAnchorOffset = remember(longPressWindowOffset, anchorWindowOrigin, density) {
val local = longPressWindowOffset - anchorWindowOrigin
with(density) { IntOffset(local.x.toInt(), local.y.toInt()) }
}
CallElementsLayout(
callGridSlot = {
Box {
Box(modifier = Modifier.onGloballyPositioned { anchorWindowOrigin = it.positionInRoot() }) {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
@@ -356,24 +365,25 @@ fun CallScreen(
}
}
},
onParticipantLongPress = if (isInternalUser) {
{ participant -> longPressedParticipantId = participant.callParticipantId }
} else {
null
onParticipantLongPress = { participant, windowOffset ->
longPressedParticipantId = participant.callParticipantId
longPressWindowOffset = windowOffset
}
)
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
Box(modifier = Modifier.offset { contextMenuAnchorOffset }) {
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
},
pictureInPictureSlot = {
@@ -49,7 +49,6 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import kotlin.time.Duration.Companion.seconds
@@ -221,7 +220,6 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isInternalUser = RemoteConfig.internalUser,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onMuteAudio = callInfoCallbacks::onMuteAudio,
@@ -21,6 +21,7 @@ import android.view.Surface
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
@@ -148,6 +149,20 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
initializeResources()
initializeViewModel()
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (viewModel.callParticipantsStateSnapshot.callState != WebRtcViewModel.State.CALL_INCOMING && enterPipModeIfPossible()) {
return
}
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
)
// Restore saved state if recreated while in PIP mode
val savedAspectRatio = savedInstanceState?.getFloat(SAVED_STATE_PIP_ASPECT_RATIO, 0f) ?: 0f
lastLocalParticipantLandscape = savedInstanceState?.getBoolean(SAVED_STATE_LOCAL_PARTICIPANT_LANDSCAPE, false) ?: false
@@ -331,12 +346,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
}
override fun onBackPressed() {
if (viewModel.callParticipantsStateSnapshot.callState == WebRtcViewModel.State.CALL_INCOMING || !enterPipModeIfPossible()) {
super.onBackPressed()
}
}
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot ?: return
@@ -1369,7 +1378,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
override fun onNavigateUpClicked() {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
}
override fun toggleControls() {
@@ -0,0 +1,262 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView.RecyclerViewReadyCallback
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.signal.core.ui.R as CoreUiR
/**
* A composable that displays a paged, selectable contact list driven by a [ContactSearchViewModel].
*
* Intended for use in two ways:
* 1. Directly inside a Compose layout the caller creates and holds a [ContactSearchViewModel]
* via `viewModel()` or a parent composable and passes it in.
* 2. Via [ContactSearchView] in XML/View-based layouts [ContactSearchView] creates the ViewModel
* and delegates its `Content()` to this function.
*
* The [PagingMappingAdapter] is created internally via `remember` and re-created if
* [displayOptions] or [adapterFactory] change.
*
* @param viewModel Drives the list managed by the caller.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated whenever state changes.
* @param modifier Modifier applied to the composable root.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param storyFragmentManager [FragmentManager] used to show story-related dialogs.
* Pass `null` to disable story context menus and dialogs.
* @param onListCommitted Called after each list commit with the committed item count.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPadding Extra bottom padding so last items scroll above overlaid UI.
* Automatically disables `clipToPadding` when non-zero.
* @param adapterFactory Factory for the adapter swap for custom adapters (e.g.
* [ContactSelectionListAdapter]).
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
*/
@Composable
fun ContactSearch(
viewModel: ContactSearchViewModel,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
modifier: Modifier = Modifier,
displayOptions: ContactSearchAdapter.DisplayOptions = ContactSearchAdapter.DisplayOptions(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
storyFragmentManager: FragmentManager? = null,
onListCommitted: (Int) -> Unit = {},
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPadding: Dp = 0.dp,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
) {
val mappingModels by viewModel.mappingModels.collectAsStateWithLifecycle()
val controller by viewModel.controller.collectAsStateWithLifecycle()
val configState by viewModel.configurationState.collectAsStateWithLifecycle()
val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration)
val currentOnListCommitted by rememberUpdatedState(onListCommitted)
// Held as State references (not delegated) so click-callback lambdas captured inside
// remember() always read the latest value without recreating the adapter.
val currentCallbacks = rememberUpdatedState(callbacks)
val currentStoryFragmentManager = rememberUpdatedState(storyFragmentManager)
val context = LocalContext.current
val contextState = rememberUpdatedState(context)
val adapter = remember(viewModel.fixedContacts, displayOptions, adapterFactory) {
adapterFactory.create(
context = context,
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = DefaultClickCallbacks(viewModel, currentCallbacks, currentStoryFragmentManager),
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = DefaultStoryContextMenuCallbacks(viewModel, currentStoryFragmentManager, contextState),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
}
LaunchedEffect(mappingModels) {
adapter.submitList(mappingModels) {
currentOnListCommitted(mappingModels.size)
}
}
LaunchedEffect(controller) {
controller?.let { adapter.setPagingController(it) }
}
LaunchedEffect(configState) {
viewModel.setConfiguration(currentMapStateToConfiguration(configState))
}
val recyclerView = remember(context) {
RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
}
}
DisposableEffect(recyclerView, itemDecorations) {
itemDecorations.forEach { recyclerView.addItemDecoration(it) }
onDispose {
itemDecorations.forEach { recyclerView.removeItemDecoration(it) }
}
}
DisposableEffect(recyclerView, scrollListeners) {
scrollListeners.forEach { recyclerView.addOnScrollListener(it) }
onDispose {
scrollListeners.forEach { recyclerView.removeOnScrollListener(it) }
}
}
val bottomPaddingPx = with(LocalDensity.current) { contentBottomPadding.roundToPx() }
LaunchedEffect(recyclerView) {
onRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
AndroidView(
factory = { recyclerView },
update = { rv ->
if (rv.adapter !== adapter) {
rv.adapter = adapter
}
rv.setPadding(0, 0, 0, bottomPaddingPx)
rv.clipToPadding = bottomPaddingPx == 0
rv.clipChildren = bottomPaddingPx == 0
},
modifier = modifier.fillMaxSize()
)
}
private class DefaultClickCallbacks(
private val viewModel: ContactSearchViewModel,
private val callbacks: State<ContactSearchCallbacks>,
private val fragmentManager: State<FragmentManager?>
) : ContactSearchAdapter.ClickCallbacks {
companion object {
private val TAG = Log.tag(DefaultClickCallbacks::class.java)
}
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked()")
if (story.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
fragmentManager.value?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
} else {
toggle(view, story, isSelected)
}
}
override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) {
Log.d(TAG, "onKnownRecipientClicked()")
toggle(view, knownRecipient, isSelected)
}
override fun onExpandClicked(expand: ContactSearchData.Expand) {
Log.d(TAG, "onExpandClicked()")
viewModel.expandSection(expand.sectionKey)
}
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
Log.d(TAG, "onChatTypeClicked()")
if (isSelected) {
viewModel.setKeysNotSelected(setOf(chatTypeRow.contactSearchKey))
} else {
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
}
}
private fun toggle(view: View, data: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
Log.d(TAG, "toggle(OFF) ${data.contactSearchKey}")
callbacks.value.onContactDeselected(view, data.contactSearchKey)
viewModel.setKeysNotSelected(setOf(data.contactSearchKey))
} else {
Log.d(TAG, "toggle(ON) ${data.contactSearchKey}")
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
}
}
}
private class DefaultStoryContextMenuCallbacks(
private val viewModel: ContactSearchViewModel,
private val fragmentManager: State<FragmentManager?>,
private val context: State<Context>
) : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
val fm = fragmentManager.value ?: return
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog().show(fm, null)
} else {
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId()).show(fm, null)
}
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
MaterialAlertDialogBuilder(context.value)
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
val ctx = context.value
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(ctx.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(ctx)))
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(ctx, CoreUiR.color.signal_colorError), ctx.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
@DayNightPreviews
@Composable
private fun ContactSearchPreview() {
Previews.Preview {
Box(modifier = Modifier.fillMaxSize())
}
}
@@ -825,6 +825,37 @@ open class ContactSearchAdapter(
class LongClickCallbacksAdapter : LongClickCallbacks {
override fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean = false
}
/**
* Creates a [PagingMappingAdapter] backed by [ContactSearchAdapter] (or a subclass).
* Pass a custom implementation to inject alternative adapters for testing or specialised UIs.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
/** Standard implementation that creates a plain [ContactSearchAdapter]. */
object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import org.signal.core.util.logging.Log
/**
* Hooks for observing and intercepting contact selection changes driven by a
* [ContactSearchViewModel]. Pass an implementation to [ContactSearchView.bind] or
* [ContactSearch] to intercept selection events (e.g. apply selection limits or show
* confirmation dialogs) and to react to list commits.
*/
interface ContactSearchCallbacks {
/**
* Called before [contactSearchKeys] are added to the selection. Return the keys that should
* actually be selected return an empty set to cancel the entire selection, or a filtered
* subset to allow only some keys through.
*/
fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey>
/** Called after [contactSearchKey] has been removed from the selection. */
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
/** Called after each [androidx.recyclerview.widget.RecyclerView.Adapter.submitList] completes, with the committed list [size]. */
fun onAdapterListCommitted(size: Int)
/** No-op implementation — override only the methods you need. */
open class Simple : ContactSearchCallbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}")
return contactSearchKeys
}
override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) {
Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey")
}
override fun onAdapterListCommitted(size: Int) = Unit
companion object {
private val TAG = Log.tag(Simple::class.java)
}
}
}
@@ -107,7 +107,7 @@ class ContactSearchConfiguration private constructor(
/**
* A set of arbitrary rows, in the order given in the builder. Usage requires
* an implementation of [ArbitraryRepository] to be passed into [ContactSearchMediator]
* an implementation of [ArbitraryRepository] to be passed into [ContactSearchViewModel.Factory]
*
* Key: [ContactSearchKey.Arbitrary]
* Data: [ContactSearchData.Arbitrary]
@@ -1,289 +0,0 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.search.SearchFilter
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.concurrent.TimeUnit
import org.signal.core.ui.R as CoreUiR
/**
* This mediator serves as the delegate for interacting with the ContactSearch* framework.
*
* @param fragment The fragment displaying the content search results.
* @param fixedContacts Contacts which are "pre-selected" (for example, already a member of a group we're adding to)
* @param selectionLimits [SelectionLimits] describing how large the result set can be.
* @param displayCheckBox Whether or not to display checkboxes on items.
* @param displaySecondaryInformation Whether or not to display phone numbers on known contacts.
* @param mapStateToConfiguration Maps a [ContactSearchState] to a [ContactSearchConfiguration]
* @param callbacks Hooks to help process, filter, and react to selection
* @param performSafetyNumberChecks Whether to perform safety number checks for selected users
* @param adapterFactory A factory for creating an instance of [PagingMappingAdapter] to display items
* @param arbitraryRepository A repository for managing [ContactSearchKey.Arbitrary] data
*/
class ContactSearchMediator(
private val fragment: Fragment,
private val fixedContacts: Set<ContactSearchKey> = setOf(),
selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean = true,
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
private val callbacks: Callbacks = SimpleCallbacks(),
performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null
) {
companion object {
private val TAG = Log.tag(ContactSearchMediator::class.java)
}
private val queryDebouncer = Debouncer(300, TimeUnit.MILLISECONDS)
private val viewModel: ContactSearchViewModel = ViewModelProvider(
fragment,
ContactSearchViewModel.Factory(
selectionLimits = selectionLimits,
isMultiSelect = isMultiSelect,
repository = ContactSearchRepository(),
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = SearchRepository(fragment.requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(fragment.requireContext())
)
)[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create(
context = fragment.requireContext(),
fixedContacts = fixedContacts,
displayOptions = displayOptions,
callbacks = object : ContactSearchAdapter.ClickCallbacks {
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked() Recipient: ${story.recipient.id}")
toggleStorySelection(view, story, isSelected)
}
override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) {
Log.d(TAG, "onKnownRecipientClicked() Recipient: ${knownRecipient.recipient.id}")
toggleSelection(view, knownRecipient, isSelected)
}
override fun onExpandClicked(expand: ContactSearchData.Expand) {
Log.d(TAG, "onExpandClicked()")
viewModel.expandSection(expand.sectionKey)
}
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow")
toggleChatTypeSelection(view, chatTypeRow, isSelected)
}
},
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
init {
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
::Pair
)
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository), {
callbacks.onAdapterListCommitted(data.size)
})
}
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
adapter.setPagingController(controller)
}
viewModel.configurationState.observe(fragment.viewLifecycleOwner) {
viewModel.setConfiguration(mapStateToConfiguration(it))
}
}
fun onFilterChanged(filter: String?) {
queryDebouncer.publish {
viewModel.setQuery(filter)
}
}
fun getFilter(): String? = viewModel.getQuery()
fun onConversationFilterRequestChanged(conversationFilterRequest: ConversationFilterRequest) {
viewModel.setConversationFilterRequest(conversationFilterRequest)
}
fun onSearchFilterChanged(searchFilter: SearchFilter) {
viewModel.setSearchFilter(searchFilter)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
keys.forEach {
callbacks.onContactDeselected(null, it)
}
viewModel.setKeysNotSelected(keys)
}
fun clearSelection() {
viewModel.clearSelection()
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getFixedContactsSize(): Int {
return fixedContacts.size
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
fun getErrorEvents(): Observable<ContactSearchError> {
return viewModel.errorEventsStream.observeOn(AndroidSchedulers.mainThread())
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
viewModel.addToVisibleGroupStories(groupStories)
}
fun refresh() {
viewModel.refresh()
}
private fun toggleStorySelection(view: View, contactSearchData: ContactSearchData.Story, isSelected: Boolean) {
if (contactSearchData.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(fragment.childFragmentManager)
} else {
toggleSelection(view, contactSearchData, isSelected)
}
}
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
callbacks.onContactDeselected(view, contactSearchData.contactSearchKey)
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
}
}
private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
}
}
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog()
.show(fragment.childFragmentManager, null)
} else {
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId())
.show(fragment.childFragmentManager, null)
}
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(fragment.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(fragment.requireContext())))
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(fragment.requireContext(), CoreUiR.color.signal_colorError), fragment.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
interface Callbacks {
fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey>
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
fun onAdapterListCommitted(size: Int)
}
open class SimpleCallbacks : Callbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}")
return contactSearchKeys
}
override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) {
Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey}")
}
override fun onAdapterListCommitted(size: Int) = Unit
}
/**
* Wraps the construction of a PagingMappingAdapter<ContactSearchKey> so that it can
* be swapped for another implementation, allow listeners to be wrapped, etc.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
private object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}
@@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.util.AttributeSet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
/**
* A Compose-compatible wrapper view for the ContactSearch framework.
*
* Usage:
* 1. Create a [ContactSearchViewModel] in the host fragment (via `viewModels { ... }` or
* `ViewModelProvider`).
* 2. Declare `<ContactSearchView>` in your fragment's XML layout.
* 3. Call [bind] from `onViewCreated`, passing the ViewModel and the Fragment.
* 4. Call ViewModel methods directly for all operations, including query updates.
*/
class ContactSearchView : AbstractComposeView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
/**
* Called once with the inner [RecyclerView] after first composition.
* Java callers may implement this as a lambda: `rv -> fastScroller.setRecyclerView(rv)`.
*/
fun interface RecyclerViewReadyCallback {
fun onRecyclerViewReady(recyclerView: RecyclerView)
}
private var viewModel: ContactSearchViewModel? by mutableStateOf(null)
private var currentFragmentManager: FragmentManager? = null
private var currentDisplayOptions: ContactSearchAdapter.DisplayOptions? = null
private var currentMapStateToConfiguration: ((ContactSearchState) -> ContactSearchConfiguration)? = null
private var currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple()
private var currentItemDecorations: List<RecyclerView.ItemDecoration> = emptyList()
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var recyclerView: RecyclerView? = null
private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
/**
* Configures and activates the contact search. Must be called exactly once from the host
* fragment's `onViewCreated`. The [viewModel] must be created and held by the caller so it
* can be accessed directly for selection queries and mutations.
*
* Pre-selected/fixed contacts (e.g. existing group members) are owned by the ViewModel and
* passed via [ContactSearchViewModel.Factory].
*
* @param viewModel The externally-created ViewModel. Fixed contacts are a
* constructor parameter of [ContactSearchViewModel.Factory].
* @param fragmentManager Used for showing story-related dialogs. Pass
* [childFragmentManager] from a Fragment or
* [supportFragmentManager] from an Activity.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated on every state change.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPaddingDp Extra bottom padding (in dp) so last items scroll above overlaid
* UI. Java callers pass a plain `float`.
* @param adapterFactory Factory for the adapter swap for custom adapters.
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
*/
fun bind(
viewModel: ContactSearchViewModel,
fragmentManager: FragmentManager,
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
callbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple(),
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPaddingDp: Float = 0f,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
) {
check(this.viewModel == null) { "ContactSearchView.bind() may only be called once" }
currentFragmentManager = fragmentManager
currentDisplayOptions = displayOptions
currentMapStateToConfiguration = mapStateToConfiguration
currentCallbacks = callbacks
currentItemDecorations = itemDecorations
currentContentBottomPadding = contentBottomPaddingDp.dp
currentAdapterFactory = adapterFactory
currentScrollListeners = scrollListeners
currentOnRecyclerViewReady = onRecyclerViewReady
this.viewModel = viewModel // triggers recomposition
}
override fun canScrollVertically(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
}
@Composable
override fun Content() {
val vm = viewModel ?: return
val displayOptions = currentDisplayOptions ?: return
val mapStateToConfiguration = currentMapStateToConfiguration ?: return
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
callbacks = currentCallbacks,
storyFragmentManager = currentFragmentManager,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
itemDecorations = currentItemDecorations,
contentBottomPadding = currentContentBottomPadding,
adapterFactory = currentAdapterFactory,
scrollListeners = currentScrollListeners,
onRecyclerViewReady = RecyclerViewReadyCallback { recyclerView ->
this@ContactSearchView.recyclerView = recyclerView
currentOnRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
)
}
}
@@ -1,45 +1,66 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.compose.runtime.Stable
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.signal.paging.LivePagedData
import kotlinx.coroutines.launch
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.signal.paging.StateFlowPagedData
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.SearchFilter
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.whispersystems.signalservice.api.util.Preconditions
/**
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
* Manages paged contact search data, query/filter state, and contact selection. Drives
* [ContactSearch] / [ContactSearchView] and can also be used standalone via
* [bindAdapterToLifecycle] when only the data pipeline is needed (no Compose surface).
*
* Create via [Factory] and scope to the host Fragment or Activity. All state is exposed as
* [kotlinx.coroutines.flow.StateFlow] so it can be collected from Compose or coroutine scopes.
*
* @param fixedContacts Pre-selected contacts that cannot be deselected (e.g. existing group
* members). Owned here rather than by the UI layer.
*/
@Stable
class ContactSearchViewModel(
private val savedStateHandle: SavedStateHandle,
private val selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean,
private val contactSearchRepository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?,
val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
val fixedContacts: Set<ContactSearchKey> = emptySet()
) : ViewModel() {
companion object {
@@ -56,16 +77,41 @@ class ContactSearchViewModel(
.setStartIndex(0)
.build()
private val pagedData = MutableLiveData<LivePagedData<ContactSearchKey, ContactSearchData>>()
private val configurationStore = Store(ContactSearchState(query = savedStateHandle[QUERY]))
private val pagedData = MutableStateFlow<StateFlowPagedData<ContactSearchKey, ContactSearchData>?>(null)
private val internalConfigurationState = MutableStateFlow(ContactSearchState(query = savedStateHandle[QUERY]))
private val internalSelectedContacts = MutableStateFlow<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>()
private val rawQuery = MutableStateFlow<String?>(savedStateHandle[QUERY])
val controller: LiveData<PagingController<ContactSearchKey>> = pagedData.map { it.controller }
val data: LiveData<List<ContactSearchData>> = pagedData.switchMap { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
private val selectedContacts: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
val selectionState: LiveData<Set<ContactSearchKey>> = selectedContacts.asLiveData()
init {
viewModelScope.launch {
rawQuery.drop(1).debounce(300).collect { query ->
savedStateHandle[QUERY] = query
internalConfigurationState.update { it.copy(query = query) }
}
}
}
/** The paging controller for the current data source. Null until [setConfiguration] is called. */
val controller: StateFlow<PagingController<ContactSearchKey>?> = pagedData
.map { it?.controller }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** Raw paged contact data. Prefer [mappingModels] for binding to an adapter. */
val data: StateFlow<List<ContactSearchData>> = pagedData
.flatMapLatest { it?.data ?: flowOf(emptyList()) }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** The current query/filter/expansion state. Changes here trigger a new [setConfiguration] call via the Compose layer or [bindAdapterToLifecycle]. */
val configurationState: StateFlow<ContactSearchState> = internalConfigurationState
/** Currently selected contact keys, excluding [fixedContacts]. */
val selectionState: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
/** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */
val mappingModels: StateFlow<MappingModelList> = combine(data, selectionState) { contactData, selection ->
ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList())
val errorEventsStream: Observable<ContactSearchError> = errorEvents
@@ -80,26 +126,25 @@ class ContactSearchViewModel(
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig)
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
}
fun getQuery(): String? = savedStateHandle[QUERY]
fun getQuery(): String? = rawQuery.value
fun setQuery(query: String?) {
savedStateHandle[QUERY] = query
configurationStore.update { it.copy(query = query) }
rawQuery.value = query
}
fun setConversationFilterRequest(conversationFilterRequest: ConversationFilterRequest) {
configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
internalConfigurationState.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
}
fun setSearchFilter(searchFilter: SearchFilter) {
configurationStore.update { it.copy(searchFilter = searchFilter) }
internalConfigurationState.update { it.copy(searchFilter = searchFilter) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
internalConfigurationState.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
fun setKeysSelected(contactSearchKeys: Set<ContactSearchKey>) {
@@ -135,7 +180,7 @@ class ContactSearchViewModel(
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return selectedContacts.value
return internalSelectedContacts.value
}
fun clearSelection() {
@@ -144,7 +189,7 @@ class ContactSearchViewModel(
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe {
configurationStore.update { state ->
internalConfigurationState.update { state ->
state.copy(
groupStories = state.groupStories + groupStories.map {
val recipient = Recipient.resolved(it.recipientId)
@@ -159,7 +204,7 @@ class ContactSearchViewModel(
Preconditions.checkArgument(story.recipient.isGroup)
setKeysNotSelected(setOf(story.contactSearchKey))
disposables += contactSearchRepository.unmarkDisplayAsStory(story.recipient.requireGroupId()).subscribe {
configurationStore.update { state ->
internalConfigurationState.update { state ->
state.copy(
groupStories = state.groupStories.filter { it.recipient.id == story.recipient.id }.toSet()
)
@@ -176,6 +221,8 @@ class ContactSearchViewModel(
}
}
fun getFixedContactsSize(): Int = fixedContacts.size
fun refresh() {
controller.value?.onDataInvalidated()
}
@@ -187,7 +234,8 @@ class ContactSearchViewModel(
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
private val fixedContacts: Set<ContactSearchKey> = emptySet()
) : AbstractSavedStateViewModelFactory() {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return modelClass.cast(
@@ -199,9 +247,31 @@ class ContactSearchViewModel(
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository,
fixedContacts = fixedContacts
)
) as T
}
}
}
/**
* Wires the three core flows of [ContactSearchViewModel] to a [PagingMappingAdapter], scoped to
* the given [LifecycleOwner]. Designed for Java callers that create the adapter directly (without
* [ContactSearchView]) and only need the data pipeline, not a full Compose surface.
*
* Call once from `onViewCreated` after constructing the ViewModel and adapter.
*/
fun ContactSearchViewModel.bindAdapterToLifecycle(
lifecycleOwner: LifecycleOwner,
adapter: PagingMappingAdapter<ContactSearchKey>,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { mappingModels.collect { adapter.submitList(it) } }
launch { controller.collect { it?.let { c -> adapter.setPagingController(c) } } }
launch { configurationState.collect { setConfiguration(mapStateToConfiguration(it)) } }
}
}
}
@@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -113,7 +114,7 @@ class ConversationHeaderView : AbstractComposeView {
val isOfficialAccount = recipient.showVerified
val showUnverifiedName = if (recipient.isGroup) {
!groupInfo.hasExistingContacts && !(groupInfo.fullMemberCount == 1 && groupInfo.isMember)
!info.groupInfo.nameVerified
} else if (!isOfficialAccount) {
recipient.nickname.isEmpty && !recipient.isSystemContact
} else {
@@ -209,7 +210,12 @@ private fun ConversationHeaderContent(
.padding(top = AvatarOverlapAbove)
.width(277.dp)
.then(
if (hasWallpaper) {
if (isReleaseNotes) {
Modifier
.clip(BorderShape)
.background(colorResource(R.color.release_notes_header_background))
.border(width = 2.dp, color = colorResource(R.color.release_notes_header_border), shape = BorderShape)
} else if (hasWallpaper) {
Modifier
.clip(BorderShape)
.background(if (isSystemInDarkTheme()) SignalTheme.colors.colorTransparentInverse5 else SignalTheme.colors.colorTransparent5)
@@ -204,6 +204,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Optional<MessageRecord> nextMessageRecord;
private Locale locale;
private boolean groupThread;
private boolean isReleaseNotes;
private LiveRecipient author;
private RequestManager requestManager;
private Optional<MessageRecord> previousMessage;
@@ -412,6 +413,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.batchSelected = batchSelected;
this.conversationRecipient = conversationRecipient.live();
this.groupThread = conversationRecipient.isGroup();
this.isReleaseNotes = conversationRecipient.isReleaseNotes();
this.author = messageRecord.getFromRecipient().live();
this.canPlayContent = false;
this.mediaItem = null;
@@ -772,6 +774,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private @ColorInt int getDefaultBubbleColor(boolean hasWallpaper) {
if (isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble);
}
return hasWallpaper ? defaultBubbleColorForWallpaper : defaultBubbleColor;
}
@@ -919,9 +924,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
} else {
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
if (isReleaseNotes) {
int releaseNotesTextColor = ContextCompat.getColor(context, R.color.release_notes_bubble_text);
bodyText.setTextColor(releaseNotesTextColor);
bodyText.setLinkTextColor(releaseNotesTextColor);
footer.setTextColor(releaseNotesTextColor);
footer.setIconColor(releaseNotesTextColor);
footer.setRevealDotColor(releaseNotesTextColor);
} else {
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
}
footer.setOnlyShowSendingStatus(false, messageRecord);
}
@@ -1718,8 +1732,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int end = messageBody.getSpanEnd(placeholder);
URLSpan span = new InterceptableLongClickCopyLinkSpan(placeholder.getValue(),
urlClickListener,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false);
ContextCompat.getColor(getContext(), isReleaseNotes ? R.color.release_notes_bubble_text : R.color.signal_accent_primary),
isReleaseNotes);
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
@@ -167,6 +167,14 @@ internal object ConversationOptionsMenu {
if (recipient.isReleaseNotes) {
hideMenuItem(menu, R.id.menu_add_shortcut)
menu.findItem(R.id.menu_mute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
menu.findItem(R.id.menu_unmute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_slash_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
}
if (!SignalStore.labs.individualChatPlaintextExport) {
@@ -10,7 +10,6 @@ import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
@@ -30,10 +29,8 @@ import androidx.core.view.ViewKt;
import androidx.core.view.WindowInsetsCompat;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import java.util.stream.Stream;
import org.signal.core.ui.compose.SignalIcons;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
@@ -44,14 +41,13 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.LongStream;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;
import kotlin.Unit;
@@ -60,7 +56,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
private static final String TAG = Log.tag(ConversationReactionOverlay.class);
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiStripViewBounds = new Rect();
private float segmentSize;
@@ -97,12 +93,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
private int statusBarHeight;
private int bottomNavigationBarHeight;
private OnReactionSelectedListener onReactionSelectedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private OnReactionSelectedListener onReactionSelectedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
private final AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
@@ -173,7 +169,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
Log.i(TAG, "Capturing insets from root view.");
Insets insets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
statusBarHeight = insets.top;
statusBarHeight = insets.top;
bottomNavigationBarHeight = insets.bottom;
} else {
Log.i(TAG, "Capturing insets from util methods.");
@@ -199,15 +195,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
setVisibility(View.INVISIBLE);
ViewKt.doOnLayout(this, v -> {
showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
showAfterLayout(conversationMessage, lastSeenDownPoint, isMessageOnLeft);
return Unit.INSTANCE;
});
}
private void showAfterLayout(@NonNull Activity activity,
@NonNull ConversationMessage conversationMessage,
private void showAfterLayout(@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
boolean isMessageOnLeft)
{
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
@@ -219,10 +215,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
int overlayHeight = getHeight() - bottomNavigationBarHeight;
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = selectedConversationModel.getSnapshotMetrics().getSnapshotOffset();
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
float endX = selectedConversationModel.getSnapshotMetrics().getSnapshotOffset();
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
float menuPadding = DimensionUnit.DP.toPixels(12f);
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
@@ -245,7 +241,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItem.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = reactionBarTopPadding;
}
@@ -280,7 +276,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
@@ -307,12 +303,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
}
endApparentTop = endY;
endApparentTop = endY;
} else {
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
@@ -395,21 +391,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
private boolean zeroNavigationBarHeightForConfiguration() {
boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
if (Build.VERSION.SDK_INT >= 29) {
return getRootWindowInsets().getSystemGestureInsets().bottom == 0 && isLandscape;
} else {
return isLandscape;
}
WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this);
return (insets == null || insets.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom == 0) && isLandscape;
}
public void hide() {
hideInternal(onHideListener);
}
public void hideForReactWithAny() {
hideInternal(onHideListener);
}
private void hideInternal(@Nullable OnHideListener onHideListener) {
if (overlayState == OverlayState.HIDDEN || selectedConversationModel == null) {
return;
@@ -675,10 +664,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
return messageRecord.getReactions().stream()
.filter(record -> record.getAuthor()
.serialize()
.equals(Recipient.self()
.getId()
.serialize()))
.serialize()
.equals(Recipient.self()
.getId()
.serialize()))
.findFirst()
.map(ReactionRecord::getEmoji)
.orElse(null);
@@ -776,7 +765,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
private void initAnimators() {
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
List<Animator> reveals = LongStream.range(0, emojiViews.length)
.boxed()
@@ -823,8 +812,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
.map( v -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
.map(v -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
return anim;
})
@@ -873,11 +862,13 @@ public final class ConversationReactionOverlay extends FrameLayout {
public interface OnHideListener {
void startHide(@Nullable View focusedView);
void onHide();
}
public interface OnReactionSelectedListener {
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
}
@@ -119,7 +119,7 @@ public class ConversationTitleView extends ConstraintLayout {
if (recipient != null && recipient.isBlocked()) {
startDrawable = ContextUtil.requireDrawable(getContext(), R.drawable.symbol_block_16);
startDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
} else if (recipient != null && recipient.isMuted()) {
} else if (recipient != null && recipient.isMuted() && !recipient.isReleaseNotes()) {
startDrawable = ContextUtil.requireDrawable(getContext(), R.drawable.ic_bell_disabled_16);
startDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
}
@@ -25,8 +25,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableArrayListCompat
@@ -38,11 +43,15 @@ import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.WrapperDialogFragment
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.ContactSearchError
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
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.ContactSearchView
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -50,6 +59,7 @@ import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomShe
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
@@ -89,12 +99,22 @@ class MultiselectForwardFragment :
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val contactSearchViewModel: ContactSearchViewModel by viewModels {
ContactSearchViewModel.Factory(
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = !args.selectSingleRecipient,
repository = ContactSearchRepository(),
performSafetyNumberChecks = true,
arbitraryRepository = null,
searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
)
}
private val disposables = LifecycleDisposable()
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearchMediator: ContactSearchMediator
private lateinit var contactSearchRecycler: RecyclerView
private lateinit var contactSearch: ContactSearchView
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
@@ -121,27 +141,25 @@ class MultiselectForwardFragment :
view.minimumHeight = resources.displayMetrics.heightPixels
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(
fragment = this,
fixedContacts = emptySet(),
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = !args.selectSingleRecipient,
contactSearch = view.findViewById(R.id.contact_selection_list)
contactSearch.bind(
viewModel = contactSearchViewModel,
fragmentManager = childFragmentManager,
displayOptions = ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
),
mapStateToConfiguration = this::getConfiguration,
callbacks = object : ContactSearchMediator.SimpleCallbacks() {
callbacks = object : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
return filtered
}
}
},
contentBottomPaddingDp = 44f
)
contactSearchRecycler.adapter = contactSearchMediator.adapter
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)
@@ -156,7 +174,7 @@ class MultiselectForwardFragment :
}
contactFilterView.setOnFilterChangedListener {
contactSearchMediator.onFilterChanged(it)
contactSearchViewModel.setQuery(it)
}
val container = callback.getContainer()
@@ -207,27 +225,31 @@ class MultiselectForwardFragment :
container.addView(bottomBarAndSpacer)
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
if (contactSelection.isNotEmpty() && args.selectSingleRecipient) {
onSend(sendButton)
return@observe
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
contactSearchViewModel.selectionState.collect { contactSelection ->
if (contactSelection.isNotEmpty() && args.selectSingleRecipient) {
onSend(sendButton)
return@collect
}
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty()
addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty()
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
bottomBar.visible = true
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
bottomBar.visible = true
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
}
}
}
}
disposables += contactSearchMediator
.getErrorEvents()
disposables += contactSearchViewModel.errorEventsStream
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
val toastMessage: Int? = when (it) {
ContactSearchError.CONTACT_NOT_SELECTABLE -> R.string.MultiselectForwardFragment__only_admins_can_send_messages_to_this_group
@@ -264,15 +286,15 @@ class MultiselectForwardFragment :
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelableCompat(CreateStoryWithViewersFragment.STORY_RECIPIENT, RecipientId::class.java)!!
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactFilterView.clear()
}
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
val groups: Set<RecipientId> = bundle.getParcelableArrayListCompat(ChooseGroupStoryBottomSheet.RESULT_SET, RecipientId::class.java)?.toSet() ?: emptySet()
val keys: Set<ContactSearchKey.RecipientSearchKey> = groups.map { ContactSearchKey.RecipientSearchKey(it, true) }.toSet()
contactSearchMediator.addToVisibleGroupStories(keys)
contactSearchMediator.setKeysSelected(keys)
contactSearchViewModel.addToVisibleGroupStories(keys)
contactSearchViewModel.setKeysSelected(keys)
contactFilterView.clear()
}
}
@@ -286,7 +308,7 @@ class MultiselectForwardFragment :
val expiringMessages = args.multiShareArgs.filter { it.expiresAt > 0L }
val firstToExpire = expiringMessages.minByOrNull { it.expiresAt }
val earliestExpiration = firstToExpire?.expiresAt ?: -1L
if (viewModel.state.value?.stage is MultiselectForwardState.Stage.SelectionConfirmed && contactSearchMediator.getSelectedContacts().isNotEmpty()) {
if (viewModel.state.value?.stage is MultiselectForwardState.Stage.SelectionConfirmed && contactSearchViewModel.getSelectedContacts().isNotEmpty()) {
onCanceled()
}
if (earliestExpiration > 0) {
@@ -320,7 +342,7 @@ class MultiselectForwardFragment :
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
@@ -331,7 +353,7 @@ class MultiselectForwardFragment :
private fun onSend(sendButton: View) {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
viewModel.send(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
}
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
@@ -341,7 +363,7 @@ class MultiselectForwardFragment :
}
private fun dismissWithSuccess(@PluralsRes toastTextResId: Int) {
Log.d(TAG, "dismissWithSuccess() Selected: ${contactSearchMediator.getSelectedContacts().map { it.toString() }}")
Log.d(TAG, "dismissWithSuccess() Selected: ${contactSearchViewModel.getSelectedContacts().map { it.toString() }}")
requireListener<Callback>().setResult(
Bundle().apply {
@@ -353,7 +375,7 @@ class MultiselectForwardFragment :
}
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
Log.d(TAG, "dismissAndShowToast() Selected: ${contactSearchMediator.getSelectedContacts().map { it.toString() }}")
Log.d(TAG, "dismissAndShowToast() Selected: ${contactSearchViewModel.getSelectedContacts().map { it.toString() }}")
val argCount = getMessageCount()
@@ -519,12 +541,12 @@ class MultiselectForwardFragment :
}
override fun onWrapperDialogFragmentDismissed() {
contactSearchMediator.refresh()
contactSearchViewModel.refresh()
}
override fun onMyStoryConfigured(recipientId: RecipientId) {
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactSearchMediator.refresh()
contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactSearchViewModel.refresh()
}
interface Callback {
@@ -46,8 +46,8 @@ public final class SafetyNumberChangeRepository {
private final Context context;
public SafetyNumberChangeRepository(Context context) {
this.context = context.getApplicationContext();
public SafetyNumberChangeRepository() {
this.context = AppDependencies.getApplication();
}
@NonNull
@@ -71,7 +71,7 @@ public final class SafetyNumberChangeViewModel extends ViewModel {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(AppDependencies.getApplication());
SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository();
return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, messageType, repo)));
}
}
@@ -18,6 +18,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
@@ -42,6 +43,7 @@ import android.view.WindowManager
import android.view.animation.AnimationUtils
import android.view.inputmethod.EditorInfo
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import android.widget.TextView.OnEditorActionListener
import android.widget.Toast
@@ -49,7 +51,9 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.SearchView
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -59,6 +63,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnPreDraw
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentResultListener
@@ -149,6 +154,7 @@ import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
import org.thoughtcrime.securesms.components.InputPanel
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.RotatedTiledDrawable
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.SignalProgressDialog
@@ -184,7 +190,6 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.ConversationItem
@@ -573,7 +578,7 @@ class ConversationFragment :
private lateinit var attachmentManager: AttachmentManager
private lateinit var multiselectItemDecoration: MultiselectItemDecoration
private lateinit var openableGiftItemDecoration: OpenableGiftItemDecoration
private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration
private lateinit var conversationHeaderPositionDecoration: ConversationHeaderPositionDecoration
private lateinit var conversationItemDecorations: ConversationItemDecorations
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
@@ -601,6 +606,8 @@ class ConversationFragment :
private var firstPinRender: Boolean = true
private var skipNextBackPressHandling: Boolean = false
private var collapsibleEventScrollPosition: CollapsibleEventScrollPosition? = null
private var releaseNotesLayoutApplied: Boolean = false
private var releaseNotesWallpaperApplied: Boolean = false
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
@@ -666,7 +673,7 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.resetBackPressedState()
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.getWindowSizeClass().isSplitPane())
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.isSplitPane())
if (args.conversationScreenType == ConversationScreenType.BUBBLE) {
binding.root.setNavigationBarInsetOverride(0)
view.post {
@@ -694,6 +701,7 @@ class ConversationFragment :
requireActivity(),
binding.toolbarBackground,
viewModel::wallpaperSnapshot,
{ viewModel.recipientSnapshot?.isReleaseNotes == true },
viewLifecycleOwner,
incognito = args.isIncognito
)
@@ -761,10 +769,10 @@ class ConversationFragment :
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
binding.conversationItemRecycler.padding(top = bottom)
if (bottom != oldBottom && ::threadHeaderMarginDecoration.isInitialized) {
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (threadHeaderMarginDecoration.toolbarMargin != newMargin) {
threadHeaderMarginDecoration.toolbarMargin = newMargin
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
binding.conversationItemRecycler.invalidateItemDecorations()
}
}
@@ -1562,6 +1570,10 @@ class ConversationFragment :
presentConversationTitle(inputReadyState.conversationRecipient)
val disabledInputView = binding.conversationDisabledInput
val isReleaseNotes = inputReadyState.conversationRecipient.isReleaseNotes
if (isReleaseNotes) {
applyReleaseNotesLayout()
}
var inputDisabled = true
when {
@@ -1572,22 +1584,42 @@ class ConversationFragment :
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
inputReadyState.conversationRecipient.isReleaseNotes -> disabledInputView.showAsReleaseNotesChannel(inputReadyState.conversationRecipient)
isReleaseNotes -> Unit
inputReadyState.shouldShowInviteToSignal() -> disabledInputView.showAsInviteToSignal(requireContext(), inputReadyState.conversationRecipient, inputReadyState.threadContainsSms)
else -> inputDisabled = false
}
inputPanel.setHideForMessageRequestState(inputDisabled)
if (inputDisabled) {
if (inputDisabled && !isReleaseNotes) {
binding.navBar.setBackgroundColor(disabledInputView.color)
} else {
} else if (!inputDisabled) {
disabledInputView.clear()
}
composeText.setMessageSendType(MessageSendType.SignalMessageSendType)
}
private fun applyReleaseNotesLayout() {
if (releaseNotesLayoutApplied) {
return
}
releaseNotesLayoutApplied = true
binding.conversationReleaseNotesFloatingLabel.visible = true
binding.conversationDisabledInput.visible = false
val navBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
binding.conversationItemRecycler.updatePadding(bottom = ViewUtil.dpToPx(72) + navBarInset)
binding.navBar.setBackgroundColor(Color.TRANSPARENT)
ConstraintSet().apply {
clone(binding.root)
connect(binding.conversationItemRecyclerFrame.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
applyTo(binding.root)
}
}
private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) {
binding.conversationTitleView.root.setVerified(identityRecordsState.isVerified)
@@ -1713,16 +1745,13 @@ class ConversationFragment :
}
private fun onRecipientChanged(recipient: Recipient) {
presentWallpaper(recipient.wallpaper)
presentWallpaper(recipient)
presentConversationTitle(recipient)
presentChatColors(recipient.chatColors)
invalidateOptionsMenu()
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
recyclerViewColorizer.setChatColors(recipient.chatColors)
if (adapter.onHasWallpaperChanged(hasWallpaper = recipient.wallpaper != null)) {
conversationItemDecorations.hasWallpaper = recipient.wallpaper != null
}
}
@MainThread
@@ -1763,7 +1792,7 @@ class ConversationFragment :
}
private fun updateNavigationIconForNormal(isFullScreenPane: Boolean) {
if (!resources.getWindowSizeClass().isSplitPane() || isFullScreenPane) {
if (!resources.isSplitPane() || isFullScreenPane) {
binding.toolbar.setNavigationIcon(CoreUiR.drawable.symbol_arrow_start_24)
binding.toolbar.navigationIcon?.setTint(
ContextCompat.getColor(
@@ -1842,17 +1871,19 @@ class ConversationFragment :
}
}
private fun presentWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(binding.conversationWallpaper)
ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
private fun presentWallpaper(recipient: Recipient) {
val chatWallpaper = recipient.wallpaper
if (recipient.isReleaseNotes) {
applyReleaseNotesWallpaper()
} else {
binding.conversationWallpaperDim.visible = false
applyChatWallpaper(chatWallpaper)
}
val wallpaperEnabled = chatWallpaper != null || recipient.isReleaseNotes
val toolbarTint = ContextCompat.getColor(
requireContext(),
if (chatWallpaper != null) {
if (wallpaperEnabled) {
CoreUiR.color.signal_colorNeutralInverse
} else {
CoreUiR.color.signal_colorOnSurface
@@ -1863,7 +1894,6 @@ class ConversationFragment :
binding.toolbar.setActionItemTint(toolbarTint)
binding.toolbar.navigationIcon?.setTint(toolbarTint)
val wallpaperEnabled = chatWallpaper != null
binding.conversationWallpaper.visible = wallpaperEnabled
binding.scrollToBottom.setWallpaperEnabled(wallpaperEnabled)
binding.scrollToMention.setWallpaperEnabled(wallpaperEnabled)
@@ -1872,6 +1902,7 @@ class ConversationFragment :
val stateChanged = adapter.onHasWallpaperChanged(wallpaperEnabled)
conversationItemDecorations.hasWallpaper = wallpaperEnabled
conversationItemDecorations.isReleaseNotes = recipient.isReleaseNotes
if (stateChanged) {
binding.conversationItemRecycler.invalidateItemDecorations()
}
@@ -1888,12 +1919,39 @@ class ConversationFragment :
)
if (!inputPanel.isHidden) {
setNavBarBackgroundColor(chatWallpaper)
setNavBarBackgroundColor(wallpaperEnabled)
}
}
private fun setNavBarBackgroundColor(chatWallpaper: ChatWallpaper?) {
val navColor = if (chatWallpaper != null) {
private fun applyReleaseNotesWallpaper() {
if (releaseNotesWallpaperApplied) {
return
}
releaseNotesWallpaperApplied = true
val tinted = DrawableUtil.tint(
AppCompatResources.getDrawable(requireContext(), R.drawable.release_chat_background)!!,
ContextCompat.getColor(requireContext(), R.color.release_notes_background_pattern)
)
val bitmap = DrawableUtil.toBitmap(tinted, tinted.intrinsicWidth, tinted.intrinsicHeight)
binding.conversationWallpaper.scaleType = ImageView.ScaleType.MATRIX
binding.conversationWallpaper.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.release_notes_background))
binding.conversationWallpaper.setImageDrawable(RotatedTiledDrawable(bitmap, -45f))
binding.conversationWallpaperDim.visible = false
}
private fun applyChatWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(binding.conversationWallpaper)
ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
} else {
binding.conversationWallpaperDim.visible = false
}
}
private fun setNavBarBackgroundColor(hasWallpaper: Boolean) {
val navColor = if (hasWallpaper) {
R.color.conversation_navigation_wallpaper
} else {
CoreUiR.color.signal_colorBackground
@@ -2237,12 +2295,11 @@ class ConversationFragment :
}
)
threadHeaderMarginDecoration = ThreadHeaderMarginDecoration()
conversationHeaderPositionDecoration = ConversationHeaderPositionDecoration()
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
binding.conversationItemRecycler.addItemDecoration(ConversationHeaderPositionDecoration())
conversationHeaderPositionDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
binding.conversationItemRecycler.addItemDecoration(conversationHeaderPositionDecoration)
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.hasWallpaper)
binding.conversationItemRecycler.addItemDecoration(conversationItemDecorations, 0)
@@ -3330,7 +3387,8 @@ class ConversationFragment :
private fun presentComposeDivider() {
val isAtBottom = isScrolledToBottom()
if (isAtBottom && !wasAtBottom) {
val suppress = viewModel.recipientSnapshot?.isReleaseNotes == true
if ((isAtBottom && !wasAtBottom) || suppress) {
ViewUtil.fadeOut(binding.composeDivider, 50, View.INVISIBLE)
} else if (wasAtBottom && !isAtBottom) {
ViewUtil.fadeIn(binding.composeDivider, 500)
@@ -3746,7 +3804,13 @@ class ConversationFragment :
"username_edit" -> startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
"chat_folder" -> startActivity(AppSettingsActivity.chatFolders(requireContext()))
"remote_backups" -> startActivity(AppSettingsActivity.remoteBackups(requireContext()))
"remote_backups" -> {
if (SignalStore.backup.areBackupsEnabled) {
startActivity(AppSettingsActivity.remoteBackups(requireContext()))
} else {
startActivity(AppSettingsActivity.backupsSettings(requireContext(), launchCheckoutFlow = true))
}
}
}
}
@@ -4353,7 +4417,7 @@ class ConversationFragment :
*/
private fun navigateTo(location: MainNavigationDetailLocation.Chats) {
val router = mainNavRouter
if (router != null && resources.getWindowSizeClass().isSplitPane()) {
if (router != null && resources.isSplitPane()) {
router.goTo(location)
} else {
when (location) {
@@ -4728,10 +4792,6 @@ class ConversationFragment :
launchIntent = this@ConversationFragment::startActivity
)
}
override fun onUnmuteReleaseNotesChannel() {
viewModel.muteConversation(0L)
}
}
//endregion
@@ -5120,7 +5180,7 @@ class ConversationFragment :
}
override fun onInputHidden() {
setNavBarBackgroundColor(viewModel.wallpaperSnapshot)
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
viewModel.setIsMediaKeyboardShowing(false)
}
@@ -5242,17 +5302,6 @@ class ConversationFragment :
}
}
private inner class ThreadHeaderMarginDecoration : RecyclerView.ItemDecoration() {
var toolbarMargin: Int = 0
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
if (view is ConversationHeaderView) {
outRect.top = toolbarMargin
}
}
}
private inner class VoiceMessageRecordingSessionCallbacks : VoiceMessageRecordingDelegate.SessionCallback {
override fun onSessionWillBegin() {
getVoiceNoteMediaController().pausePlayback()
@@ -7,35 +7,45 @@ package org.thoughtcrime.securesms.conversation.v2
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
import kotlin.math.min
/**
* Adjusts the Conversation's recycler view translationY so that the conversation header
* is pinned to the top of the visible area when content is too short to
* fill the screen.
* Reserves space above the [ConversationHeaderView] for the toolbar and adjusts the conversation RecyclerView's translationY so the header is pinned below the
* toolbar when content is short enough to fit the viewport. The toolbar margin is only contributed when a translation is actually going to be applied; when
* content overflows, no margin is added and no translation is applied.
*/
class ConversationHeaderPositionDecoration : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (parent.childCount == 0 || parent.canScrollVertically(-1) || parent.canScrollVertically(1)) {
parent.translationY = 0f
} else {
val threadHeaderView: ConversationHeaderView = parent.children
.filterIsInstance<ConversationHeaderView>()
.firstOrNull() ?: run {
parent.translationY = 0f
return
}
private val bounds = Rect()
// A decorator adds the margin for the toolbar, margin is the difference of the bounds "height" and the view height
val bounds = Rect()
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
val toolbarMargin = bounds.bottom - bounds.top - threadHeaderView.height
var toolbarMargin: Int = 0
val childTop: Int = threadHeaderView.top - toolbarMargin
parent.translationY = min(0, -childTop).toFloat()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
if (view is ConversationHeaderView && !parent.canScrollVertically(1)) {
outRect.top = toolbarMargin
}
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (parent.canScrollVertically(1)) {
parent.translationY = 0f
return
}
val threadHeaderView: ConversationHeaderView = parent.children
.filterIsInstance<ConversationHeaderView>()
.firstOrNull() ?: run {
parent.translationY = 0f
return
}
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
val margin = bounds.bottom - bounds.top - threadHeaderView.height
val childTop: Int = threadHeaderView.top - margin
parent.translationY = min(0, -childTop).toFloat()
}
}
@@ -59,6 +59,12 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
unreadViewHolder?.updateForWallpaper()
}
var isReleaseNotes: Boolean = false
set(value) {
field = value
headerCache.values.forEach { it.updateForWallpaper() }
}
var selfRecipientId: RecipientId? = null
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
@@ -307,7 +313,10 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
}
fun updateForWallpaper() {
if (hasWallpaper) {
if (isReleaseNotes) {
date.setBackgroundResource(R.drawable.release_notes_date_header_background)
date.setTextColor(ContextCompat.getColor(itemView.context, CoreUiR.color.signal_colorOnSurfaceVariant))
} else if (hasWallpaper) {
date.setBackgroundResource(R.drawable.wallpaper_bubble_background_18)
date.setTextColor(ContextCompat.getColor(itemView.context, CoreUiR.color.signal_colorNeutralInverse))
} else {
@@ -16,6 +16,7 @@ class ConversationToolbarOnScrollHelper(
activity: FragmentActivity,
toolbarBackground: View,
private val wallpaperProvider: () -> ChatWallpaper?,
private val releaseNotesProvider: () -> Boolean,
lifecycleOwner: LifecycleOwner,
private val incognito: Boolean = false
) : Material3OnScrollHelper(
@@ -25,10 +26,18 @@ class ConversationToolbarOnScrollHelper(
setStatusBarColor = {}
) {
override val activeColorSet: ColorSet
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
get() = when {
incognito -> ColorSet(R.color.conversation_toolbar_color_incognito)
releaseNotesProvider() -> ColorSet(R.color.release_notes_toolbar_scrolled)
else -> ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
}
override val inactiveColorSet: ColorSet
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
get() = when {
incognito -> ColorSet(R.color.conversation_toolbar_color_incognito)
releaseNotesProvider() -> ColorSet(R.color.release_notes_toolbar_transparent)
else -> ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
}
@ColorRes
private fun getActiveToolbarColor(hasWallpaper: Boolean): Int {
@@ -48,7 +48,6 @@ class DisabledInputView @JvmOverloads constructor(
private var requestingGroup: View? = null
private var announcementGroupOnly: TextView? = null
private var inviteToSignal: View? = null
private var releaseNoteChannel: View? = null
private var incognitoView: View? = null
private var currentView: View? = null
@@ -187,21 +186,6 @@ class DisabledInputView @JvmOverloads constructor(
)
}
fun showAsReleaseNotesChannel(recipient: Recipient) {
releaseNoteChannel = show(
existingView = releaseNoteChannel,
create = { inflater.inflate(R.layout.conversation_activity_unmute, this, false) },
bind = {
if (recipient.isMuted) {
visible = true
findViewById<View>(R.id.conversation_activity_unmute_button).setOnClickListener { listener?.onUnmuteReleaseNotesChannel() }
} else {
visible = false
}
}
)
}
fun setWallpaperEnabled(wallpaperEnabled: Boolean) {
color = ContextCompat.getColor(context, if (wallpaperEnabled) R.color.wallpaper_bubble_color else CoreUiR.color.signal_colorBackground)
setBackgroundColor(color)
@@ -272,7 +256,6 @@ class DisabledInputView @JvmOverloads constructor(
fun onBlockClicked()
fun onUnblockClicked()
fun onInviteToSignal(recipient: Recipient)
fun onUnmuteReleaseNotesChannel()
fun onReportSpamClicked()
}
}
@@ -444,14 +444,21 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
V2ConversationItemUtils.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
if (conversationMessage.hasStyleLinks()) {
val isReleaseNotes = conversationMessage.threadRecipient.isReleaseNotes
val linkColor = if (isReleaseNotes) {
themeDelegate.getBodyTextColor(conversationMessage)
} else {
ContextCompat.getColor(getContext(), R.color.signal_accent_primary)
}
val underline = isReleaseNotes
messageBody.getSpans(0, messageBody.length, PlaceholderURLSpan::class.java).forEach { placeholder ->
val start = messageBody.getSpanStart(placeholder)
val end = messageBody.getSpanEnd(placeholder)
val span: URLSpan = InterceptableLongClickCopyLinkSpan(
placeholder.value,
conversationContext.clickListener::onUrlClicked,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false
linkColor,
underline
)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
@@ -44,6 +44,10 @@ class V2ConversationItemTheme(
return conversationContext.getColorizer().getIncomingFooterTextColor(context, conversationContext.hasWallpaper())
}
if (!conversationMessage.messageRecord.isOutgoing && conversationMessage.threadRecipient.isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble_text)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterTextColor,
@@ -55,6 +59,9 @@ class V2ConversationItemTheme(
fun getBodyTextColor(
conversationMessage: ConversationMessage
): Int {
if (!conversationMessage.messageRecord.isOutgoing && conversationMessage.threadRecipient.isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble_text)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingBodyTextColor,
@@ -79,6 +86,8 @@ class V2ConversationItemTheme(
): Int {
return if (conversationMessage.messageRecord.isOutgoing) {
Color.TRANSPARENT
} else if (conversationMessage.threadRecipient.isReleaseNotes) {
ContextCompat.getColor(context, R.color.release_notes_bubble)
} else {
if (conversationContext.hasWallpaper()) {
ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_wallpaper)
@@ -118,8 +118,12 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
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.contacts.paged.ContactSearchViewModelKt;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
@@ -231,7 +235,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
protected ConversationListArchiveItemDecoration archiveDecoration;
protected ConversationListItemAnimator itemAnimator;
private Stopwatch startupStopwatch;
private ContactSearchMediator contactSearchMediator;
private ContactSearchViewModel contactSearchViewModel;
private MainToolbarViewModel mainToolbarViewModel;
private ChatListBackHandler chatListBackHandler;
@@ -318,44 +322,34 @@ public class ConversationListFragment extends MainFragment implements Conversati
pullView = view.findViewById(R.id.pull_view);
pullViewAppBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
contactSearchMediator = new ContactSearchMediator(this,
Collections.emptySet(),
SelectionLimits.NO_LIMITS,
false,
new ContactSearchAdapter.DisplayOptions(
false,
ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
false,
false
),
this::mapSearchStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks(),
false,
(context,
fixedContacts,
displayOptions,
callbacks,
longClickCallbacks,
storyContextMenuCallbacks,
callButtonClickCallbacks
) -> {
//noinspection CodeBlock2Expr
return new ConversationListSearchAdapter(
context,
fixedContacts,
displayOptions,
new ContactSearchClickCallbacks(callbacks),
longClickCallbacks,
storyContextMenuCallbacks,
callButtonClickCallbacks,
getViewLifecycleOwner(),
Glide.with(this)
);
},
new ConversationListSearchAdapter.ChatFilterRepository()
contactSearchViewModel = new ViewModelProvider(this, new ContactSearchViewModel.Factory(
SelectionLimits.NO_LIMITS,
false,
new ContactSearchRepository(),
false,
new ConversationListSearchAdapter.ChatFilterRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
Collections.emptySet()
)).get(ContactSearchViewModel.class);
searchAdapter = new ConversationListSearchAdapter(
requireContext(),
Collections.emptySet(),
new ContactSearchAdapter.DisplayOptions(false, ContactSearchAdapter.DisplaySecondaryInformation.NEVER, false, false),
new ContactSearchClickCallbacks(),
new ContactSearchAdapter.LongClickCallbacksAdapter(),
new ContactSearchAdapter.StoryContextMenuCallbacks() {
@Override public void onOpenStorySettings(@NonNull ContactSearchData.Story story) {}
@Override public void onRemoveGroupStory(@NonNull ContactSearchData.Story story, boolean isSelected) {}
@Override public void onDeletePrivateStory(@NonNull ContactSearchData.Story story, boolean isSelected) {}
},
ContactSearchAdapter.EmptyCallButtonClickCallbacks.INSTANCE,
getViewLifecycleOwner(),
Glide.with(this)
);
searchAdapter = contactSearchMediator.getAdapter();
ContactSearchViewModelKt.bindAdapterToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), searchAdapter, this::mapSearchStateToConfiguration);
initializeSearchFilterListener();
@@ -436,7 +430,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
maybeScheduleRefreshProfileJob();
ConversationListFragmentExtensionsKt.listenToEventBusWhileResumed(this, mainNavigationViewModel.getDetailLocation());
String query = contactSearchMediator.getFilter();
String query = contactSearchViewModel.getQuery();
if (query != null) {
onSearchQueryUpdated(query);
}
@@ -465,7 +459,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
}));
if (isSplitPane(getWindowSizeClass(getResources()))) {
if (isSplitPane(getResources())) {
lifecycleDisposable.add(mainNavigationViewModel.getObservableActiveChatThreadId()
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(defaultAdapter::setActiveThreadId));
@@ -723,7 +717,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
lifecycleDisposable.add(
viewModel.getFilterRequestState().subscribe(request -> {
updateSearchToolbarHint(request);
contactSearchMediator.onConversationFilterRequestChanged(request);
contactSearchViewModel.setConversationFilterRequest(request);
})
);
@@ -773,13 +767,13 @@ public class ConversationListFragment extends MainFragment implements Conversati
authorIdStr != null ? RecipientId.from(Long.parseLong(authorIdStr)) : null
);
mainToolbarViewModel.setHasActiveSearchFilter(!activeSearchFilter.isEmpty());
contactSearchMediator.onSearchFilterChanged(activeSearchFilter);
contactSearchViewModel.setSearchFilter(activeSearchFilter);
break;
case SearchFilterBottomSheet.ACTION_CLEAR:
activeSearchFilter = SearchFilter.EMPTY;
mainToolbarViewModel.setHasActiveSearchFilter(false);
contactSearchMediator.onSearchFilterChanged(activeSearchFilter);
contactSearchViewModel.setSearchFilter(activeSearchFilter);
break;
case SearchFilterBottomSheet.ACTION_SELECT_AUTHOR:
@@ -1747,7 +1741,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
activeSearchFilter = SearchFilter.EMPTY;
mainToolbarViewModel.setHasActiveSearchFilter(false);
contactSearchMediator.onSearchFilterChanged(activeSearchFilter);
contactSearchViewModel.setSearchFilter(activeSearchFilter);
chatListBackHandler.setEnabled(false);
}
@@ -1755,7 +1749,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
private void onSearchQueryUpdated(@NonNull String query) {
String trimmed = query.trim();
contactSearchMediator.onFilterChanged(trimmed);
contactSearchViewModel.setQuery(trimmed);
if (!trimmed.isEmpty()) {
if (activeAdapter != searchAdapter && list != null) {
@@ -1970,12 +1964,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
private class ContactSearchClickCallbacks implements ConversationListSearchAdapter.ConversationListSearchClickCallbacks {
private final ContactSearchAdapter.ClickCallbacks delegate;
private ContactSearchClickCallbacks(@NonNull ContactSearchAdapter.ClickCallbacks delegate) {
this.delegate = delegate;
}
@Override
public void onThreadClicked(@NonNull View view, @NonNull ContactSearchData.Thread thread, boolean isSelected) {
onConversationClicked(thread.getThreadRecord());
@@ -2013,7 +2001,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
delegate.onExpandClicked(expand);
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
@@ -36,7 +35,7 @@ fun Fragment.listenToEventBusWhileResumed(
detailLocation
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED)
.collectLatest {
if (!resources.getWindowSizeClass().isSplitPane()) {
if (!resources.isSplitPane()) {
when (it) {
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
@@ -74,6 +74,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.push.DistributionId
import java.io.Closeable
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.util.Optional
@@ -111,6 +112,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
const val SHOW_AS_STORY_STATE = "show_as_story_state"
const val LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp"
const val GROUP_SEND_ENDORSEMENTS_EXPIRATION = "group_send_endorsements_expiration"
const val V2_VERIFIED_NAME_HASH = "verified_name_hash"
/** 32 bytes serialized [GroupMasterKey] */
const val V2_MASTER_KEY = "master_key"
@@ -124,14 +126,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$GROUP_ID TEXT NOT NULL UNIQUE,
$ID INTEGER PRIMARY KEY,
$GROUP_ID TEXT NOT NULL UNIQUE,
$RECIPIENT_ID INTEGER NOT NULL UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
$TITLE TEXT DEFAULT NULL,
$AVATAR_ID INTEGER DEFAULT 0,
$AVATAR_ID INTEGER DEFAULT 0,
$AVATAR_KEY BLOB DEFAULT NULL,
$AVATAR_CONTENT_TYPE TEXT DEFAULT NULL,
$AVATAR_DIGEST BLOB DEFAULT NULL,
$AVATAR_CONTENT_TYPE TEXT DEFAULT NULL,
$AVATAR_DIGEST BLOB DEFAULT NULL,
$TIMESTAMP INTEGER DEFAULT 0,
$IS_MEMBER INTEGER DEFAULT 1,
$MMS INTEGER DEFAULT 0,
@@ -144,7 +146,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
$SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code},
$LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0,
$GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0,
$TERMINATED_BY INTEGER DEFAULT 0
$TERMINATED_BY INTEGER DEFAULT 0,
$V2_VERIFIED_NAME_HASH BLOB DEFAULT NULL
)
"""
@@ -167,6 +170,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
V2_MASTER_KEY,
V2_REVISION,
V2_DECRYPTED_GROUP,
V2_VERIFIED_NAME_HASH,
LAST_FORCE_UPDATE_TIMESTAMP,
GROUP_SEND_ENDORSEMENTS_EXPIRATION
)
@@ -177,6 +181,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
.toList()
val CREATE_TABLES = arrayOf(CREATE_TABLE, MembershipTable.CREATE_TABLE)
@JvmStatic
fun computeVerifiedNameHash(title: String?): ByteArray? {
return title?.let { MessageDigest.getInstance("SHA-256").digest(it.toByteArray(Charsets.UTF_8)) }
}
}
class MembershipTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
@@ -544,6 +553,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
.run()
}
fun setVerifiedGroupNameHash(groupId: GroupId.V2, verifiedNameHash: ByteArray?) {
writableDatabase
.update(TABLE_NAME)
.values(V2_VERIFIED_NAME_HASH to verifiedNameHash)
.where("$GROUP_ID = ?", groupId)
.run()
}
@WorkerThread
fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List<RecipientId> {
return if (groupId.isV2) {
@@ -607,19 +624,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
throw LegacyGroupInsertException(groupId)
}
return create(groupId, title, members, avatar, null, null, null)
return create(groupId, title, members, avatar, null, null, null, null)
}
@CheckReturnValue
fun create(groupId: GroupId.Mms, title: String?, members: Collection<RecipientId>): Boolean {
return create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null)
return create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null, null)
}
@CheckReturnValue
fun create(groupMasterKey: GroupMasterKey, groupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?): GroupId.V2? {
@JvmOverloads
fun create(
groupMasterKey: GroupMasterKey,
groupState: DecryptedGroup,
groupSendEndorsements: ReceivedGroupSendEndorsements?,
verifiedNameHash: ByteArray? = null
): GroupId.V2? {
val groupId = GroupId.v2(groupMasterKey)
return if (create(groupId = groupId, title = groupState.title, memberCollection = emptyList(), avatar = null, groupMasterKey = groupMasterKey, groupState = groupState, receivedGroupSendEndorsements = groupSendEndorsements)) {
return if (create(groupId = groupId, title = groupState.title, memberCollection = emptyList(), avatar = null, groupMasterKey = groupMasterKey, groupState = groupState, receivedGroupSendEndorsements = groupSendEndorsements, verifiedNameHash = verifiedNameHash)) {
groupId
} else {
null
@@ -667,7 +690,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
avatar: SignalServiceAttachmentPointer?,
groupMasterKey: GroupMasterKey?,
groupState: DecryptedGroup?,
receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?
receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?,
verifiedNameHash: ByteArray?
): Boolean {
val membershipValues = mutableListOf<ContentValues>()
val groupRecipientId = recipients.getOrInsertFromGroupId(groupId)
@@ -716,6 +740,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
values.put(V2_MASTER_KEY, groupMasterKey.serialize())
values.put(V2_REVISION, groupState.revision)
values.put(V2_DECRYPTED_GROUP, groupState.encode())
values.put(V2_VERIFIED_NAME_HASH, verifiedNameHash)
membershipValues.clear()
membershipValues.addAll(groupMembers.toContentValues(groupId, receivedGroupSendEndorsements?.toGroupSendEndorsementRecords()))
} else {
@@ -787,11 +812,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
notifyConversationListListeners()
}
fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements, terminatorRecipientId)
@JvmOverloads
fun update(
groupMasterKey: GroupMasterKey,
decryptedGroup: DecryptedGroup,
groupSendEndorsements: ReceivedGroupSendEndorsements?,
terminatorRecipientId: RecipientId? = null,
selfAuthoredTitle: Boolean = false
) {
update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle)
}
fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup, receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
@JvmOverloads
fun update(
groupId: GroupId.V2,
decryptedGroup: DecryptedGroup,
receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?,
terminatorRecipientId: RecipientId? = null,
selfAuthoredTitle: Boolean = false
) {
val groupRecipientId: RecipientId = recipients.getOrInsertFromGroupId(groupId)
val existingGroup: Optional<GroupRecord> = getGroup(groupId)
val title: String = decryptedGroup.title
@@ -803,6 +842,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0)
contentValues.put(TERMINATED_BY, terminatorRecipientId?.toLong() ?: if (decryptedGroup.terminated) -1 else 0)
if (selfAuthoredTitle) {
contentValues.put(V2_VERIFIED_NAME_HASH, computeVerifiedNameHash(title))
}
if (receivedGroupSendEndorsements != null) {
contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs)
}
@@ -1165,6 +1208,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY),
groupRevision = cursor.requireInt(V2_REVISION),
decryptedGroupBytes = cursor.requireBlob(V2_DECRYPTED_GROUP),
verifiedNameHash = cursor.requireBlob(V2_VERIFIED_NAME_HASH),
distributionId = cursor.optionalString(DISTRIBUTION_ID).map { id -> DistributionId.from(id) }.orElse(null),
lastForceUpdateTimestamp = cursor.requireLong(LAST_FORCE_UPDATE_TIMESTAMP),
groupSendEndorsementExpiration = cursor.requireLong(GROUP_SEND_ENDORSEMENTS_EXPIRATION)
@@ -1013,6 +1013,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val masterKey = GroupMasterKey(insert.proto.masterKey.toByteArray())
val groupId = GroupId.v2(masterKey)
val values = getValuesForStorageGroupV2(insert, true)
val verifiedNameHash: ByteArray? = insert.proto.verifiedNameHash.nullIfEmpty()?.toByteArray()
val createdId = writableDatabase.withinTransaction {
writableDatabase.insertOrThrow(TABLE_NAME, null, values)
@@ -1021,12 +1022,14 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
groups.create(
groupMasterKey = masterKey,
groupState = DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION),
groupSendEndorsements = null
groupSendEndorsements = null,
verifiedNameHash = verifiedNameHash
)
}
if (createdId == null) {
Log.w(TAG, "Unable to create restore placeholder for $groupId, group already exists")
groups.setVerifiedGroupNameHash(groupId, verifiedNameHash)
}
groups.setShowAsStoryState(groupId, insert.proto.storySendMode.toShowAsStoryState())
@@ -1040,7 +1043,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
Log.i(TAG, "Scheduling request for latest group info for $groupId")
AppDependencies.jobManager.add(RequestGroupV2InfoJob(groupId))
threads.applyStorageSyncUpdate(recipient.id, insert)
recipient.live().refresh()
AppDependencies.databaseObserver.notifyRecipientChanged(recipient.id)
}
fun applyStorageSyncGroupV2Update(update: StorageRecordUpdate<SignalGroupV2Record>) {
@@ -1060,8 +1063,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
groups.setShowAsStoryState(groupId, update.new.proto.storySendMode.toShowAsStoryState())
groups.setVerifiedGroupNameHash(groupId, update.new.proto.verifiedNameHash.nullIfEmpty()?.toByteArray())
threads.applyStorageSyncUpdate(recipient.id, update.new)
recipient.live().refresh()
AppDependencies.databaseObserver.notifyRecipientChanged(recipient.id)
}
fun applyStorageSyncAccountUpdate(update: StorageRecordUpdate<SignalAccountRecord>) {
@@ -168,6 +168,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V312_RefactorNameCo
import org.thoughtcrime.securesms.database.helpers.migration.V313_AddCollapsingUpdateColumns
import org.thoughtcrime.securesms.database.helpers.migration.V314_FixMessageRequestAcceptedToRecipient
import org.thoughtcrime.securesms.database.helpers.migration.V315_CleanupE164SenderKeyShared
import org.thoughtcrime.securesms.database.helpers.migration.V316_AddVerifiedGroupNameHashMigration
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -343,10 +344,11 @@ object SignalDatabaseMigrations {
312 to V312_RefactorNameCollisionTables,
313 to V313_AddCollapsingUpdateColumns,
314 to V314_FixMessageRequestAcceptedToRecipient,
315 to V315_CleanupE164SenderKeyShared
315 to V315_CleanupE164SenderKeyShared,
316 to V316_AddVerifiedGroupNameHashMigration
)
const val DATABASE_VERSION = 315
const val DATABASE_VERSION = 316
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
@Suppress("ClassName")
object V316_AddVerifiedGroupNameHashMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE groups ADD COLUMN verified_name_hash BLOB DEFAULT NULL")
}
}
@@ -31,6 +31,7 @@ class GroupRecord(
groupMasterKeyBytes: ByteArray?,
groupRevision: Int,
decryptedGroupBytes: ByteArray?,
val verifiedNameHash: ByteArray? = null,
val distributionId: DistributionId?,
val lastForceUpdateTimestamp: Long,
val groupSendEndorsementExpiration: Long
@@ -16,6 +16,17 @@ import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.mediasend.MediaSendDependencies
import org.signal.network.api.ArchiveApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
import org.signal.network.api.LinkDeviceApi
import org.signal.network.api.PaymentsApi
import org.signal.network.api.ProvisioningApi
import org.signal.network.api.RateLimitChallengeApi
import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender
@@ -52,27 +63,16 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.calling.CallingApi
import org.whispersystems.signalservice.api.cds.CdsApi
import org.whispersystems.signalservice.api.certificate.CertificateApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.configuration.HttpProxy
@@ -89,30 +89,30 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.archive.ArchiveApi;
import org.signal.network.api.ArchiveApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.calling.CallingApi;
import org.whispersystems.signalservice.api.cds.CdsApi;
import org.whispersystems.signalservice.api.certificate.CertificateApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.whispersystems.signalservice.api.donations.DonationsApi;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.link.LinkDeviceApi;
import org.signal.network.api.LinkDeviceApi;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.payments.PaymentsApi;
import org.signal.network.api.PaymentsApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi;
import org.signal.network.api.ProvisioningApi;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi;
import org.signal.network.api.RateLimitChallengeApi;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi;
import org.signal.network.api.RemoteConfigApi;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.storage.StorageServiceApi;
import org.whispersystems.signalservice.api.svr.SvrBApi;
import org.whispersystems.signalservice.api.username.UsernameApi;
import org.signal.network.api.SvrBApi;
import org.signal.network.api.UsernameApi;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
@@ -16,6 +16,17 @@ import org.signal.core.util.orNull
import org.signal.core.util.resettableLazy
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
import org.signal.network.api.LinkDeviceApi
import org.signal.network.api.PaymentsApi
import org.signal.network.api.ProvisioningApi
import org.signal.network.api.RateLimitChallengeApi
import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
import org.thoughtcrime.securesms.groups.GroupsV2Authorization
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache
@@ -29,28 +40,17 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.calling.CallingApi
import org.whispersystems.signalservice.api.cds.CdsApi
import org.whispersystems.signalservice.api.certificate.CertificateApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.payments.PaymentsApi
import org.whispersystems.signalservice.api.profiles.ProfileApi
import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
import org.whispersystems.signalservice.api.util.Tls12SocketFactory
import org.whispersystems.signalservice.api.util.TlsProxySocketFactory
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.ProfileUtil;
import org.whispersystems.signalservice.api.groupsv2.DecryptChangeVerificationMode;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupExtensions;
@@ -235,7 +236,7 @@ final class GroupManagerV2 {
DecryptedGroup decryptedGroup = createGroupResponse.getGroup();
GroupMasterKey masterKey = groupSecretParams.getMasterKey();
ReceivedGroupSendEndorsements groupSendEndorsements = groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroup, createGroupResponse.getGroupSendEndorsementsResponse());
GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup, groupSendEndorsements);
GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup, groupSendEndorsements, GroupTable.computeVerifiedNameHash(decryptedGroup.title));
if (groupId == null) {
throw new GroupChangeFailedException("Unable to create group, group already exists");
@@ -745,7 +746,14 @@ final class GroupManagerV2 {
}
RecipientId terminatorRecipientId = (decryptedGroupState.terminated && !previousGroupState.terminated) ? Recipient.self().getId() : null;
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId);
boolean selfAuthoredTitle = changeActions.modifyTitle != null;
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId, selfAuthoredTitle);
if (selfAuthoredTitle) {
RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromGroupId(groupId);
SignalDatabase.recipients().rotateStorageId(groupRecipientId, false);
StorageSyncHelper.scheduleSyncForDataChange();
}
GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState);
RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange, sendToMembers);
@@ -18,6 +18,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
@@ -591,26 +593,7 @@ class GroupsV2StateProcessor private constructor(
Log.i(TAG, "$logPrefix Local state (revision: ${currentLocalState?.revision}) does not match, updating to ${updatedGroupState.revision}")
}
val wasTerminated = updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)
val terminatorRecipientId: RecipientId? = if (wasTerminated) {
groupStateDiff.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) }
?.let { RecipientId.from(it) }
} else {
null
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
if (terminatorRecipientId != null) {
profileAndMessageHelper.stopAllTypingForGroup()
}
if (wasTerminated) {
ConversationShortcutUpdateJob.enqueue()
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements)
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
if (!updatedGroupState.terminated) {
@@ -639,32 +622,83 @@ class GroupsV2StateProcessor private constructor(
return InternalUpdateResult.Updated(updatedGroupState)
}
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
private fun saveGroupState(
groupStateDiff: GroupStateDiff,
updatedGroupState: DecryptedGroup,
groupSendEndorsements: ReceivedGroupSendEndorsements?
) {
val previousGroupState = groupStateDiff.previousGroupState
if (groupSendEndorsements != null) {
Log.i(TAG, "$logPrefix Updating send endorsements")
}
val wasTerminated = updatedGroupState.terminated && (previousGroupState == null || !previousGroupState.terminated)
val terminatorRecipientId: RecipientId? = if (wasTerminated) {
groupStateDiff
.serverHistory
.asSequence()
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) }
?.let { RecipientId.from(it) }
} else {
null
}
val selfAuthoredTitle: Boolean = run {
val lastTitleChange = groupStateDiff
.serverHistory
.asSequence()
.mapNotNull { it.change }
.lastOrNull { it.newTitle != null }
if (lastTitleChange != null) {
return@run ServiceId.parseOrNull(lastTitleChange.editorServiceIdBytes) == serviceIds.aci
}
if (previousGroupState == null && updatedGroupState.revision == 0) {
val rev0Editor = groupStateDiff
.serverHistory
.firstOrNull { it.group?.revision == 0 }
?.change
?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) }
return@run rev0Editor == serviceIds.aci
}
false
}
val needsAvatarFetch = if (previousGroupState == null) {
val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState, groupSendEndorsements)
val verifiedNameHash: ByteArray? = if (selfAuthoredTitle) GroupTable.computeVerifiedNameHash(updatedGroupState.title) else null
val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState, groupSendEndorsements, verifiedNameHash)
if (groupId == null) {
Log.w(TAG, "$logPrefix Group create failed, trying to update")
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle)
}
updatedGroupState.avatar.isNotEmpty()
} else {
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle)
updatedGroupState.avatar != previousGroupState.avatar
}
if (wasTerminated) {
profileAndMessageHelper.stopAllTypingForGroup()
ConversationShortcutUpdateJob.enqueue()
}
if (needsAvatarFetch) {
AppDependencies.jobManager.add(AvatarGroupsV2DownloadJob(groupId, updatedGroupState.avatar))
}
if (selfAuthoredTitle) {
profileAndMessageHelper.scheduleStorageServiceSync()
}
profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState, needsAvatarFetch)
}
@@ -1003,6 +1037,13 @@ class GroupsV2StateProcessor private constructor(
return Optional.empty()
}
fun scheduleStorageServiceSync() {
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
SignalDatabase.recipients.rotateStorageId(groupRecipientId)
Recipient.live(groupRecipientId).refresh()
StorageSyncHelper.scheduleSyncForDataChange()
}
companion object {
@VisibleForTesting
fun create(aci: ACI, masterKey: GroupMasterKey, groupId: GroupId.V2): ProfileAndMessageHelper {
@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonProperty
import okhttp3.OkHttpClient
import okhttp3.Request
import org.signal.core.util.Hex
import org.signal.core.util.forEach
@@ -16,7 +17,6 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.apkupdate.ApkUpdateDownloadManagerReceiver
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -63,7 +63,7 @@ class ApkUpdateJob private constructor(parameters: Parameters) : BaseJob(paramet
Log.d(TAG, "Checking for APK update at ${BuildConfig.APK_UPDATE_MANIFEST_URL}")
val client = AppDependencies.signalOkHttpClient
val client = OkHttpClient()
val request = Request.Builder().url(BuildConfig.APK_UPDATE_MANIFEST_URL).build()
val rawUpdateDescriptor: String = client.newCall(request).execute().use { response ->
@@ -31,8 +31,10 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.ByteArrayInputStream
import java.io.IOException
@@ -176,7 +178,9 @@ class ArchiveThumbnailUploadJob private constructor(
return Result.failure()
}
val form: AttachmentUploadForm = when (val formResult = BackupRepository.getAttachmentUploadForm()) {
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(thumbnailResult.data.size.toLong()))
val form: AttachmentUploadForm = when (val formResult = BackupRepository.getAttachmentUploadForm(ciphertextLength)) {
is NetworkResult.Success -> formResult.result
is NetworkResult.ApplicationError -> {
Log.w(TAG, "Failed to get upload form due to an application error. Retrying.", formResult.throwable)
@@ -192,6 +196,13 @@ class ArchiveThumbnailUploadJob private constructor(
Log.w(TAG, "Rate limited when getting upload form.")
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
413 -> {
Log.w(TAG, "Thumbnail is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
SignalDatabase.attachments.setArchiveThumbnailTransferState(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
Result.failure()
}
else -> {
Log.w(TAG, "Failed to get upload form with status code ${formResult.code}")
Result.retry(defaultBackoff())
@@ -23,6 +23,7 @@ import org.signal.core.util.logging.logW
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.net.SvrBStoreResponse
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.network.api.SvrBApi
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
@@ -55,7 +56,6 @@ import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.File
import java.io.FileInputStream
@@ -123,6 +123,10 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob {
continue;
}
if (Recipient.externalGroupExact(id).isBlocked()) {
continue;
}
ByteString selfUuidBytes = Recipient.self().requireAci().toByteString();
boolean isActive = group.get().isActive();
DecryptedMember selfMember = group.get().requireV2GroupProperties().getDecryptedGroup().members
@@ -170,6 +174,22 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob {
return;
}
Optional<GroupRecord> group = SignalDatabase.groups().getGroup(groupId);
if (!group.isPresent()) {
Log.w(TAG, "Group " + group + " no longer exists?");
return;
}
if (Recipient.externalGroupExact(groupId).isBlocked()) {
Log.i(TAG, "Not updating blocked group " + groupId);
return;
}
if (!group.get().isActive()) {
Log.i(TAG, "Group is not active, skipping update.");
return;
}
Log.i(TAG, "Ensuring profile key up to date on group " + groupId);
GroupManager.updateSelfProfileKeyInGroup(context, groupId);
}
@@ -94,6 +94,7 @@ import org.thoughtcrime.securesms.migrations.RecheckPaymentsMigrationJob;
import org.thoughtcrime.securesms.migrations.ReleaseChannelRecipientFixMigrationJob;
import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob;
import org.thoughtcrime.securesms.migrations.ResetArchiveTierMigrationJob;
import org.thoughtcrime.securesms.migrations.ResetKeyTransparencyMigrationJob;
import org.thoughtcrime.securesms.migrations.SelfRegisteredStateMigrationJob;
import org.thoughtcrime.securesms.migrations.StickerAdditionMigrationJob;
import org.thoughtcrime.securesms.migrations.StickerDayByDayMigrationJob;
@@ -351,6 +352,7 @@ public final class JobManagerFactories {
put(ReleaseChannelRecipientFixMigrationJob.KEY, new ReleaseChannelRecipientFixMigrationJob.Factory());
put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory());
put(ResetArchiveTierMigrationJob.KEY, new ResetArchiveTierMigrationJob.Factory());
put(ResetKeyTransparencyMigrationJob.KEY, new ResetKeyTransparencyMigrationJob.Factory());
put(SelfRegisteredStateMigrationJob.KEY, new SelfRegisteredStateMigrationJob.Factory());
put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory());
put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory());
@@ -137,9 +137,11 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
archiveFileSystem.deleteOldBackups()
stopwatch.split("delete-old")
archiveFileSystem.deleteUnusedFiles { completed, total ->
setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())), notification)
}
archiveFileSystem.deleteUnusedFiles(
deletionProgressListener = { completed, total ->
setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())), notification)
}
)
stopwatch.split("delete-unused")
stopwatch.stop(TAG)
@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.network.service.StorageServiceService
import org.signal.network.service.StorageServiceService.ManifestResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -14,8 +16,6 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestResult
import java.util.concurrent.TimeUnit
/**
@@ -56,7 +56,7 @@ class StorageAccountRestoreJob private constructor(parameters: Parameters) : Bas
SignalStore.storageService.storageKey
}
val repository = StorageServiceRepository(SignalNetwork.storageService)
val repository = StorageServiceService(SignalNetwork.storageService)
Log.i(TAG, "Retrieving manifest...")
val manifest: SignalStorageManifest? = when (val result = repository.getStorageManifest(storageServiceKey)) {
@@ -85,14 +85,14 @@ class StorageAccountRestoreJob private constructor(parameters: Parameters) : Bas
Log.i(TAG, "Retrieving account record...")
val records: List<SignalStorageRecord> = when (val result = repository.readStorageRecords(storageServiceKey, manifest.recordIkm, listOf(accountId.get()))) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
is StorageServiceRepository.StorageRecordResult.DecryptionError -> {
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> {
Log.w(TAG, "Account record was undecryptable. Not restoring. Force-pushing.")
AppDependencies.jobManager.add(StorageForcePushJob())
return
}
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
is StorageServiceService.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceService.StorageRecordResult.StatusCodeError -> throw result.exception
}
val record = if (records.isNotEmpty()) records[0] else null
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logI
import org.signal.network.service.StorageServiceService
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.NotificationProfileTables
@@ -25,7 +26,6 @@ import org.whispersystems.signalservice.api.storage.RecordIkm
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
import java.io.IOException
import java.util.Collections
import java.util.concurrent.TimeUnit
@@ -71,7 +71,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
}
val storageServiceKey = SignalStore.storageService.storageKey
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
val repository = StorageServiceService(AppDependencies.storageServiceApi)
val currentVersion: Long = when (val result = repository.getManifestVersion()) {
is NetworkResult.Success -> result.result
@@ -133,10 +133,10 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
if (newVersion > 1) {
Log.i(TAG, "Force-pushing data. Inserting ${inserts.size} IDs.")
when (val result = repository.resetAndWriteStorageRecords(storageServiceKey, manifest, inserts)) {
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
StorageServiceService.WriteStorageRecordsResult.Success -> Unit
is StorageServiceService.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceService.WriteStorageRecordsResult.NetworkError -> throw result.exception
StorageServiceService.WriteStorageRecordsResult.ConflictError -> {
Log.w(TAG, "Hit a conflict. Trying again.")
throw RetryLaterException()
}
@@ -144,10 +144,10 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
} else {
Log.i(TAG, "First version, normal push. Inserting ${inserts.size} IDs.")
when (val result = repository.writeStorageRecords(storageServiceKey, manifest, inserts, emptyList())) {
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
is StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
StorageServiceService.WriteStorageRecordsResult.Success -> Unit
is StorageServiceService.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceService.WriteStorageRecordsResult.NetworkError -> throw result.exception
is StorageServiceService.WriteStorageRecordsResult.ConflictError -> {
Log.w(TAG, "Hit a conflict. Trying again.")
throw RetryLaterException()
}
@@ -2,12 +2,12 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.models.storageservice.StorageKey
import org.signal.core.util.logging.Log
import org.signal.network.service.StorageServiceService
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
import java.util.concurrent.TimeUnit
/**
@@ -52,27 +52,27 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job
}
val storageServiceKey = SignalStore.storageService.storageKey
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
val repository = StorageServiceService(AppDependencies.storageServiceApi)
val currentManifest: SignalStorageManifest = when (val result = repository.getStorageManifest(restoreKey)) {
is StorageServiceRepository.ManifestResult.Success -> {
is StorageServiceService.ManifestResult.Success -> {
result.manifest
}
is StorageServiceRepository.ManifestResult.DecryptionError -> {
is StorageServiceService.ManifestResult.DecryptionError -> {
Log.w(TAG, "Failed to decrypt the manifest! Only recourse is to force push.", result.exception)
AppDependencies.jobManager.add(StorageForcePushJob())
return Result.failure()
}
is StorageServiceRepository.ManifestResult.NetworkError -> {
is StorageServiceService.ManifestResult.NetworkError -> {
Log.w(TAG, "Encountered a network error during read, retrying.", result.exception)
return Result.retry(defaultBackoff())
}
StorageServiceRepository.ManifestResult.NotFoundError -> {
StorageServiceService.ManifestResult.NotFoundError -> {
Log.w(TAG, "No existing manifest was found! Force pushing.")
AppDependencies.jobManager.add(StorageForcePushJob())
return Result.failure()
}
is StorageServiceRepository.ManifestResult.StatusCodeError -> {
is StorageServiceService.ManifestResult.StatusCodeError -> {
Log.w(TAG, "Encountered a status code error during read, retrying.", result.exception)
return Result.retry(defaultBackoff())
}
@@ -87,7 +87,7 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job
val manifestWithNewVersion = currentManifest.copy(version = currentManifest.version + 1)
return when (val result = repository.writeUnchangedManifest(storageServiceKey, manifestWithNewVersion)) {
StorageServiceRepository.WriteStorageRecordsResult.Success -> {
StorageServiceService.WriteStorageRecordsResult.Success -> {
Log.i(TAG, "Successfully rotated the manifest as version ${manifestWithNewVersion.version}.${manifestWithNewVersion.sourceDeviceId}. Clearing restore key.")
SignalStore.svr.masterKeyForInitialDataRestore = null
@@ -96,18 +96,18 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job
Result.success()
}
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
StorageServiceService.WriteStorageRecordsResult.ConflictError -> {
Log.w(TAG, "Hit a conflict! Enqueuing a sync followed by another rotation.")
AppDependencies.jobManager.add(StorageSyncJob.forRemoteChange())
AppDependencies.jobManager.add(StorageRotateManifestJob())
Result.failure()
}
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> {
is StorageServiceService.WriteStorageRecordsResult.StatusCodeError -> {
Log.w(TAG, "Encountered a non-conflict status code error during write. Failing.", result.exception)
Result.failure()
}
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> {
is StorageServiceService.WriteStorageRecordsResult.NetworkError -> {
Log.w(TAG, "Encountered a network error during write, retrying.", result.exception)
Result.retry(defaultBackoff())
}
@@ -8,6 +8,8 @@ import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.network.service.StorageServiceService
import org.signal.network.service.StorageServiceService.ManifestIfDifferentVersionResult
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.NotificationProfileTables
import org.thoughtcrime.securesms.database.RecipientTable
@@ -47,8 +49,6 @@ import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestIfDifferentVersionResult
import org.whispersystems.signalservice.api.storage.toSignalAccountRecord
import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.toSignalChatFolderRecord
@@ -247,7 +247,7 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
private fun performSync(storageServiceKey: StorageKey): Boolean {
val stopwatch = Stopwatch("StorageSync")
val db = SignalDatabase.rawDatabase
val repository = StorageServiceRepository(SignalNetwork.storageService)
val repository = StorageServiceService(SignalNetwork.storageService)
val localManifest = SignalStore.storageService.manifest
val remoteManifest = if (localManifestOutOfDate || localManifest.version < 1 || runAttempt >= 3) {
@@ -309,10 +309,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
Log.i(TAG, "[Remote Sync] Retrieving records for key difference.")
val remoteOnlyRecords = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, idDifference.remoteOnlyIds)) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
is StorageServiceRepository.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceService.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceService.StorageRecordResult.StatusCodeError -> throw result.exception
}
stopwatch.split("remote-records")
@@ -400,10 +400,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
StorageSyncValidations.validate(remoteWriteOperation, remoteManifest, needsForcePush, self)
when (val result = repository.writeStorageRecords(storageServiceKey, remoteWriteOperation.manifest, remoteWriteOperation.inserts, remoteWriteOperation.deletes)) {
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
StorageServiceService.WriteStorageRecordsResult.Success -> Unit
is StorageServiceService.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
is StorageServiceService.WriteStorageRecordsResult.NetworkError -> throw result.exception
StorageServiceService.WriteStorageRecordsResult.ConflictError -> {
Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying.")
localManifestOutOfDate = true
throw RetryLaterException()
@@ -428,10 +428,10 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
Log.i(TAG, "We have ${knownUnknownIds.size} unknown records that we can now process.")
val remote = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, knownUnknownIds)) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
is StorageServiceRepository.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceService.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceService.StorageRecordResult.StatusCodeError -> throw result.exception
}
val records = StorageRecordCollection(remote)
@@ -36,9 +36,11 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.archive.ArchiveMediaUploadFormStatusCodes
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.FileNotFoundException
@@ -230,8 +232,10 @@ class UploadAttachmentToArchiveJob private constructor(
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size))
val form: AttachmentUploadForm? = if (existingSpec == null) {
when (val formResult = BackupRepository.getAttachmentUploadForm()) {
when (val formResult = BackupRepository.getAttachmentUploadForm(ciphertextLength)) {
is NetworkResult.Success -> formResult.result
is NetworkResult.ApplicationError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Failed to get upload form due to an application error.", formResult.throwable)
@@ -248,6 +252,13 @@ class UploadAttachmentToArchiveJob private constructor(
Log.w(TAG, "[$attachmentId]$mediaIdLog Rate limited when getting upload form.")
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
ArchiveMediaUploadFormStatusCodes.MediaTooLarge -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Media is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
Result.failure()
}
else -> Result.retry(defaultBackoff())
}
}
@@ -43,11 +43,9 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.isSplitPane
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.compose.FragmentBackHandler
@@ -247,17 +245,17 @@ private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffol
*/
@Stable
class ChatNavGraphState private constructor(
val windowSizeClass: WindowSizeClass,
val isSplitPane: Boolean,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(windowSizeClass: WindowSizeClass): ChatNavGraphState {
fun remember(isSplitPane: Boolean): ChatNavGraphState {
val graphicsLayer = rememberGraphicsLayer()
return remember(windowSizeClass) {
return remember(isSplitPane) {
ChatNavGraphState(
windowSizeClass,
isSplitPane,
graphicsLayer
)
}
@@ -271,7 +269,7 @@ class ChatNavGraphState private constructor(
suspend fun writeGraphicsLayerToBitmap() {
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
if (Build.VERSION.SDK_INT >= 26 && !windowSizeClass.isSplitPane() && hasWrittenToGraphicsLayer) {
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
@@ -13,18 +13,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.showSnackbar
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.rememberSnackbarState
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -64,7 +65,7 @@ fun MainBottomChrome(
megaphoneActionController: MegaphoneActionController,
modifier: Modifier = Modifier
) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val navigationType = NavigationType.rememberNavigationType()
Column(
@@ -92,7 +93,7 @@ fun MainBottomChrome(
)
}
if (windowSizeClass.isSplitPane()) {
if (isSplitPane) {
return@Column
}
@@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
private val MEDIUM_CONTENT_CORNERS = 18.dp
private val EXTENDED_CONTENT_CORNERS = 14.dp
@@ -47,7 +47,7 @@ data class MainContentLayoutData(
*/
@Composable
fun hasDragHandle(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
return LocalResources.current.rememberIsSplitPane()
}
/**
@@ -55,11 +55,12 @@ data class MainContentLayoutData(
*/
@Composable
fun rememberDefaultPanePreferredWidth(maxWidth: Dp): Dp {
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
return remember(maxWidth, windowSizeClass) {
return remember(maxWidth, windowSizeClass, isSplitPane) {
when {
!windowSizeClass.isSplitPane() -> maxWidth
!isSplitPane -> maxWidth
windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> 416.dp
else -> (maxWidth - extraPadding) / 2f
}
@@ -75,9 +76,9 @@ data class MainContentLayoutData(
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val breakpoint = resources.getWindowBreakpoint()
val isSplitPane = resources.rememberIsSplitPane()
return remember(windowSizeClass, mode, breakpoint) {
val isSplitPane = windowSizeClass.isSplitPane()
return remember(windowSizeClass, mode, breakpoint, isSplitPane) {
val isLargeWindowSize = breakpoint == WindowBreakpoint.LARGE
MainContentLayoutData(
@@ -120,7 +120,7 @@ class MainNavigationViewModel(
* where the user can change configurations (such as opening a foldable) and we will restore state and errantly
* take them back into a PRIMARY pane. This boolean helps avoid these cases.
*/
private var lockPaneToSecondary: Boolean by savedStateHandle.delegate(LOCK_PANE_TO_SECONDARY, false)
private var lockPaneToSecondary: Boolean by savedStateHandle.delegate(LOCK_PANE_TO_SECONDARY, true)
val snackbarRegistry = SnackbarStateConsumerRegistry()
@@ -11,7 +11,12 @@ import android.widget.EditText
import android.widget.FrameLayout
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.signal.core.ui.FixedRoundedCornerBottomSheetDialogFragment
import org.signal.core.util.DimensionUnit
import org.signal.core.util.getParcelableArrayListCompat
@@ -19,9 +24,13 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
@@ -35,11 +44,23 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
}
private lateinit var divider: View
private lateinit var mediator: ContactSearchMediator
private lateinit var contactSearch: ContactSearchView
private lateinit var innerContainer: View
private var animatorSet: AnimatorSet? = null
private val contactSearchViewModel: ContactSearchViewModel by viewModels {
ContactSearchViewModel.Factory(
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = true,
repository = ContactSearchRepository(),
performSafetyNumberChecks = false,
arbitraryRepository = null,
searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)).inflate(R.layout.stories_choose_group_bottom_sheet, container, false)
}
@@ -62,11 +83,10 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
onDone()
}
val contactRecycler: RecyclerView = view.findViewById(R.id.contact_recycler)
mediator = ContactSearchMediator(
fragment = this,
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = true,
contactSearch = view.findViewById(R.id.contact_recycler)
contactSearch.bind(
viewModel = contactSearchViewModel,
fragmentManager = childFragmentManager,
displayOptions = ContactSearchAdapter.DisplayOptions(
displayCheckBox = true,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
@@ -84,33 +104,35 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
)
}
},
performSafetyNumberChecks = false
contentBottomPaddingDp = 44f
)
contactRecycler.adapter = mediator.adapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
contactSearchViewModel.selectionState.collect { state ->
adapter.submitList(
state.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)
.map { it.recipientId }
.mapIndexed { index, recipientId ->
ShareSelectionMappingModel(
ShareContact(recipientId),
index == 0
)
}
)
mediator.getSelectionState().observe(viewLifecycleOwner) { state ->
adapter.submitList(
state.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)
.map { it.recipientId }
.mapIndexed { index, recipientId ->
ShareSelectionMappingModel(
ShareContact(recipientId),
index == 0
)
if (state.isEmpty()) {
animateOutBottomBar()
} else {
animateInBottomBar()
}
)
if (state.isEmpty()) {
animateOutBottomBar()
} else {
animateInBottomBar()
}
}
}
val searchField: EditText = view.findViewById(R.id.search_field)
searchField.doAfterTextChanged {
mediator.onFilterChanged(it?.toString())
contactSearchViewModel.setQuery(it?.toString())
}
}
@@ -150,7 +172,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
putParcelableArrayList(
RESULT_SET,
ArrayList(
mediator.getSelectedContacts()
contactSearchViewModel.getSelectedContacts()
.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)
.map { it.recipientId }
)
@@ -476,7 +476,7 @@ public final class Megaphones {
.setTitle(R.string.TurnOnSignalBackups__title)
.setBody(R.string.TurnOnSignalBackups__body)
.setActionButton(R.string.TurnOnSignalBackups__turn_on, (megaphone, controller) -> {
Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity());
Intent intent = AppSettingsActivity.backupsSettings(controller.getMegaphoneActivity(), true);
controller.onMegaphoneNavigationRequested(intent);
controller.onMegaphoneSnooze(Event.BACKUPS_GENERIC_UPSELL);
@@ -692,7 +692,7 @@ public final class Megaphones {
.setTitle(R.string.BackupMessagesUpsell__title)
.setBody(R.string.BackupMessagesUpsell__body)
.setActionButton(R.string.BackupMessagesUpsell__turn_on, (megaphone, controller) -> {
Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity());
Intent intent = AppSettingsActivity.backupsSettings(controller.getMegaphoneActivity(), true);
controller.onMegaphoneNavigationRequested(intent);
controller.onMegaphoneSnooze(Event.BACKUP_MESSAGE_COUNT_UPSELL);
})
@@ -25,11 +25,11 @@ class VerifyBackupKeyReminderSchedule : MegaphoneSchedule {
val isFirstReminder = !SignalStore.backup.hasVerifiedBefore
val intervalTime = if (isFirstReminder) 14.days.inWholeMilliseconds else 183.days.inWholeMilliseconds
val snoozedTime = if (previouslySnoozed) 7.days.inWholeMilliseconds else 0.days.inWholeMilliseconds
val shouldShowBackupKeyReminder = System.currentTimeMillis() > (lastVerifiedTime + intervalTime + snoozedTime)
val hasShownPinReminderRecently = System.currentTimeMillis() < SignalStore.pin.lastReminderTime + 7.days.inWholeMilliseconds
val intervalHasPassed = currentTime > (lastVerifiedTime + intervalTime)
val snoozeHasExpired = !previouslySnoozed || currentTime > (lastSeen + 7.days.inWholeMilliseconds)
val hasShownPinReminderRecently = currentTime < SignalStore.pin.lastReminderTime + 7.days.inWholeMilliseconds
return shouldShowBackupKeyReminder && !hasShownPinReminderRecently
return intervalHasPassed && snoozeHasExpired && !hasShownPinReminderRecently
}
}
@@ -12,7 +12,8 @@ class GroupInfo(
val hasExistingContacts: Boolean = false,
val membersPreview: List<Recipient> = emptyList(),
val isMember: Boolean = false,
val isTerminated: Boolean = false
val isTerminated: Boolean = false,
val nameVerified: Boolean = false
) {
companion object {
@JvmField

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