mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-09 09:40:14 +01:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b98b03971 | |||
| dfbdf30535 | |||
| d567555047 | |||
| 7658f6c36c | |||
| 51bd2d51c6 | |||
| a00978d96e | |||
| b700529c3b | |||
| 4051cf739c | |||
| 6031fc9113 | |||
| 454fe86dda | |||
| 92927ec69b | |||
| 9fa587b7e4 | |||
| 552361dff4 | |||
| 78a25a6186 | |||
| 58fcc07578 | |||
| 8cd92a400c | |||
| 5d207932c9 | |||
| 7c147982c4 | |||
| bde1a94122 | |||
| 2b66d7485a | |||
| 017b902c3c | |||
| 357fbfa8aa | |||
| 0ce667f4af | |||
| c4d78243c8 | |||
| 51e12b2c76 | |||
| 4dea1d8aa1 | |||
| 89c645dea3 | |||
| cd01d5f0b7 | |||
| 8730e28282 | |||
| 82046dd55f | |||
| 76e30ab09f | |||
| f680256f1d | |||
| da590a3241 | |||
| 91f73b473f | |||
| 53023517b3 | |||
| 7f831e6806 | |||
| 77a18111e1 | |||
| 2a699a23dd | |||
| 5643ffc1a9 | |||
| 90207b7dd7 | |||
| 5b7f668251 | |||
| 798bf3ec3e | |||
| 1c77c9d3fb | |||
| dd52d78ee0 | |||
| 4b1acca119 | |||
| 195fe60927 | |||
| f427f31303 | |||
| fa19ed7ffc | |||
| e5e99d4e03 | |||
| 26d1a7ada7 | |||
| 5dd11e26e4 | |||
| 9877b13c6e | |||
| d7d0fd3622 | |||
| 2439506c05 | |||
| 6088024f76 | |||
| 9decd81cfc | |||
| f27773a4e3 | |||
| 8d8c974a19 | |||
| 1a3e81dcb0 |
@@ -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/*"
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1680
|
||||
val canonicalVersionName = "8.8.0"
|
||||
val canonicalVersionCode = 1683
|
||||
val canonicalVersionName = "8.9.0"
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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 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 */
|
||||
|
||||
+3
-3
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+9
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
)
|
||||
|
||||
+5
-3
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -37,6 +37,7 @@ import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.roundedString
|
||||
import org.signal.core.util.stream.LimitedInputStream
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+12
-12
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-5
@@ -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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
) {
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
+87
@@ -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 = {}) })
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+16
-7
@@ -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 = {
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+16
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,129 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
init {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
}
|
||||
|
||||
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 currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@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 = currentOnRecyclerViewReady
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
-29
@@ -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)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,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 {
|
||||
|
||||
+33
-42
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+61
-39
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -666,7 +666,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 {
|
||||
@@ -1763,7 +1763,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(
|
||||
@@ -3746,7 +3746,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 +4359,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) {
|
||||
|
||||
+40
-52
@@ -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
|
||||
|
||||
+1
-2
@@ -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>) {
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+11
-11
@@ -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;
|
||||
|
||||
+11
-11
@@ -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);
|
||||
|
||||
+65
-24
@@ -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()
|
||||
|
||||
|
||||
+48
-26
@@ -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);
|
||||
})
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -31,11 +31,10 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
@@ -73,12 +72,13 @@ public final class MessageRequestRepository {
|
||||
boolean groupHasExistingContacts = recipients.stream().filter(r -> !r.isSelf()).anyMatch(r -> r.isProfileSharing() || r.isSystemContact());
|
||||
List<Recipient> membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList());
|
||||
DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup();
|
||||
boolean nameVerified = groupRecord.get().getVerifiedNameHash() != null && Arrays.equals(GroupTable.computeVerifiedNameHash(groupRecord.get().getTitle()), groupRecord.get().getVerifiedNameHash());
|
||||
|
||||
groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated());
|
||||
groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated(), nameVerified);
|
||||
} else {
|
||||
List<Recipient> membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList());
|
||||
|
||||
groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false);
|
||||
groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,9 +198,10 @@ public class ApplicationMigrations {
|
||||
static final int EMOJI_VERSION_13 = 154;
|
||||
static final int COLLAPSED_EVENTS = 155;
|
||||
static final int COLLAPSED_EVENTS_2 = 156;
|
||||
static final int KEY_TRANSPARENCY = 157;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 156;
|
||||
public static final int CURRENT_VERSION = 157;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
@@ -919,6 +920,10 @@ public class ApplicationMigrations {
|
||||
jobs.put(Version.COLLAPSED_EVENTS_2, new BackfillCollapsedEventsMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.KEY_TRANSPARENCY) {
|
||||
jobs.put(Version.KEY_TRANSPARENCY, new ResetKeyTransparencyMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.migrations
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Clears all existing key transparency data
|
||||
*/
|
||||
internal class ResetKeyTransparencyMigrationJob private constructor(parameters: Parameters) : MigrationJob(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "ResetKeyTransparencyMigrationJob"
|
||||
}
|
||||
|
||||
internal constructor() : this(Parameters.Builder().build())
|
||||
|
||||
override fun isUiBlocking(): Boolean = false
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun performMigration() {
|
||||
SignalStore.account.distinguishedHead = null
|
||||
SignalStore.misc.lastKeyTransparencyTime = 0
|
||||
SignalDatabase.recipients.clearAllKeyTransparencyData()
|
||||
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = false)
|
||||
}
|
||||
|
||||
override fun shouldRetry(e: Exception): Boolean = false
|
||||
|
||||
class Factory : Job.Factory<ResetKeyTransparencyMigrationJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): ResetKeyTransparencyMigrationJob {
|
||||
return ResetKeyTransparencyMigrationJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,25 @@
|
||||
|
||||
package org.thoughtcrime.securesms.net
|
||||
|
||||
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.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
|
||||
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.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.remoteconfig.RemoteConfigApi
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
import org.whispersystems.signalservice.api.svr.SvrBApi
|
||||
import org.whispersystems.signalservice.api.username.UsernameApi
|
||||
|
||||
/**
|
||||
* A convenient way to access network operations, similar to [org.thoughtcrime.securesms.database.SignalDatabase] and [org.thoughtcrime.securesms.keyvalue.SignalStore].
|
||||
|
||||
@@ -8,8 +8,7 @@ import androidx.annotation.RawRes;
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsApi;
|
||||
import org.signal.network.api.PaymentsApi;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -5,14 +5,12 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
import com.mobilecoin.lib.Verifier;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsApi;
|
||||
import org.signal.network.api.PaymentsApi;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsApi;
|
||||
import org.signal.network.api.PaymentsApi;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
+3
-1
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
@@ -34,6 +35,7 @@ import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.detailPaneMaxContentWidth
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.ScreenTitlePane
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
@@ -53,8 +55,8 @@ fun RecipientPickerScaffold(
|
||||
primaryContent: @Composable () -> Unit,
|
||||
floatingActionButton: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val isSplitPane = LocalResources.current.rememberIsSplitPane(forceSplitPane)
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPane = forceSplitPane)
|
||||
|
||||
AppScaffold(
|
||||
topBarContent = {
|
||||
|
||||
+39
-1
@@ -14,6 +14,7 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
@@ -48,6 +49,7 @@ import org.signal.registration.proto.RegistrationProvisionMessage
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.gcm.FcmUtil
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
@@ -538,7 +540,7 @@ class AppRegistrationNetworkController(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRemoteBackupInfo(): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
|
||||
override suspend fun getRemoteBackupInfo(aep: AccountEntropyPool): RequestResult<NetworkController.GetBackupInfoResponse, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
|
||||
val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available"))
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
@@ -589,6 +591,42 @@ class AppRegistrationNetworkController(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enqueueAccountAttributesSyncJob() {
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
}
|
||||
|
||||
override suspend fun getBackupFileLastModified(
|
||||
aep: AccountEntropyPool,
|
||||
backupInfo: NetworkController.GetBackupInfoResponse
|
||||
): RequestResult<Long, NetworkController.GetBackupInfoError> = withContext(Dispatchers.IO) {
|
||||
val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available"))
|
||||
val cdn = backupInfo.cdn ?: return@withContext RequestResult.ApplicationError(IllegalStateException("CDN number not available"))
|
||||
val backupDir = backupInfo.backupDir ?: return@withContext RequestResult.ApplicationError(IllegalStateException("Backup dir not available"))
|
||||
val backupName = backupInfo.backupName ?: return@withContext RequestResult.ApplicationError(IllegalStateException("Backup name not available"))
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val messageCredential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
|
||||
?: return@withContext RequestResult.ApplicationError(IllegalStateException("No message credential available"))
|
||||
|
||||
val access = ArchiveServiceAccess(messageCredential, SignalStore.backup.messageBackupKey)
|
||||
|
||||
val cdnCredentials = when (val cdnResult = SignalNetwork.archive.getCdnReadCredentials(cdn, aci, access)) {
|
||||
is NetworkResult.Success -> cdnResult.result.headers
|
||||
is NetworkResult.StatusCodeError -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to get CDN credentials: ${cdnResult.code}"))
|
||||
is NetworkResult.NetworkError -> return@withContext RequestResult.RetryableNetworkError(cdnResult.exception)
|
||||
is NetworkResult.ApplicationError -> return@withContext RequestResult.ApplicationError(cdnResult.throwable)
|
||||
}
|
||||
|
||||
try {
|
||||
val lastModified = AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(cdn, cdnCredentials, "backups/$backupDir/$backupName")
|
||||
RequestResult.Success(lastModified.toInstant().toEpochMilli())
|
||||
} catch (e: IOException) {
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
|
||||
val socketHandles = mutableListOf<java.io.Closeable>()
|
||||
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
|
||||
|
||||
+60
@@ -9,11 +9,17 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.archive.LocalBackupRestoreProgress
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.MasterKey
|
||||
@@ -22,8 +28,11 @@ import org.signal.registration.PreExistingRegistrationData
|
||||
import org.signal.registration.StorageController
|
||||
import org.signal.registration.proto.RegistrationData
|
||||
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
|
||||
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
@@ -318,6 +327,57 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
|
||||
backups.sortedByDescending { it.date }
|
||||
}
|
||||
|
||||
override fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress> = callbackFlow {
|
||||
val subscriber = object {
|
||||
@Subscribe(threadMode = ThreadMode.POSTING)
|
||||
fun onRestoreEvent(event: RestoreV2Event) {
|
||||
val progress = when (event.type) {
|
||||
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> RemoteBackupRestoreProgress.Downloading(event.count.inWholeBytes, event.estimatedTotalCount.inWholeBytes)
|
||||
RestoreV2Event.Type.PROGRESS_RESTORE -> RemoteBackupRestoreProgress.Restoring(event.count.inWholeBytes, event.estimatedTotalCount.inWholeBytes)
|
||||
RestoreV2Event.Type.PROGRESS_FINALIZING -> RemoteBackupRestoreProgress.Finalizing
|
||||
}
|
||||
trySend(progress)
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().register(subscriber)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
when (BackupRepository.restoreRemoteBackup()) {
|
||||
RemoteRestoreResult.Success -> {
|
||||
send(RemoteBackupRestoreProgress.Complete)
|
||||
}
|
||||
RemoteRestoreResult.NetworkError -> {
|
||||
send(RemoteBackupRestoreProgress.NetworkError())
|
||||
}
|
||||
RemoteRestoreResult.Canceled -> {
|
||||
send(RemoteBackupRestoreProgress.Canceled)
|
||||
}
|
||||
RemoteRestoreResult.Failure -> {
|
||||
if (SignalStore.backup.hasInvalidBackupVersion) {
|
||||
send(RemoteBackupRestoreProgress.InvalidBackupVersion)
|
||||
} else {
|
||||
send(RemoteBackupRestoreProgress.GenericError())
|
||||
}
|
||||
}
|
||||
RemoteRestoreResult.PermanentSvrBFailure -> {
|
||||
send(RemoteBackupRestoreProgress.PermanentSvrBFailure)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Remote restore failed", e)
|
||||
send(RemoteBackupRestoreProgress.GenericError(e))
|
||||
} finally {
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
EventBus.getDefault().unregister(subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
|
||||
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
|
||||
file.writeBytes(RegistrationData.ADAPTER.encode(data))
|
||||
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.LocalFragmentManager
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.or
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityFragment
|
||||
|
||||
/**
|
||||
* Compose content for the safety number bottom sheet.
|
||||
*
|
||||
* @param state Current sheet state from [SafetyNumberBottomSheetViewModel].
|
||||
* @param initialUntrustedCount Original untrusted recipient count from [SafetyNumberBottomSheetArgs],
|
||||
* used for the "You have X connections" subtitle when [SafetyNumberBottomSheetState.hasLargeNumberOfUntrustedRecipients] is true.
|
||||
* @param getIdentityRecord Suspending function that fetches the identity record for a recipient,
|
||||
* used when the user chooses to verify a safety number.
|
||||
* @param emitter Callback for user-driven events.
|
||||
*/
|
||||
@Composable
|
||||
fun SafetyNumberBottomSheetContent(
|
||||
state: SafetyNumberBottomSheetState,
|
||||
initialUntrustedCount: Int,
|
||||
getIdentityRecord: suspend (RecipientId) -> IdentityRecord?,
|
||||
emitter: (SafetyNumberBottomSheetEvent) -> Unit
|
||||
) {
|
||||
val recipients = remember(state) {
|
||||
if (!state.hasLargeNumberOfUntrustedRecipients) {
|
||||
state.destinationToRecipientMap.values.flatten().distinct()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val fragmentManager = LocalFragmentManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val wrappedEmitter: (SafetyNumberBottomSheetEvent) -> Unit = remember(emitter, fragmentManager) {
|
||||
{ event ->
|
||||
when (event) {
|
||||
is SafetyNumberBottomSheetEvent.VerifySafetyNumber -> scope.launch {
|
||||
val record = getIdentityRecord(event.recipientId) ?: return@launch
|
||||
val fm = fragmentManager ?: error("SafetyNumberBottomSheetContent requires a FragmentManager via LocalFragmentManager.")
|
||||
VerifyIdentityFragment.createDialog(
|
||||
recipientId = event.recipientId,
|
||||
remoteIdentity = IdentityKeyParcelable(record.identityKey),
|
||||
verified = false
|
||||
).show(fm, null)
|
||||
}
|
||||
|
||||
else -> emitter(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showReviewConnections by remember { mutableStateOf(false) }
|
||||
|
||||
SafetyNumberBottomSheetContentInternal(
|
||||
hasLargeList = state.hasLargeNumberOfUntrustedRecipients,
|
||||
isCheckupComplete = state.isCheckupComplete(),
|
||||
isEmpty = state.isEmpty(),
|
||||
sendAnywayFired = state.sendAnywayFired,
|
||||
recipients = recipients,
|
||||
initialUntrustedCount = initialUntrustedCount,
|
||||
onReviewConnectionsClick = {
|
||||
showReviewConnections = true
|
||||
emitter(SafetyNumberBottomSheetEvent.ReviewConnections)
|
||||
},
|
||||
emitter = wrappedEmitter
|
||||
)
|
||||
|
||||
if (showReviewConnections) {
|
||||
Dialog(
|
||||
onDismissRequest = { showReviewConnections = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
SafetyNumberReviewConnectionsScreen(
|
||||
state = state,
|
||||
onDoneClick = { showReviewConnections = false },
|
||||
emitter = wrappedEmitter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafetyNumberBottomSheetContentInternal(
|
||||
hasLargeList: Boolean,
|
||||
isCheckupComplete: Boolean,
|
||||
isEmpty: Boolean,
|
||||
sendAnywayFired: Boolean,
|
||||
recipients: List<SafetyNumberRecipient>,
|
||||
initialUntrustedCount: Int,
|
||||
onReviewConnectionsClick: () -> Unit,
|
||||
emitter: (SafetyNumberBottomSheetEvent) -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_safety_number_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = when {
|
||||
isCheckupComplete && hasLargeList -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup_complete
|
||||
hasLargeList -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup
|
||||
else -> R.string.SafetyNumberBottomSheetFragment__safety_number_changes
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
isCheckupComplete && hasLargeList -> stringResource(R.string.SafetyNumberBottomSheetFragment__all_connections_have_been_reviewed)
|
||||
hasLargeList -> pluralStringResource(R.plurals.SafetyNumberBottomSheetFragment__you_have_d_connections_plural, initialUntrustedCount, initialUntrustedCount)
|
||||
else -> stringResource(R.string.SafetyNumberBottomSheetFragment__the_following_people)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
|
||||
if (isEmpty) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.SafetyNumberBottomSheetFragment__no_more_recipients_to_show),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
} else if (!hasLargeList) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
recipients.forEach { safetyNumberRecipient ->
|
||||
SafetyNumberRecipientRow(safetyNumberRecipient = safetyNumberRecipient, emitter = emitter)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 16.dp)
|
||||
) {
|
||||
if (hasLargeList) {
|
||||
TextButton(onClick = onReviewConnectionsClick) {
|
||||
Text(text = stringResource(R.string.SafetyNumberBottomSheetFragment__review_connections))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Buttons.MediumTonal(enabled = !sendAnywayFired, onClick = { emitter(SafetyNumberBottomSheetEvent.SendAnyway) }) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isCheckupComplete) R.string.conversation_activity__send
|
||||
else R.string.SafetyNumberBottomSheetFragment__send_anyway
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SafetyNumberRecipientRow(
|
||||
safetyNumberRecipient: SafetyNumberRecipient,
|
||||
emitter: (SafetyNumberBottomSheetEvent) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
val displayName by rememberRecipientField(safetyNumberRecipient.recipient) { getDisplayName(context) }
|
||||
val identifier by rememberRecipientField(safetyNumberRecipient.recipient) { e164.or(username).orElse(null) }
|
||||
val isVerified = safetyNumberRecipient.identityRecord.verifiedStatus == IdentityTable.VerifiedStatus.VERIFIED
|
||||
val secondaryText = remember(identifier, isVerified) { buildSecondaryText(identifier, isVerified, context) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { menuController.show() }
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
) {
|
||||
AvatarImage(
|
||||
recipient = safetyNumberRecipient.recipient,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
if (!secondaryText.isNullOrBlank()) {
|
||||
Text(
|
||||
text = secondaryText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenus.Menu(controller = menuController) { controller ->
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = controller,
|
||||
drawableResId = R.drawable.ic_safety_number_24,
|
||||
stringResId = R.string.SafetyNumberBottomSheetFragment__verify_safety_number,
|
||||
onClick = { emitter(SafetyNumberBottomSheetEvent.VerifySafetyNumber(safetyNumberRecipient.recipient.id)) }
|
||||
)
|
||||
if (safetyNumberRecipient.distributionListMembershipCount > 0) {
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = controller,
|
||||
drawableResId = R.drawable.ic_circle_x_24,
|
||||
stringResId = R.string.SafetyNumberBottomSheetFragment__remove_from_story,
|
||||
onClick = { emitter(SafetyNumberBottomSheetEvent.RemoveFromStory(safetyNumberRecipient.recipient.id)) }
|
||||
)
|
||||
}
|
||||
if (safetyNumberRecipient.distributionListMembershipCount == 0 && safetyNumberRecipient.groupMembershipCount == 0) {
|
||||
DropdownMenus.ItemWithIcon(
|
||||
menuController = controller,
|
||||
drawableResId = R.drawable.ic_circle_x_24,
|
||||
stringResId = R.string.SafetyNumberReviewConnectionsFragment__remove,
|
||||
onClick = { emitter(SafetyNumberBottomSheetEvent.RemoveDestination(safetyNumberRecipient.recipient.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSecondaryText(identifier: String?, isVerified: Boolean, context: Context): String? {
|
||||
return when {
|
||||
isVerified && identifier.isNullOrBlank() -> context.getString(R.string.SafetyNumberRecipientRowItem__verified)
|
||||
isVerified -> context.getString(R.string.SafetyNumberRecipientRowItem__s_dot_verified, identifier)
|
||||
else -> identifier
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> Any?.unsafeCast(): T = this as T
|
||||
|
||||
private fun previewSafetyRecipient(
|
||||
firstName: String,
|
||||
lastName: String = "",
|
||||
isVerified: Boolean = false,
|
||||
distributionListMembershipCount: Int = 0,
|
||||
groupMembershipCount: Int = 0
|
||||
): SafetyNumberRecipient {
|
||||
return SafetyNumberRecipient(
|
||||
recipient = Recipient(profileName = ProfileName.fromParts(firstName, lastName)),
|
||||
identityRecord = IdentityRecord(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
identityKey = FakeIdentityKey(0),
|
||||
verifiedStatus = if (isVerified) IdentityTable.VerifiedStatus.VERIFIED else IdentityTable.VerifiedStatus.DEFAULT,
|
||||
firstUse = false,
|
||||
timestamp = 0L,
|
||||
nonblockingApproval = false
|
||||
),
|
||||
distributionListMembershipCount = distributionListMembershipCount,
|
||||
groupMembershipCount = groupMembershipCount
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PreviewSmallList() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SafetyNumberBottomSheetContent(
|
||||
state = SafetyNumberBottomSheetState(
|
||||
untrustedRecipientCount = 2,
|
||||
hasLargeNumberOfUntrustedRecipients = false,
|
||||
destinationToRecipientMap = mapOf(
|
||||
SafetyNumberBucket.ContactsBucket to listOf(
|
||||
previewSafetyRecipient("Alice", "Smith"),
|
||||
previewSafetyRecipient("Bob", "Chen", isVerified = true, distributionListMembershipCount = 1)
|
||||
)
|
||||
),
|
||||
loadState = SafetyNumberBottomSheetState.LoadState.READY
|
||||
),
|
||||
initialUntrustedCount = 2,
|
||||
getIdentityRecord = { null },
|
||||
emitter = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PreviewLargeList() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SafetyNumberBottomSheetContent(
|
||||
state = SafetyNumberBottomSheetState(
|
||||
untrustedRecipientCount = 12,
|
||||
hasLargeNumberOfUntrustedRecipients = true,
|
||||
loadState = SafetyNumberBottomSheetState.LoadState.READY
|
||||
),
|
||||
initialUntrustedCount = 12,
|
||||
getIdentityRecord = { null },
|
||||
emitter = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PreviewCheckupComplete() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SafetyNumberBottomSheetContent(
|
||||
state = SafetyNumberBottomSheetState(
|
||||
untrustedRecipientCount = 12,
|
||||
hasLargeNumberOfUntrustedRecipients = true,
|
||||
loadState = SafetyNumberBottomSheetState.LoadState.DONE
|
||||
),
|
||||
initialUntrustedCount = 12,
|
||||
getIdentityRecord = { null },
|
||||
emitter = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PreviewEmpty() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SafetyNumberBottomSheetContent(
|
||||
state = SafetyNumberBottomSheetState(
|
||||
untrustedRecipientCount = 1,
|
||||
hasLargeNumberOfUntrustedRecipients = false,
|
||||
loadState = SafetyNumberBottomSheetState.LoadState.READY
|
||||
),
|
||||
initialUntrustedCount = 1,
|
||||
getIdentityRecord = { null },
|
||||
emitter = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since ECPublicKey relies on native code that we don't have access to in
|
||||
* previews, this lets us create an 'IdentityKey' that doesn't break them.
|
||||
*/
|
||||
private class FakeIdentityKey(private val id: Int) : IdentityKey(null.unsafeCast<ECPublicKey>()) {
|
||||
override fun equals(other: Any?): Boolean = other is FakeIdentityKey && other.id == id
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult
|
||||
|
||||
/** One-shot side effects emitted by [SafetyNumberBottomSheetViewModel] for the fragment to handle. */
|
||||
sealed interface SafetyNumberBottomSheetEffect {
|
||||
/**
|
||||
* The trust-and-verify operation finished. The fragment should inspect [result],
|
||||
* fire the appropriate [SafetyNumberBottomSheet.Callbacks] method, then dismiss.
|
||||
*/
|
||||
data class TrustCompleted(
|
||||
val result: TrustAndVerifyResult,
|
||||
val destinations: List<ContactSearchKey.RecipientSearchKey>
|
||||
) : SafetyNumberBottomSheetEffect
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/** User-driven events emitted by the safety number bottom sheet UI. */
|
||||
sealed interface SafetyNumberBottomSheetEvent {
|
||||
/** The user confirmed they want to send despite safety number changes. */
|
||||
data object SendAnyway : SafetyNumberBottomSheetEvent
|
||||
|
||||
/** The user opened the full review-connections screen. */
|
||||
data object ReviewConnections : SafetyNumberBottomSheetEvent
|
||||
|
||||
/** The user requested to verify the safety number for [recipientId]. */
|
||||
data class VerifySafetyNumber(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent
|
||||
|
||||
/** The user removed [recipientId] from all selected distribution lists. */
|
||||
data class RemoveFromStory(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent
|
||||
|
||||
/** The user removed [recipientId] from the send destinations. */
|
||||
data class RemoveDestination(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent
|
||||
|
||||
/** The user removed all recipients from the given distribution list [bucket]. */
|
||||
data class RemoveAll(val bucket: SafetyNumberBucket.DistributionListBucket) : SafetyNumberBottomSheetEvent
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user