Merge branch 'dev' into strings-squashed
commit
e812527358
@ -1,135 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.MediaDocumentsAdapter.HeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.MediaDocumentsAdapter.ViewHolder;
|
||||
import org.thoughtcrime.securesms.components.DocumentView;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.mms.DocumentSlide;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
|
||||
import org.session.libsession.utilities.Util;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
import static com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager.TAG;
|
||||
|
||||
public class MediaDocumentsAdapter extends CursorRecyclerViewAdapter<ViewHolder> implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder> {
|
||||
|
||||
private final Calendar calendar;
|
||||
private final Locale locale;
|
||||
|
||||
MediaDocumentsAdapter(Context context, Cursor cursor, Locale locale) {
|
||||
super(context, cursor);
|
||||
|
||||
this.calendar = Calendar.getInstance();
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
||||
return new ViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
||||
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
|
||||
|
||||
if (slide != null && slide.hasDocument()) {
|
||||
viewHolder.documentView.setDocument((DocumentSlide)slide, false);
|
||||
|
||||
String relativeDate = DateUtils.INSTANCE.getRelativeDate(getContext(), locale, mediaRecord.getDate());
|
||||
viewHolder.date.setText(relativeDate);
|
||||
|
||||
viewHolder.documentView.setVisibility(View.VISIBLE);
|
||||
viewHolder.date.setVisibility(View.VISIBLE);
|
||||
viewHolder.documentView.setOnClickListener(view -> {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.setDataAndType(PartAuthority.getAttachmentPublicUri(slide.getUri()), slide.getContentType());
|
||||
try {
|
||||
getContext().startActivity(intent);
|
||||
} catch (ActivityNotFoundException anfe) {
|
||||
Log.w(TAG, "No activity existed to view the media.");
|
||||
Toast.makeText(getContext(), R.string.attachmentsErrorOpen, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
viewHolder.documentView.setVisibility(View.GONE);
|
||||
viewHolder.date.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int position) {
|
||||
if (!isActiveCursor()) return -1;
|
||||
if (isHeaderPosition(position)) return -1;
|
||||
if (isFooterPosition(position)) return -1;
|
||||
if (position >= getItemCount()) return -1;
|
||||
if (position < 0) return -1;
|
||||
|
||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
||||
|
||||
calendar.setTime(new Date(mediaRecord.getDate()));
|
||||
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) {
|
||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item_header, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
|
||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
||||
viewHolder.textView.setText(DateUtils.INSTANCE.getRelativeDate(getContext(), locale, mediaRecord.getDate()));
|
||||
}
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final DocumentView documentView;
|
||||
private final TextView date;
|
||||
|
||||
public ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
this.documentView = itemView.findViewById(R.id.document_view);
|
||||
this.date = itemView.findViewById(R.id.date);
|
||||
}
|
||||
}
|
||||
|
||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final TextView textView;
|
||||
|
||||
HeaderViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
this.textView = itemView.findViewById(R.id.text);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,173 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
|
||||
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
class MediaGalleryAdapter extends StickyHeaderGridAdapter {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = MediaGalleryAdapter.class.getSimpleName();
|
||||
|
||||
private final Context context;
|
||||
private final RequestManager glideRequests;
|
||||
private final Locale locale;
|
||||
private final ItemClickListener itemClickListener;
|
||||
private final Set<MediaRecord> selected;
|
||||
|
||||
private BucketedThreadMedia media;
|
||||
|
||||
private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder {
|
||||
ThumbnailView imageView;
|
||||
View selectedIndicator;
|
||||
|
||||
ViewHolder(View v) {
|
||||
super(v);
|
||||
imageView = v.findViewById(R.id.image);
|
||||
selectedIndicator = v.findViewById(R.id.selected_indicator);
|
||||
}
|
||||
}
|
||||
|
||||
private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder {
|
||||
TextView textView;
|
||||
|
||||
HeaderHolder(View itemView) {
|
||||
super(itemView);
|
||||
textView = itemView.findViewById(R.id.text);
|
||||
}
|
||||
}
|
||||
|
||||
MediaGalleryAdapter(@NonNull Context context,
|
||||
@NonNull RequestManager glideRequests,
|
||||
BucketedThreadMedia media,
|
||||
Locale locale,
|
||||
ItemClickListener clickListener)
|
||||
{
|
||||
this.context = context;
|
||||
this.glideRequests = glideRequests;
|
||||
this.locale = locale;
|
||||
this.media = media;
|
||||
this.itemClickListener = clickListener;
|
||||
this.selected = new HashSet<>();
|
||||
}
|
||||
|
||||
public void setMedia(BucketedThreadMedia media) {
|
||||
this.media = media;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) {
|
||||
return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item_header, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) {
|
||||
return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) {
|
||||
((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) {
|
||||
MediaRecord mediaRecord = media.get(section, offset);
|
||||
ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView;
|
||||
View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator;
|
||||
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
|
||||
|
||||
if (slide != null) {
|
||||
thumbnailView.setImageResource(glideRequests, slide, false);
|
||||
}
|
||||
|
||||
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
||||
thumbnailView.setOnLongClickListener(view -> {
|
||||
itemClickListener.onMediaLongClicked(mediaRecord);
|
||||
return true;
|
||||
});
|
||||
|
||||
selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSectionCount() {
|
||||
return media.getSectionCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSectionItemCount(int section) {
|
||||
return media.getSectionItemCount(section);
|
||||
}
|
||||
|
||||
public void toggleSelection(@NonNull MediaRecord mediaRecord) {
|
||||
if (!selected.remove(mediaRecord)) {
|
||||
selected.add(mediaRecord);
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public int getSelectedMediaCount() {
|
||||
return selected.size();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Collection<MediaRecord> getSelectedMedia() {
|
||||
return new HashSet<>(selected);
|
||||
}
|
||||
|
||||
public void clearSelection() {
|
||||
selected.clear();
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
void selectAllMedia() {
|
||||
for (int section = 0; section < media.getSectionCount(); section++) {
|
||||
for (int item = 0; item < media.getSectionItemCount(section); item++) {
|
||||
selected.add(media.get(section, item));
|
||||
}
|
||||
}
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
interface ItemClickListener {
|
||||
void onMediaClicked(@NonNull MediaRecord mediaRecord);
|
||||
void onMediaLongClicked(MediaRecord mediaRecord);
|
||||
}
|
||||
}
|
@ -1,523 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.database.Cursor;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.squareup.phrase.Phrase;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import kotlin.Unit;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.ViewUtil;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
|
||||
/**
|
||||
* Activity for displaying media attachments in-app
|
||||
*/
|
||||
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = MediaOverviewActivity.class.getSimpleName();
|
||||
|
||||
public static final String ADDRESS_EXTRA = "address";
|
||||
|
||||
private Toolbar toolbar;
|
||||
private TabLayout tabLayout;
|
||||
private ViewPager viewPager;
|
||||
private Recipient recipient;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
setContentView(R.layout.media_overview_activity);
|
||||
|
||||
initializeResources();
|
||||
initializeToolbar();
|
||||
|
||||
this.tabLayout.setupWithViewPager(viewPager);
|
||||
this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home: finish(); return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
this.viewPager = ViewUtil.findById(this, R.id.pager);
|
||||
this.toolbar = ViewUtil.findById(this, R.id.search_toolbar);
|
||||
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
|
||||
|
||||
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
|
||||
if (address == null) {
|
||||
Log.w(TAG, "Got null address in initializeResources.");
|
||||
} else {
|
||||
this.recipient = Recipient.from(this, address, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
setSupportActionBar(this.toolbar);
|
||||
ActionBar actionBar = getSupportActionBar();
|
||||
if (actionBar == null) {
|
||||
Log.w(TAG, "Could not get support actionbar");
|
||||
return;
|
||||
}
|
||||
// Implied else that the actionbar is fine to work with...
|
||||
actionBar.setTitle(recipient.toShortString());
|
||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||
actionBar.setHomeButtonEnabled(true);
|
||||
this.recipient.addListener(recipient -> {
|
||||
Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
|
||||
});
|
||||
}
|
||||
|
||||
public void onEnterMultiSelect() {
|
||||
tabLayout.setEnabled(false);
|
||||
viewPager.setEnabled(false);
|
||||
}
|
||||
|
||||
public void onExitMultiSelect() {
|
||||
tabLayout.setEnabled(true);
|
||||
viewPager.setEnabled(true);
|
||||
}
|
||||
|
||||
private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
|
||||
|
||||
MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
|
||||
super(fragmentManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fragment getItem(int position) {
|
||||
Fragment fragment;
|
||||
|
||||
if (position == 0) fragment = new MediaOverviewGalleryFragment();
|
||||
else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
|
||||
else throw new AssertionError();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
|
||||
args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
|
||||
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getPageTitle(int position) {
|
||||
if (position == 0) return getString(R.string.media);
|
||||
else if (position == 1) return getString(R.string.files);
|
||||
else throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
public static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
|
||||
|
||||
public static final String ADDRESS_EXTRA = "address";
|
||||
public static final String LOCALE_EXTRA = "locale_extra";
|
||||
|
||||
protected TextView noMedia;
|
||||
protected Recipient recipient;
|
||||
protected RecyclerView recyclerView;
|
||||
protected Locale locale;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
|
||||
String address = getArguments().getString(ADDRESS_EXTRA);
|
||||
Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA);
|
||||
|
||||
if (address == null) throw new AssertionError();
|
||||
if (locale == null) throw new AssertionError();
|
||||
|
||||
this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
|
||||
this.locale = locale;
|
||||
|
||||
getLoaderManager().initLoader(0, null, this);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MediaOverviewGalleryFragment
|
||||
extends MediaOverviewFragment<BucketedThreadMedia>
|
||||
implements MediaGalleryAdapter.ItemClickListener
|
||||
{
|
||||
|
||||
private StickyHeaderGridLayoutManager gridManager;
|
||||
private ActionMode actionMode;
|
||||
private ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
|
||||
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_images);
|
||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
||||
|
||||
this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(),
|
||||
Glide.with(this),
|
||||
new BucketedThreadMedia(getContext()),
|
||||
locale,
|
||||
this));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
this.recyclerView.setHasFixedSize(true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
if (gridManager != null) {
|
||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
||||
this.recyclerView.setLayoutManager(gridManager);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) {
|
||||
return new BucketedThreadMediaLoader(getContext(), recipient.getAddress());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<BucketedThreadMedia> loader, BucketedThreadMedia bucketedThreadMedia) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia);
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
|
||||
|
||||
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<BucketedThreadMedia> cursorLoader) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode != null) {
|
||||
handleMediaMultiSelectClick(mediaRecord);
|
||||
} else {
|
||||
handleMediaPreviewClick(mediaRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
MediaGalleryAdapter adapter = getListAdapter();
|
||||
|
||||
adapter.toggleSelection(mediaRecord);
|
||||
if (adapter.getSelectedMediaCount() == 0) {
|
||||
actionMode.finish();
|
||||
} else {
|
||||
actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (mediaRecord.getAttachment().getDataUri() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
|
||||
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
|
||||
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
|
||||
|
||||
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
|
||||
if (actionMode == null) {
|
||||
((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
|
||||
recyclerView.getAdapter().notifyDataSetChanged();
|
||||
|
||||
enterMultiSelect();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
||||
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
final Context context = requireContext();
|
||||
|
||||
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
|
||||
Permissions.with(this)
|
||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||
.withPermanentDenialDialog(Phrase.from(context, R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString())
|
||||
.onAnyDenied(() -> Toast.makeText(getContext(),
|
||||
Phrase.from(context, R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString(),
|
||||
Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
|
||||
context,
|
||||
R.string.attachmentsCollecting,
|
||||
R.string.waitOneMoment) {
|
||||
@Override
|
||||
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
|
||||
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
|
||||
|
||||
for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
|
||||
if (mediaRecord.getAttachment().getDataUri() != null) {
|
||||
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
|
||||
mediaRecord.getContentType(),
|
||||
mediaRecord.getDate(),
|
||||
mediaRecord.getAttachment().getFileName()));
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
|
||||
super.onPostExecute(attachments);
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
|
||||
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
|
||||
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
|
||||
actionMode.finish();
|
||||
boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
|
||||
if (containsIncoming) {
|
||||
sendMediaSavedNotificationIfNeeded();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
})
|
||||
.execute();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (recipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
MessageSender.send(message, recipient.getAddress());
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||
int recordCount = mediaRecords.size();
|
||||
|
||||
DeleteMediaDialog.show(
|
||||
requireContext(),
|
||||
recordCount,
|
||||
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
|
||||
requireContext(),
|
||||
R.string.deleting,
|
||||
R.string.deleting) {
|
||||
@Override
|
||||
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
||||
if (records == null || records.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (MediaDatabase.MediaRecord record : records) {
|
||||
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
|
||||
}
|
||||
|
||||
private void handleSelectAllMedia() {
|
||||
getListAdapter().selectAllMedia();
|
||||
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
|
||||
}
|
||||
|
||||
private MediaGalleryAdapter getListAdapter() {
|
||||
return (MediaGalleryAdapter) recyclerView.getAdapter();
|
||||
}
|
||||
|
||||
private void enterMultiSelect() {
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
|
||||
((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
|
||||
}
|
||||
|
||||
private class ActionModeCallback implements ActionMode.Callback {
|
||||
|
||||
private int originalStatusBarColor;
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
|
||||
mode.setTitle("1");
|
||||
|
||||
FragmentActivity activity = getActivity();
|
||||
if (activity == null) return false;
|
||||
|
||||
Window window = activity.getWindow();
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
window.setStatusBarColor(ContextCompat.getColor(activity, R.color.action_mode_status_bar));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
|
||||
switch (menuItem.getItemId()) {
|
||||
case R.id.save:
|
||||
handleSaveMedia(getListAdapter().getSelectedMedia());
|
||||
return true;
|
||||
case R.id.delete:
|
||||
handleDeleteMedia(getListAdapter().getSelectedMedia());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.select_all:
|
||||
handleSelectAllMedia();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
actionMode = null;
|
||||
getListAdapter().clearSelection();
|
||||
|
||||
MediaOverviewActivity activity = ((MediaOverviewActivity) getActivity());
|
||||
if(activity == null) return;
|
||||
|
||||
activity.onExitMultiSelect();
|
||||
activity.getWindow().setStatusBarColor(originalStatusBarColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
|
||||
MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
|
||||
|
||||
this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
|
||||
this.noMedia = ViewUtil.findById(view, R.id.no_documents);
|
||||
|
||||
this.recyclerView.setAdapter(adapter);
|
||||
this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
|
||||
this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true));
|
||||
this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
|
||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
|
||||
this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
|
||||
getActivity().invalidateOptionsMenu();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ListView
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
|
||||
class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
|
||||
private var mentionCandidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue }
|
||||
var glide: RequestManager? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue }
|
||||
var openGroupServer: String? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupServer = openGroupServer }
|
||||
var openGroupRoom: String? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupRoom = openGroupRoom }
|
||||
var onMentionCandidateSelected: ((Mention) -> Unit)? = null
|
||||
|
||||
private val mentionCandidateSelectionViewAdapter by lazy { Adapter(context) }
|
||||
|
||||
private class Adapter(private val context: Context) : BaseAdapter() {
|
||||
var mentionCandidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; notifyDataSetChanged() }
|
||||
var glide: RequestManager? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
override fun getCount(): Int {
|
||||
return mentionCandidates.count()
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Mention {
|
||||
return mentionCandidates[position]
|
||||
}
|
||||
|
||||
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context)
|
||||
val mentionCandidate = getItem(position)
|
||||
cell.glide = glide
|
||||
cell.mentionCandidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context) : this(context, null)
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
adapter = mentionCandidateSelectionViewAdapter
|
||||
mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
onMentionCandidateSelected?.invoke(mentionCandidates[position])
|
||||
}
|
||||
}
|
||||
|
||||
fun show(mentionCandidates: List<Mention>, threadID: Long) {
|
||||
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
||||
if (openGroup != null) {
|
||||
openGroupServer = openGroup.server
|
||||
openGroupRoom = openGroup.room
|
||||
}
|
||||
this.mentionCandidates = mentionCandidates
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources)
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = 0
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateBinding
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import com.bumptech.glide.RequestManager
|
||||
|
||||
class MentionCandidateView : LinearLayout {
|
||||
private lateinit var binding: ViewMentionCandidateBinding
|
||||
var mentionCandidate = Mention("", "")
|
||||
set(newValue) { field = newValue; update() }
|
||||
var glide: RequestManager? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||
|
||||
private fun initialize() {
|
||||
binding = ViewMentionCandidateBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
|
||||
private fun update() = with(binding) {
|
||||
mentionCandidateNameTextView.text = mentionCandidate.displayName
|
||||
profilePictureView.publicKey = mentionCandidate.publicKey
|
||||
profilePictureView.displayName = mentionCandidate.displayName
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
if (openGroupServer != null && openGroupRoom != null) {
|
||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
|
||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
moderatorIconImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
|
||||
@Composable
|
||||
fun AttachmentHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
){
|
||||
Text(
|
||||
modifier = modifier
|
||||
.background(LocalColors.current.background)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = LocalDimensions.current.smallSpacing,
|
||||
vertical = LocalDimensions.current.xsSpacing
|
||||
),
|
||||
text = text,
|
||||
style = LocalType.current.xl,
|
||||
color = LocalColors.current.text
|
||||
)
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.Util
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DocumentsPage(
|
||||
nestedScrollConnection: NestedScrollConnection,
|
||||
content: TabContent?,
|
||||
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||
) {
|
||||
when {
|
||||
content == null -> {
|
||||
// Loading
|
||||
}
|
||||
|
||||
content.isEmpty() -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.attachmentsFilesEmpty),
|
||||
style = LocalType.current.base,
|
||||
color = LocalColors.current.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.fillMaxSize()
|
||||
.padding(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing)
|
||||
) {
|
||||
for ((bucketTitle, files) in content) {
|
||||
stickyHeader {
|
||||
AttachmentHeader(text = bucketTitle)
|
||||
}
|
||||
|
||||
items(files) { file ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onItemClicked(file) })
|
||||
.padding(LocalDimensions.current.smallSpacing),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_document_large_dark),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) {
|
||||
Text(
|
||||
text = file.fileName.orEmpty(),
|
||||
style = LocalType.current.large,
|
||||
color = LocalColors.current.text
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = Util.getPrettyFileSize(file.fileSize),
|
||||
style = LocalType.current.small,
|
||||
color = LocalColors.current.textSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = file.date,
|
||||
style = LocalType.current.small,
|
||||
color = LocalColors.current.textSecondary,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import network.loki.messenger.R
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A data structure that describes a series of time points in the past. It's primarily
|
||||
* used to bucket items into categories like "Today", "Yesterday", "This week", "This month", etc.
|
||||
*
|
||||
* Call [getBucketText] to get the appropriate string resource for a given time. If no bucket is
|
||||
* appropriate, it will return null.
|
||||
*/
|
||||
class FixedTimeBuckets(
|
||||
private val startOfToday: ZonedDateTime,
|
||||
private val startOfYesterday: ZonedDateTime,
|
||||
private val startOfThisWeek: ZonedDateTime,
|
||||
private val startOfThisMonth: ZonedDateTime
|
||||
) {
|
||||
constructor(now: ZonedDateTime = ZonedDateTime.now()) : this(
|
||||
startOfToday = now.toLocalDate().atStartOfDay(now.zone),
|
||||
startOfYesterday = now.toLocalDate().minusDays(1).atStartOfDay(now.zone),
|
||||
startOfThisWeek = now.toLocalDate()
|
||||
.with(WeekFields.of(Locale.getDefault()).dayOfWeek(), 1)
|
||||
.atStartOfDay(now.zone),
|
||||
startOfThisMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay(now.zone)
|
||||
)
|
||||
|
||||
/**
|
||||
* Test the given time against the buckets and return the appropriate string resource the time
|
||||
* bucket. If no bucket is appropriate, it will return null.
|
||||
*/
|
||||
@StringRes
|
||||
fun getBucketText(time: ZonedDateTime): Int? {
|
||||
return when {
|
||||
time >= startOfToday -> R.string.BucketedThreadMedia_Today // Should be replaced with call to getLocalisedRelativeDayString
|
||||
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday // Should be replaced with call to getLocalisedRelativeDayString
|
||||
time >= startOfThisWeek -> R.string.attachmentsThisWeek
|
||||
time >= startOfThisMonth -> R.string.attachmentsThisMonth
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.ui.setComposeContent
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MediaOverviewActivity : PassphraseRequiredActionBarActivity() {
|
||||
@Inject
|
||||
lateinit var viewModelFactory: MediaOverviewViewModel.AssistedFactory
|
||||
|
||||
private val viewModel: MediaOverviewViewModel by viewModels {
|
||||
viewModelFactory.create(IntentCompat.getParcelableExtra(intent, EXTRA_ADDRESS, Address::class.java)!!)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setComposeContent {
|
||||
MediaOverviewScreen(viewModel, onClose = this::finish)
|
||||
}
|
||||
|
||||
supportActionBar?.hide()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ADDRESS = "address"
|
||||
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context, address: Address): Intent {
|
||||
return Intent(context, MediaOverviewActivity::class.java).apply {
|
||||
putExtra(EXTRA_ADDRESS, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,296 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.AlertDialog
|
||||
import org.thoughtcrime.securesms.ui.DialogButtonModel
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import org.thoughtcrime.securesms.ui.components.SessionTabRow
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
|
||||
@OptIn(
|
||||
ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class,
|
||||
)
|
||||
@Composable
|
||||
fun MediaOverviewScreen(
|
||||
viewModel: MediaOverviewViewModel,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val selectedItems by viewModel.selectedItemIDs.collectAsState()
|
||||
val selectionMode by viewModel.inSelectionMode.collectAsState()
|
||||
val topAppBarState = rememberTopAppBarState()
|
||||
var showingDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
var showingSaveAttachmentWarning by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val requestStoragePermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
viewModel.onSaveClicked()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.cameraGrantAccessDenied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// In selection mode, the app bar should not be scrollable and should be pinned
|
||||
val appBarScrollBehavior = if (selectionMode) {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(topAppBarState, canScroll = { false })
|
||||
} else {
|
||||
TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
|
||||
}
|
||||
|
||||
// Reset the top app bar offset (so that it shows up) when entering selection mode
|
||||
LaunchedEffect(selectionMode) {
|
||||
if (selectionMode) {
|
||||
topAppBarState.heightOffset = 0f
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = viewModel::onBackClicked)
|
||||
|
||||
// Event handling
|
||||
LaunchedEffect(viewModel.events) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
MediaOverviewEvent.Close -> onClose()
|
||||
is MediaOverviewEvent.NavigateToActivity -> {
|
||||
try {
|
||||
context.startActivity(event.intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.attachmentsErrorOpen,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
is MediaOverviewEvent.ShowSaveAttachmentError -> {
|
||||
Toast.makeText(context, R.string.attachmentsSaveError, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is MediaOverviewEvent.ShowSaveAttachmentSuccess -> {
|
||||
Toast.makeText(context, R.string.saved, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(appBarScrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediaOverviewTopAppBar(
|
||||
selectionMode = selectionMode,
|
||||
title = viewModel.title.collectAsState().value,
|
||||
onBackClicked = viewModel::onBackClicked,
|
||||
onSaveClicked = { showingSaveAttachmentWarning = true },
|
||||
onDeleteClicked = { showingDeleteConfirmation = true },
|
||||
onSelectAllClicked = viewModel::onSelectAllClicked,
|
||||
appBarScrollBehavior = appBarScrollBehavior
|
||||
)
|
||||
}
|
||||
) { paddings ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddings)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { MediaOverviewTab.entries.size })
|
||||
val selectedTab by viewModel.selectedTab.collectAsState()
|
||||
|
||||
// Apply "selectedTab" view model state to pager
|
||||
LaunchedEffect(selectedTab) {
|
||||
pagerState.animateScrollToPage(selectedTab.ordinal)
|
||||
}
|
||||
|
||||
// Apply "selectedTab" pager state to view model
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
viewModel.onTabItemClicked(MediaOverviewTab.entries[pagerState.currentPage])
|
||||
}
|
||||
|
||||
SessionTabRow(
|
||||
pagerState = pagerState,
|
||||
titles = MediaOverviewTab.entries.map { it.titleResId }
|
||||
)
|
||||
|
||||
val content = viewModel.mediaListState.collectAsState()
|
||||
val canLongPress = viewModel.canLongPress.collectAsState().value
|
||||
|
||||
HorizontalPager(
|
||||
pagerState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) { index ->
|
||||
when (MediaOverviewTab.entries[index]) {
|
||||
MediaOverviewTab.Media -> {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
MediaPage(
|
||||
content = content.value?.mediaContent,
|
||||
selectedItemIDs = selectedItems,
|
||||
onItemClicked = viewModel::onItemClicked,
|
||||
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
|
||||
onItemLongClicked = if(canLongPress){{
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.onItemLongClicked(it)
|
||||
}} else null
|
||||
)
|
||||
}
|
||||
|
||||
MediaOverviewTab.Documents -> DocumentsPage(
|
||||
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
|
||||
content = content.value?.documentContent,
|
||||
onItemClicked = viewModel::onItemClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showingDeleteConfirmation) {
|
||||
DeleteConfirmationDialog(
|
||||
onDismissRequest = { showingDeleteConfirmation = false },
|
||||
onAccepted = viewModel::onDeleteClicked,
|
||||
numSelected = selectedItems.size
|
||||
)
|
||||
}
|
||||
|
||||
if (showingSaveAttachmentWarning) {
|
||||
SaveAttachmentWarningDialog(
|
||||
onDismissRequest = { showingSaveAttachmentWarning = false },
|
||||
onAccepted = {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
requestStoragePermission.launch(WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
viewModel.onSaveClicked()
|
||||
}
|
||||
},
|
||||
numSelected = selectedItems.size
|
||||
)
|
||||
}
|
||||
|
||||
val showingActionDialog = viewModel.showingActionProgress.collectAsState().value
|
||||
if (showingActionDialog != null) {
|
||||
ActionProgressDialog(showingActionDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveAttachmentWarningDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onAccepted: () -> Unit,
|
||||
numSelected: Int,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = context.getString(R.string.permissionsRequired),
|
||||
text = context.resources.getString(R.string.attachmentsWarning),
|
||||
buttons = listOf(
|
||||
DialogButtonModel(GetString(R.string.save), onClick = onAccepted),
|
||||
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmationDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onAccepted: () -> Unit,
|
||||
numSelected: Int,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = context.resources.getQuantityString(
|
||||
R.plurals.ConversationFragment_delete_selected_messages, numSelected
|
||||
),
|
||||
text = context.resources.getQuantityString(
|
||||
R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages,
|
||||
numSelected,
|
||||
numSelected,
|
||||
),
|
||||
buttons = listOf(
|
||||
DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted),
|
||||
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun ActionProgressDialog(
|
||||
text: String
|
||||
) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(LocalColors.current.background, shape = MaterialTheme.shapes.medium)
|
||||
.padding(LocalDimensions.current.mediumSpacing),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(color = LocalColors.current.primary)
|
||||
Text(
|
||||
text,
|
||||
style = LocalType.current.large,
|
||||
color = LocalColors.current.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MediaOverviewTab.titleResId: Int
|
||||
get() = when (this) {
|
||||
MediaOverviewTab.Media -> R.string.media
|
||||
MediaOverviewTab.Documents -> R.string.document
|
||||
}
|
||||
|
@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.components.ActionAppBar
|
||||
import org.thoughtcrime.securesms.ui.components.AppBarBackIcon
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun MediaOverviewTopAppBar(
|
||||
selectionMode: Boolean,
|
||||
title: String,
|
||||
onBackClicked: () -> Unit,
|
||||
onSaveClicked: () -> Unit,
|
||||
onDeleteClicked: () -> Unit,
|
||||
onSelectAllClicked: () -> Unit,
|
||||
appBarScrollBehavior: TopAppBarScrollBehavior
|
||||
) {
|
||||
ActionAppBar(
|
||||
title = title,
|
||||
navigationIcon = {AppBarBackIcon(onBack = onBackClicked)},
|
||||
scrollBehavior = appBarScrollBehavior,
|
||||
actionMode = selectionMode,
|
||||
actionModeActions = {
|
||||
IconButton(onClick = onSaveClicked) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_baseline_save_24),
|
||||
contentDescription = stringResource(R.string.save),
|
||||
tint = LocalColors.current.text,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDeleteClicked) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_baseline_delete_24),
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
tint = LocalColors.current.text,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onSelectAllClicked) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_baseline_select_all_24),
|
||||
contentDescription = stringResource(R.string.selectAll),
|
||||
tint = LocalColors.current.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,427 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
||||
import org.thoughtcrime.securesms.util.asSequence
|
||||
import org.thoughtcrime.securesms.util.observeChanges
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
class MediaOverviewViewModel(
|
||||
private val address: Address,
|
||||
private val application: Application,
|
||||
private val threadDatabase: ThreadDatabase,
|
||||
private val mediaDatabase: MediaDatabase
|
||||
) : AndroidViewModel(application) {
|
||||
private val timeBuckets by lazy { FixedTimeBuckets() }
|
||||
private val monthTimeBucketFormatter =
|
||||
DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
|
||||
|
||||
private val recipient: SharedFlow<Recipient> = application.contentResolver
|
||||
.observeChanges(DatabaseContentProviders.Attachment.CONTENT_URI)
|
||||
.onStart { emit(DatabaseContentProviders.Attachment.CONTENT_URI) }
|
||||
.map { Recipient.from(application, address, false) }
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val title: StateFlow<String> = recipient
|
||||
.map { it.toShortString() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
val mediaListState: StateFlow<MediaOverviewContent?> = recipient
|
||||
.map { recipient ->
|
||||
withContext(Dispatchers.Default) {
|
||||
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
|
||||
val mediaItems = mediaDatabase.getGalleryMediaForThread(threadId)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { MediaRecord.from(application, it) }
|
||||
.groupRecordsByTimeBuckets()
|
||||
}
|
||||
|
||||
val documentItems = mediaDatabase.getDocumentMediaForThread(threadId)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { MediaRecord.from(application, it) }
|
||||
.groupRecordsByRelativeTime()
|
||||
}
|
||||
|
||||
MediaOverviewContent(
|
||||
mediaContent = mediaItems,
|
||||
documentContent = documentItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val mutableSelectedItemIDs = MutableStateFlow(emptySet<Long>())
|
||||
val selectedItemIDs: StateFlow<Set<Long>> get() = mutableSelectedItemIDs
|
||||
|
||||
val inSelectionMode: StateFlow<Boolean> = selectedItemIDs
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, mutableSelectedItemIDs.value.isNotEmpty())
|
||||
|
||||
val canLongPress: StateFlow<Boolean> = inSelectionMode
|
||||
.map { !it }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
private val mutableEvents = MutableSharedFlow<MediaOverviewEvent>()
|
||||
val events get() = mutableEvents
|
||||
|
||||
private val mutableSelectedTab = MutableStateFlow(MediaOverviewTab.Media)
|
||||
val selectedTab: StateFlow<MediaOverviewTab> get() = mutableSelectedTab
|
||||
|
||||
private val mutableShowingActionProgress = MutableStateFlow<String?>(null)
|
||||
val showingActionProgress: StateFlow<String?> get() = mutableShowingActionProgress
|
||||
|
||||
private val selectedMedia: Sequence<MediaOverviewItem>
|
||||
get() {
|
||||
val selected = selectedItemIDs.value
|
||||
return mediaListState.value
|
||||
?.mediaContent
|
||||
?.asSequence()
|
||||
.orEmpty()
|
||||
.flatMap { it.second.asSequence() }
|
||||
.filter { it.id in selected }
|
||||
}
|
||||
|
||||
private fun Sequence<MediaRecord>.groupRecordsByTimeBuckets(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
|
||||
return this
|
||||
.groupBy { record ->
|
||||
val time =
|
||||
ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC"))
|
||||
timeBuckets.getBucketText(time)?.let(application::getString)
|
||||
?: time.toLocalDate().withDayOfMonth(1)
|
||||
}
|
||||
.map { (bucket, records) ->
|
||||
val bucketTitle = when (bucket) {
|
||||
is String -> bucket
|
||||
is LocalDate -> bucket.format(monthTimeBucketFormatter)
|
||||
else -> error("Invalid bucket type: $bucket")
|
||||
}
|
||||
|
||||
bucketTitle to records.map { record ->
|
||||
MediaOverviewItem(
|
||||
id = record.attachment.attachmentId.rowId,
|
||||
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
|
||||
mediaRecord = record,
|
||||
date = bucketTitle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Sequence<MediaRecord>.groupRecordsByRelativeTime(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
|
||||
return this
|
||||
.groupBy { record ->
|
||||
DateUtils.getRelativeDate(application, Locale.getDefault(), record.date)
|
||||
}
|
||||
.map { (bucket, records) ->
|
||||
bucket to records.map { record ->
|
||||
MediaOverviewItem(
|
||||
id = record.attachment.attachmentId.rowId,
|
||||
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
|
||||
mediaRecord = record,
|
||||
date = bucket
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onItemClicked(item: MediaOverviewItem) {
|
||||
if (inSelectionMode.value) {
|
||||
val newSet = mutableSelectedItemIDs.value.toMutableSet()
|
||||
if (item.id in newSet) {
|
||||
newSet.remove(item.id)
|
||||
} else {
|
||||
newSet.add(item.id)
|
||||
}
|
||||
|
||||
mutableSelectedItemIDs.value = newSet
|
||||
} else if (!item.slide.hasDocument()) {
|
||||
val mediaRecord = item.mediaRecord
|
||||
|
||||
// The item clicked is a media item, so we should open the media viewer
|
||||
val intent = Intent(application, MediaPreviewActivity::class.java)
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.date)
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.attachment.size)
|
||||
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, address)
|
||||
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing)
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true)
|
||||
|
||||
intent.setDataAndType(
|
||||
mediaRecord.attachment.dataUri,
|
||||
mediaRecord.contentType
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
|
||||
}
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.setDataAndType(
|
||||
PartAuthority.getAttachmentPublicUri(item.slide.uri),
|
||||
item.slide.contentType
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onTabItemClicked(tab: MediaOverviewTab) {
|
||||
if (inSelectionMode.value) {
|
||||
// Not allowing to switch tabs while in selection mode
|
||||
return
|
||||
}
|
||||
|
||||
mutableSelectedTab.value = tab
|
||||
}
|
||||
|
||||
fun onItemLongClicked(id: Long) {
|
||||
mutableSelectedItemIDs.value = setOf(id)
|
||||
}
|
||||
|
||||
fun onSaveClicked() {
|
||||
if (!inSelectionMode.value) {
|
||||
// Not in selection mode, so we should not be able to save
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val selectedMedia = selectedMedia.toList()
|
||||
|
||||
mutableShowingActionProgress.value = application.resources.getString(R.string.saving)
|
||||
|
||||
val attachments = selectedMedia
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
val uri = it.mediaRecord.attachment.dataUri ?: return@mapNotNull null
|
||||
SaveAttachmentTask.Attachment(
|
||||
uri = uri,
|
||||
contentType = it.mediaRecord.contentType,
|
||||
date = it.mediaRecord.date,
|
||||
fileName = it.mediaRecord.attachment.fileName,
|
||||
)
|
||||
}
|
||||
|
||||
var savedDirectory: String? = null
|
||||
var successCount = 0
|
||||
var errorCount = 0
|
||||
|
||||
for (attachment in attachments) {
|
||||
val directory = withContext(Dispatchers.Default) {
|
||||
kotlin.runCatching {
|
||||
SaveAttachmentTask.saveAttachment(application, attachment)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
if (directory == null) {
|
||||
errorCount += 1
|
||||
} else {
|
||||
savedDirectory = directory
|
||||
successCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentSuccess(
|
||||
savedDirectory.orEmpty(),
|
||||
successCount
|
||||
))
|
||||
} else if (errorCount > 0) {
|
||||
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentError(errorCount))
|
||||
}
|
||||
|
||||
// Send a notification of attachment saved if we are in a 1to1 chat and the
|
||||
// attachments saved are from the other party (a.k.a let other person know
|
||||
// that you saved their attachments, but don't need to let the whole world know as
|
||||
// in groups/communities)
|
||||
if (selectedMedia.any { !it.mediaRecord.isOutgoing } &&
|
||||
successCount > 0 &&
|
||||
!address.isGroup) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val timestamp = SnodeAPI.nowWithOffset
|
||||
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
|
||||
val message = DataExtractionNotification(kind)
|
||||
MessageSender.send(message, address)
|
||||
}
|
||||
}
|
||||
|
||||
mutableShowingActionProgress.value = null
|
||||
mutableSelectedItemIDs.value = emptySet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun onDeleteClicked() {
|
||||
if (!inSelectionMode.value) {
|
||||
// Not in selection mode, so we should not be able to delete
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
mutableShowingActionProgress.value = application.getString(R.string.deleting)
|
||||
|
||||
// Delete the selected media items, and retrieve the thread ID for the address if any
|
||||
val threadId = withContext(Dispatchers.Default) {
|
||||
for (media in selectedMedia) {
|
||||
kotlin.runCatching {
|
||||
AttachmentUtil.deleteAttachment(application, media.mediaRecord.attachment)
|
||||
}
|
||||
}
|
||||
|
||||
threadDatabase.getThreadIdIfExistsFor(address.serialize())
|
||||
}
|
||||
|
||||
// Notify the content provider that the thread has been updated
|
||||
if (threadId >= 0) {
|
||||
application.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null)
|
||||
}
|
||||
|
||||
mutableShowingActionProgress.value = null
|
||||
mutableSelectedItemIDs.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectAllClicked() {
|
||||
if (!inSelectionMode.value) {
|
||||
// Not in selection mode, so we should not be able to select all
|
||||
return
|
||||
}
|
||||
|
||||
val allItems = mediaListState.value?.let { content ->
|
||||
when (selectedTab.value) {
|
||||
MediaOverviewTab.Media -> content.mediaContent
|
||||
MediaOverviewTab.Documents -> content.documentContent
|
||||
}
|
||||
} ?: return
|
||||
|
||||
mutableSelectedItemIDs.value = allItems
|
||||
.asSequence()
|
||||
.flatMap { it.second }
|
||||
.mapTo(hashSetOf()) { it.id }
|
||||
}
|
||||
|
||||
fun onBackClicked() {
|
||||
if (inSelectionMode.value) {
|
||||
// Clear selection mode by clear selecting items
|
||||
mutableSelectedItemIDs.value = emptySet()
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
mutableEvents.emit(MediaOverviewEvent.Close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(address: Address): Factory
|
||||
}
|
||||
|
||||
class Factory @AssistedInject constructor(
|
||||
@Assisted private val address: Address,
|
||||
private val application: Application,
|
||||
private val threadDatabase: ThreadDatabase,
|
||||
private val mediaDatabase: MediaDatabase
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = MediaOverviewViewModel(
|
||||
address,
|
||||
application,
|
||||
threadDatabase,
|
||||
mediaDatabase
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum class MediaOverviewTab {
|
||||
Media,
|
||||
Documents,
|
||||
}
|
||||
|
||||
sealed interface MediaOverviewEvent {
|
||||
data object Close : MediaOverviewEvent
|
||||
data class ShowSaveAttachmentError(val errorCount: Int) : MediaOverviewEvent
|
||||
data class ShowSaveAttachmentSuccess(val directory: String, val successCount: Int) : MediaOverviewEvent
|
||||
data class NavigateToActivity(val intent: Intent) : MediaOverviewEvent
|
||||
}
|
||||
|
||||
typealias BucketTitle = String
|
||||
typealias TabContent = List<Pair<BucketTitle, List<MediaOverviewItem>>>
|
||||
|
||||
data class MediaOverviewContent(
|
||||
val mediaContent: TabContent,
|
||||
val documentContent: TabContent
|
||||
)
|
||||
|
||||
data class MediaOverviewItem(
|
||||
val id: Long,
|
||||
val slide: Slide,
|
||||
val date: String,
|
||||
val mediaRecord: MediaRecord,
|
||||
) {
|
||||
val showPlayOverlay: Boolean
|
||||
get() = slide.hasPlayOverlay()
|
||||
|
||||
val thumbnailUri: Uri?
|
||||
get() = slide.thumbnailUri
|
||||
|
||||
val hasPlaceholder: Boolean
|
||||
get() = slide.hasPlaceholder()
|
||||
|
||||
val fileName: String?
|
||||
get() = slide.fileName.orNull()
|
||||
|
||||
val fileSize: Long
|
||||
get() = slide.fileSize
|
||||
|
||||
fun placeholder(context: Context): Int {
|
||||
return slide.getPlaceholderRes(context.theme)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,207 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
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.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bumptech.glide.integration.compose.CrossFade
|
||||
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||
import com.bumptech.glide.integration.compose.GlideImage
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
import kotlin.math.ceil
|
||||
|
||||
private val MEDIA_SPACING = 2.dp
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MediaPage(
|
||||
nestedScrollConnection: NestedScrollConnection,
|
||||
content: TabContent?,
|
||||
selectedItemIDs: Set<Long>,
|
||||
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||
onItemLongClicked: ((Long) -> Unit)?,
|
||||
) {
|
||||
val columnCount = LocalContext.current.resources.getInteger(R.integer.media_overview_cols)
|
||||
|
||||
Crossfade(content, label = "Media content animation") { state ->
|
||||
when {
|
||||
state == null -> {
|
||||
// Loading state
|
||||
}
|
||||
|
||||
state.isEmpty() -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.attachmentsMediaEmpty),
|
||||
style = LocalType.current.base,
|
||||
color = LocalColors.current.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.fillMaxSize()
|
||||
.padding(MEDIA_SPACING),
|
||||
verticalArrangement = Arrangement.spacedBy(MEDIA_SPACING)
|
||||
) {
|
||||
for ((header, thumbnails) in state) {
|
||||
stickyHeader {
|
||||
AttachmentHeader(text = header)
|
||||
}
|
||||
|
||||
val numRows = ceil(thumbnails.size / columnCount.toFloat()).toInt()
|
||||
|
||||
// Row of thumbnails
|
||||
items(numRows) { rowIndex ->
|
||||
ThumbnailRow(
|
||||
columnCount = columnCount,
|
||||
thumbnails = thumbnails,
|
||||
rowIndex = rowIndex,
|
||||
onItemClicked = onItemClicked,
|
||||
onItemLongClicked = onItemLongClicked,
|
||||
selectedItemIDs = selectedItemIDs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class)
|
||||
private fun ThumbnailRow(
|
||||
columnCount: Int,
|
||||
thumbnails: List<MediaOverviewItem>,
|
||||
rowIndex: Int,
|
||||
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||
onItemLongClicked: ((Long) -> Unit)?,
|
||||
selectedItemIDs: Set<Long>
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(MEDIA_SPACING)) {
|
||||
repeat(columnCount) { columnIndex ->
|
||||
val item = thumbnails.getOrNull(rowIndex * columnCount + columnIndex)
|
||||
if (item != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.let {
|
||||
when {
|
||||
onItemLongClicked != null -> {
|
||||
it.combinedClickable(
|
||||
onClick = { onItemClicked(item) },
|
||||
onLongClick = { onItemLongClicked(item.id) }
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
it.clickable { onItemClicked(item) }
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val uri = item.thumbnailUri
|
||||
|
||||
if (uri != null) {
|
||||
GlideImage(
|
||||
DecryptableStreamUriLoader.DecryptableUri(uri),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
transition = CrossFade,
|
||||
) {
|
||||
it.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
}
|
||||
} else if (item.hasPlaceholder) {
|
||||
Image(
|
||||
painter = painterResource(item.placeholder(LocalContext.current)),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Inside
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
item.showPlayOverlay -> {
|
||||
// The code below is translated from thumbnail_view.xml:
|
||||
// Trying to show a green play button on a white background.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.White, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.padding(start = LocalDimensions.current.xxxsSpacing),
|
||||
painter = painterResource(R.drawable.triangle_right),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(LocalColors.current.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Crossfade(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
targetState = item.id in selectedItemIDs,
|
||||
label = "Showing selected state"
|
||||
) { selected ->
|
||||
if (selected) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
contentScale = ContentScale.Inside,
|
||||
painter = painterResource(R.drawable.ic_check_white_48dp),
|
||||
contentDescription = stringResource(R.string.AccessibilityId_select),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,53 +1,176 @@
|
||||
package org.thoughtcrime.securesms.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.Divider
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
||||
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppBarPreview(
|
||||
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
|
||||
) {
|
||||
PreviewTheme(colors) {
|
||||
AppBar(title = "Title", {}, {})
|
||||
Column() {
|
||||
BasicAppBar(title = "Basic App Bar")
|
||||
Divider()
|
||||
BasicAppBar(title = "Basic App Bar With Color", backgroundColor = LocalColors.current.backgroundSecondary)
|
||||
Divider()
|
||||
BackAppBar(title = "Back Bar", onBack = {})
|
||||
Divider()
|
||||
ActionAppBar(
|
||||
title = "Action mode",
|
||||
actionMode = true,
|
||||
actionModeActions = {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.check),
|
||||
contentDescription = "check"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic structure for an app bar.
|
||||
* It can be passed navigation content and actions
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BasicAppBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
backgroundColor: Color = LocalColors.current.background,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
){
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
AppBarText(title = title)
|
||||
},
|
||||
colors = appBarColors(backgroundColor),
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Common use case of an app bar with a back button
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) {
|
||||
Row(modifier = Modifier.height(LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
|
||||
onBack?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back")
|
||||
}
|
||||
fun BackAppBar(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
backgroundColor: Color = LocalColors.current.background,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
){
|
||||
BasicAppBar(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
AppBarBackIcon(onBack = onBack)
|
||||
},
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
backgroundColor = backgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun ActionAppBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
backgroundColor: Color = LocalColors.current.background,
|
||||
actionMode: Boolean = false,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
actionModeActions: @Composable (RowScope.() -> Unit) = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (!actionMode) {
|
||||
AppBarText(title = title)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = title, style = LocalType.current.h4)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close")
|
||||
},
|
||||
navigationIcon =navigationIcon,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = appBarColors(backgroundColor),
|
||||
actions = {
|
||||
if (actionMode) {
|
||||
actionModeActions()
|
||||
} else {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarText(title: String) {
|
||||
Text(text = title, style = LocalType.current.h4)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarBackIcon(onBack: () -> Unit) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24),
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarCloseIcon(onClose: () -> Unit) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_x),
|
||||
contentDescription = stringResource(id = R.string.close)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun appBarColors(backgroundColor: Color) = TopAppBarDefaults.centerAlignedTopAppBarColors()
|
||||
.copy(
|
||||
containerColor = backgroundColor,
|
||||
scrolledContainerColor = backgroundColor,
|
||||
navigationIconContentColor = LocalColors.current.text,
|
||||
titleContentColor = LocalColors.current.text,
|
||||
actionIconContentColor = LocalColors.current.text
|
||||
)
|
||||
|
@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent"
|
||||
tools:context="org.thoughtcrime.securesms.MediaOverviewActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
style="@style/Widget.Session.AppBarLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stateListAnimator="@animator/appbar_elevation">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/search_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:attr/actionBarSize"
|
||||
app:layout_scrollFlags="scroll|enterAlways"/>
|
||||
|
||||
<org.thoughtcrime.securesms.components.ControllableTabLayout
|
||||
android:id="@+id/tab_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"/>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.components.ControllableViewPager
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.DocumentView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/document_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="1"
|
||||
android:visibility="visible"
|
||||
app:doc_titleColor="?android:textColorPrimary"
|
||||
app:doc_captionColor="?android:textColorTertiary"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<TextView android:id="@+id/date"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:paddingTop="20dp"
|
||||
tools:text="Jun 1"/>
|
||||
|
||||
</LinearLayout>
|
@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/media_overview_toolbar_background"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:textColor="?attr/media_overview_header_foreground"
|
||||
android:textSize="@dimen/small_font_size"
|
||||
tools:text="March 1, 2015" />
|
||||
</FrameLayout>
|
@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
tools:listitem="@layout/media_overview_document_item" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/no_documents"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/attachmentsFilesEmpty"
|
||||
android:textSize="@dimen/medium_font_size"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</RelativeLayout>
|
@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/media_grid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
<TextView android:id="@+id/no_images"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:textSize="@dimen/medium_font_size"
|
||||
android:gravity="center"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:visibility="gone"
|
||||
android:text="@string/attachmentsFilesEmpty" />
|
||||
|
||||
</RelativeLayout>
|
@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.SquareFrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="2dp">
|
||||
|
||||
<include layout="@layout/thumbnail_view"
|
||||
android:id="@+id/image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/selected_indicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/MediaOverview_Media_selected_overlay"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/check"
|
||||
android:layout_gravity="center"/>
|
||||
</FrameLayout>
|
||||
|
||||
</org.thoughtcrime.securesms.components.SquareFrameLayout>
|
@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?colorPrimary"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="@dimen/small_font_size"
|
||||
tools:text="March 1, 2015" />
|
||||
|
||||
</FrameLayout>
|
@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/medium_spacing"
|
||||
android:paddingEnd="@dimen/medium_spacing"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/mention_candidate_view_background">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="32dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
android:id="@+id/profilePictureView"
|
||||
android:layout_width="@dimen/very_small_profile_picture_size"
|
||||
android:layout_height="@dimen/very_small_profile_picture_size"
|
||||
android:layout_marginTop="3dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/moderatorIconImageView"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/ic_crown"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mentionCandidateNameTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/medium_spacing"
|
||||
android:textSize="@dimen/small_font_size"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
</org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView>
|
@ -1,6 +1,6 @@
|
||||
#Thu Dec 30 07:09:53 SAST 2021
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="network.loki.messenger.libsession_util">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
@ -1,2 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="org.session.libsession" />
|
||||
<manifest />
|
@ -1,2 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="org.session.libsignal" />
|
||||
<manifest />
|
@ -1,2 +0,0 @@
|
||||
configurations.maybeCreate("default")
|
||||
artifacts.add("default", file('stickyheadergrid-0.9.4.aar'))
|
Binary file not shown.
Loading…
Reference in New Issue