SES-2524 - Rewrite media gallery in Compose (#1619)

This commit is contained in:
Fanchao Liu 2024-08-19 13:46:38 +10:00 committed by GitHub
parent f379604c54
commit 16d6efbb5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1608 additions and 1230 deletions

View File

@ -1,18 +1,3 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$gradlePluginVersion"
classpath files('libs/gradle-witness.jar')
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion"
classpath "com.google.gms:google-services:$googleServicesVersion"
}
}
plugins {
id 'com.google.devtools.ksp'
id 'com.google.dagger.hilt.android'
@ -24,8 +9,8 @@ apply plugin: 'witness'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization'
configurations.all {
exclude module: "commons-logging"
configurations.forEach {
it.exclude module: "commons-logging"
}
def canonicalVersionCode = 379
@ -64,13 +49,9 @@ android {
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
resources {
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro']
}
}
splits {
@ -85,6 +66,7 @@ android {
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.14'
}
@ -108,8 +90,8 @@ android {
buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
resourceConfigurations += []
resConfigs autoResConfig()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// The following argument makes the Android Test Orchestrator run its
// "pm clear" command after each test invocation. This command ensures
@ -169,7 +151,7 @@ android {
}
}
applicationVariants.all { variant ->
applicationVariants.forEach { variant ->
variant.outputs.each { output ->
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
@ -180,10 +162,6 @@ android {
}
}
lintOptions {
abortOnError true
baseline file("lint-baseline.xml")
}
testOptions {
unitTests {
@ -192,7 +170,6 @@ android {
}
buildFeatures {
dataBinding true
viewBinding true
}
@ -212,9 +189,11 @@ android {
}
}
task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") {
tasks.register('testPlayDebugUnitTestCoverageReport', JacocoReport) {
dependsOn 'testPlayDebugUnitTest'
reports {
xml.enabled = true
xml.required = true
}
// Add files that should not be listed in the report (e.g. generated Files from dagger)
@ -232,6 +211,13 @@ android {
// This is enabled with 'enableUnitTestCoverage' in the 'debug' build type.
executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec"
}
testNamespace 'network.loki.messenger.test'
lint {
abortOnError true
baseline file('lint-baseline.xml')
}
}
dependencies {
@ -258,6 +244,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.activity:activity-compose:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
@ -279,6 +266,7 @@ dependencies {
implementation 'commons-net:commons-net:3.7.2'
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:compose:1.0.0-beta01"
ksp "com.github.bumptech.glide:ksp:$glideVersion"
implementation 'com.makeramen:roundedimageview:2.1.0'
implementation 'com.pnikosis:materialish-progress:1.5'
@ -299,7 +287,6 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation 'com.annimon:stream:1.1.8'
implementation project(':stickyheader')
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'

View File

@ -1,6 +1,5 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="network.loki.messenger.test">
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<uses-library android:name="android.test.runner"
android:required="false" />

View File

@ -306,6 +306,8 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<activity android:name="org.thoughtcrime.securesms.media.MediaOverviewActivity" />
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
android:foregroundServiceType="microphone"
android:exported="false" />

View File

@ -1,132 +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);
viewHolder.date.setText(DateUtils.getRelativeDate(getContext(), locale, mediaRecord.getDate()));
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.ConversationItem_unable_to_open_media, 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.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);
}
}
}

View File

@ -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);
}
}

View File

@ -1,509 +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.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.content.res.Resources;
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.AlertDialog;
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.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
import com.google.android.material.tabs.TabLayout;
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.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
import com.bumptech.glide.Glide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
/**
* 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() {
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
this.viewPager = ViewUtil.findById(this, R.id.pager);
this.toolbar = ViewUtil.findById(this, R.id.toolbar);
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
this.recipient = Recipient.from(this, address, true);
}
private void initializeToolbar() {
setSupportActionBar(this.toolbar);
ActionBar actionBar = getSupportActionBar();
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.MediaOverviewActivity_Media);
else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
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(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> {
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
context,
R.string.MediaOverviewActivity_collecting_attachments,
R.string.please_wait) {
@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.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message) {
@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();
}
}
}

View File

@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.components.MediaView;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.media.MediaOverviewActivity;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mms.Slide;
@ -390,9 +391,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
private void showOverview() {
Intent intent = new Intent(this, MediaOverviewActivity.class);
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, conversationRecipient.getAddress());
startActivity(intent);
startActivity(MediaOverviewActivity.createIntent(this, conversationRecipient.getAddress()));
}
private void forward() {

View File

@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
@ -22,16 +24,18 @@ import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelega
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BasicAppBar
import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
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
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun StartConversationScreen(
accountId: String,
@ -41,7 +45,11 @@ internal fun StartConversationScreen(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.dialog_start_conversation_title), onClose = delegate::onDialogClosePressed)
BasicAppBar(
title = stringResource(R.string.dialog_start_conversation_title),
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
actions = { AppBarCloseIcon(onClose = delegate::onDialogClosePressed) }
)
Surface(
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
color = LocalColors.current.backgroundSecondary

View File

@ -8,24 +8,28 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
import org.thoughtcrime.securesms.ui.components.border
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InviteFriend(
accountId: String,
@ -38,7 +42,12 @@ internal fun InviteFriend(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.invite_a_friend), onBack = onBack, onClose = onClose)
BackAppBar(
title = stringResource(R.string.invite_a_friend),
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
onBack = onBack,
actions = { AppBarCloseIcon(onClose = onClose) }
)
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)
.padding(top = LocalDimensions.current.spacing),

View File

@ -17,6 +17,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
@ -43,7 +45,8 @@ import kotlinx.coroutines.flow.emptyFlow
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
@ -60,7 +63,7 @@ import kotlin.math.max
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun NewMessage(
state: State,
@ -76,7 +79,12 @@ internal fun NewMessage(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
BackAppBar(
title = stringResource(R.string.messageNew),
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
onBack = onBack,
actions = { AppBarCloseIcon(onClose = onClose) }
)
SessionTabRow(pagerState, TITLES)
HorizontalPager(pagerState) {
when (TITLES[it]) {

View File

@ -24,7 +24,7 @@ import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MediaOverviewActivity
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
@ -149,10 +149,8 @@ object ConversationMenuHelper {
}
private fun showAllMedia(context: Context, thread: Recipient) {
val intent = Intent(context, MediaOverviewActivity::class.java)
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, thread.address)
val activity = context as AppCompatActivity
activity.startActivity(intent)
activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address))
}
private fun search(context: Context) {

View File

@ -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
)
}

View File

@ -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.media_overview_documents_fragment__no_documents_found),
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,
)
}
}
}
}
}
}
}
}
}

View File

@ -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
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday
time >= startOfThisWeek -> R.string.BucketedThreadMedia_This_week
time >= startOfThisMonth -> R.string.BucketedThreadMedia_This_month
else -> null
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -0,0 +1,313 @@
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.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio,
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.ConversationItem_unable_to_open_media,
Toast.LENGTH_LONG
).show()
}
}
is MediaOverviewEvent.ShowSaveAttachmentError -> {
val message = context.resources.getQuantityText(
R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
event.errorCount
)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
is MediaOverviewEvent.ShowSaveAttachmentSuccess -> {
val message = if (event.directory.isNotBlank()) {
context.resources.getString(
R.string.SaveAttachmentTask_saved_to,
event.directory
)
} else {
context.resources.getString(R.string.SaveAttachmentTask_saved)
}
Toast.makeText(context, message, 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.ConversationFragment_save_to_sd_card),
text = context.resources.getQuantityString(
R.plurals.ConversationFragment_saving_n_media_to_storage_warning,
numSelected,
numSelected
),
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.MediaOverviewActivity_Media
MediaOverviewTab.Documents -> R.string.MediaOverviewActivity_Documents
}

View File

@ -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.MediaOverviewActivity_Select_all),
tint = LocalColors.current.text,
)
}
}
)
}

View File

@ -0,0 +1,431 @@
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.getQuantityString(
R.plurals.ConversationFragment_saving_n_attachments,
selectedMedia.size,
selectedMedia.size,
)
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.MediaOverviewActivity_Media_delete_progress_message)
// 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)
}
}

View File

@ -0,0 +1,191 @@
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.lazy.LazyColumn
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.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.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.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.media_overview_activity__no_media),
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 -> {
Image(
painter = painterResource(R.drawable.ic_baseline_play_circle_filled_48),
contentDescription = null
)
}
}
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))
}
}
}
}

View File

@ -81,9 +81,8 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.takeIf { IntentUtils.isResolvable(requireContext(), it) }.let {
startActivity(it)
}
.takeIf { IntentUtils.isResolvable(requireContext(), it) }
?.let { startActivity(it) }
}
button(R.string.dismiss)
}

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@ -26,6 +27,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
@ -56,11 +58,17 @@ fun AlertDialog(
onDismissRequest = onDismissRequest,
content = {
Box(
modifier = Modifier.background(color = LocalColors.current.backgroundSecondary,
modifier = Modifier.background(
color = LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small)
.border(
width = 1.dp,
color = LocalColors.current.borders,
shape = MaterialTheme.shapes.small)
) {
// only show the 'x' button is required
if(showCloseButton) {
if (showCloseButton) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.align(Alignment.TopEnd)
@ -78,7 +86,7 @@ fun AlertDialog(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = LocalDimensions.current.smallSpacing)
.padding(top = LocalDimensions.current.spacing)
.padding(horizontal = LocalDimensions.current.smallSpacing)
) {
title?.let {
@ -123,7 +131,12 @@ fun AlertDialog(
}
@Composable
fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecified, onClick: () -> Unit) {
fun DialogButton(
text: String,
modifier: Modifier,
color: Color = Color.Unspecified,
onClick: () -> Unit
) {
TextButton(
modifier = modifier,
shape = RectangleShape,
@ -135,8 +148,7 @@ fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecif
style = LocalType.current.large.bold(),
textAlign = TextAlign.Center,
modifier = Modifier.padding(
top = LocalDimensions.current.smallSpacing,
bottom = LocalDimensions.current.spacing
vertical = LocalDimensions.current.smallSpacing
)
)
}
@ -144,7 +156,7 @@ fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecif
@Preview
@Composable
fun PreviewSimpleDialog(){
fun PreviewSimpleDialog() {
PreviewTheme {
AlertDialog(
onDismissRequest = {},
@ -166,7 +178,7 @@ fun PreviewSimpleDialog(){
@Preview
@Composable
fun PreviewXCloseDialog(){
fun PreviewXCloseDialog() {
PreviewTheme {
AlertDialog(
title = stringResource(R.string.urlOpen),

View File

@ -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 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)
}
},
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 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")
}
}
}
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")
}
}
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
)

View File

@ -57,15 +57,139 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
button(R.string.no)
}
}
fun saveAttachment(context: Context, attachment: Attachment): String? {
val contentType = checkNotNull(MediaUtil.getCorrectedMimeType(attachment.contentType))
var fileName = attachment.fileName
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date)
fileName = sanitizeOutputFileName(fileName)
val outputUri: Uri = getMediaStoreContentUriForType(contentType)
val mediaUri = createOutputUri(context, outputUri, contentType, fileName)
val updateValues = ContentValues()
PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream ->
if (inputStream == null) {
return null
}
if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
FileOutputStream(mediaUri!!.path).use { outputStream ->
StreamUtil.copy(inputStream, outputStream)
MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf(contentType), null)
}
} else {
context.contentResolver.openOutputStream(mediaUri!!, "w").use { outputStream ->
val total: Long = StreamUtil.copy(inputStream, outputStream)
if (total > 0) {
updateValues.put(MediaStore.MediaColumns.SIZE, total)
}
}
}
}
if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
}
if (updateValues.size() > 0) {
context.contentResolver.update(mediaUri!!, updateValues, null, null)
}
return outputUri.lastPathSegment
}
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
val base = "session-${dateFormatter.format(timestamp)}"
return "${base}.${extension}";
}
private fun sanitizeOutputFileName(fileName: String): String {
return File(fileName).name
}
private fun getMediaStoreContentUriForType(contentType: String): Uri {
return when {
contentType.startsWith("video/") ->
ExternalStorageUtil.getVideoUri()
contentType.startsWith("audio/") ->
ExternalStorageUtil.getAudioUri()
contentType.startsWith("image/") ->
ExternalStorageUtil.getImageUri()
else ->
ExternalStorageUtil.getDownloadUri()
}
}
private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, fileName: String): Uri? {
val fileParts: Array<String> = getFileNameParts(fileName)
val base = fileParts[0]
val extension = fileParts[1]
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
if (Build.VERSION.SDK_INT > 28) {
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
} else if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
val outputDirectory = File(outputUri.path)
var outputFile = File(outputDirectory, "$base.$extension")
var i = 0
while (outputFile.exists()) {
outputFile = File(outputDirectory, base + "-" + ++i + "." + extension)
}
if (outputFile.isHidden) {
throw IOException("Specified name would not be visible")
}
return Uri.fromFile(outputFile)
} else {
var outputFileName = fileName
var dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
var i = 0
while (pathTaken(context, outputUri, dataPath)) {
Log.d(TAG, "The content exists. Rename and check again.")
outputFileName = base + "-" + ++i + "." + extension
dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
}
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
}
return context.contentResolver.insert(outputUri, contentValues)
}
private fun getExternalPathToFileForType(context: Context, contentType: String): String {
val storage: File = when {
contentType.startsWith("video/") ->
context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)!!
contentType.startsWith("audio/") ->
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!
contentType.startsWith("image/") ->
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
else ->
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!!
}
return storage.absolutePath
}
private fun getFileNameParts(fileName: String): Array<String> {
val tokens = fileName.split("\\.(?=[^\\.]+$)".toRegex()).toTypedArray()
return arrayOf(tokens[0], if (tokens.size > 1) tokens[1] else "")
}
private fun pathTaken(context: Context, outputUri: Uri, dataPath: String): Boolean {
context.contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA),
MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath),
null).use { cursor ->
if (cursor == null) {
throw IOException("Something is wrong with the filename to save")
}
return cursor.moveToFirst()
}
}
}
private val contextReference: WeakReference<Context>
private val contextReference = WeakReference(context)
private val attachmentCount: Int = count
init {
this.contextReference = WeakReference(context)
}
@Deprecated("Deprecated in Java")
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
if (attachments.isEmpty()) {
@ -97,137 +221,6 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
}
}
@Throws(IOException::class)
private fun saveAttachment(context: Context, attachment: Attachment): String? {
val contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType))!!
var fileName = attachment.fileName
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date)
fileName = sanitizeOutputFileName(fileName)
val outputUri: Uri = getMediaStoreContentUriForType(contentType)
val mediaUri = createOutputUri(outputUri, contentType, fileName)
val updateValues = ContentValues()
PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream ->
if (inputStream == null) {
return null
}
if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
FileOutputStream(mediaUri!!.path).use { outputStream ->
StreamUtil.copy(inputStream, outputStream)
MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf(contentType), null)
}
} else {
context.contentResolver.openOutputStream(mediaUri!!, "w").use { outputStream ->
val total: Long = StreamUtil.copy(inputStream, outputStream)
if (total > 0) {
updateValues.put(MediaStore.MediaColumns.SIZE, total)
}
}
}
}
if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
}
if (updateValues.size() > 0) {
getContext().contentResolver.update(mediaUri!!, updateValues, null, null)
}
return outputUri.lastPathSegment
}
private fun getMediaStoreContentUriForType(contentType: String): Uri {
return when {
contentType.startsWith("video/") ->
ExternalStorageUtil.getVideoUri()
contentType.startsWith("audio/") ->
ExternalStorageUtil.getAudioUri()
contentType.startsWith("image/") ->
ExternalStorageUtil.getImageUri()
else ->
ExternalStorageUtil.getDownloadUri()
}
}
@Throws(IOException::class)
private fun createOutputUri(outputUri: Uri, contentType: String, fileName: String): Uri? {
val fileParts: Array<String> = getFileNameParts(fileName)
val base = fileParts[0]
val extension = fileParts[1]
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
if (Build.VERSION.SDK_INT > 28) {
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
} else if (Objects.equals(outputUri.scheme, ContentResolver.SCHEME_FILE)) {
val outputDirectory = File(outputUri.path)
var outputFile = File(outputDirectory, "$base.$extension")
var i = 0
while (outputFile.exists()) {
outputFile = File(outputDirectory, base + "-" + ++i + "." + extension)
}
if (outputFile.isHidden) {
throw IOException("Specified name would not be visible")
}
return Uri.fromFile(outputFile)
} else {
var outputFileName = fileName
var dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName)
var i = 0
while (pathTaken(outputUri, dataPath)) {
Log.d(TAG, "The content exists. Rename and check again.")
outputFileName = base + "-" + ++i + "." + extension
dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName)
}
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
}
return context.contentResolver.insert(outputUri, contentValues)
}
private fun getFileNameParts(fileName: String): Array<String> {
val tokens = fileName.split("\\.(?=[^\\.]+$)".toRegex()).toTypedArray()
return arrayOf(tokens[0], if (tokens.size > 1) tokens[1] else "")
}
private fun getExternalPathToFileForType(contentType: String): String {
val storage: File = when {
contentType.startsWith("video/") ->
context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)!!
contentType.startsWith("audio/") ->
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!
contentType.startsWith("image/") ->
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
else ->
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!!
}
return storage.absolutePath
}
@Throws(IOException::class)
private fun pathTaken(outputUri: Uri, dataPath: String): Boolean {
context.contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA),
MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath),
null).use { cursor ->
if (cursor == null) {
throw IOException("Something is wrong with the filename to save")
}
return cursor.moveToFirst()
}
}
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
val base = "session-${dateFormatter.format(timestamp)}"
return "${base}.${extension}";
}
private fun sanitizeOutputFileName(fileName: String): String {
return File(fileName).name
}
@Deprecated("Deprecated in Java")
override fun onPostExecute(result: Pair<Int, String?>) {
super.onPostExecute(result)
@ -255,4 +248,5 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
}
data class Attachment(val uri: Uri, val contentType: String, val date: Long, val fileName: String?)
}

View File

@ -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/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>

View File

@ -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>

View File

@ -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>

View File

@ -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/media_overview_documents_fragment__no_documents_found"
android:textSize="@dimen/medium_font_size"
android:visibility="gone"
tools:visibility="visible" />
</RelativeLayout>

View File

@ -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/media_overview_activity__no_media" />
</RelativeLayout>

View File

@ -1,28 +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"
android:contentDescription="@string/media_preview_activity__media_content_description" />
<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>

View File

@ -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>

View File

@ -1139,4 +1139,5 @@
<string name="you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit">You cannot go back further. In order to stop loading your account, Session needs to quit.</string>
<string name="you_cannot_go_back_further_cancel_account_creation">You cannot go back further. In order to cancel your account creation, Session needs to quit.</string>
<string name="quit">Quit</string>
<string name="back">Back</string>
</resources>

View File

@ -15,7 +15,7 @@ android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
gradlePluginVersion=7.3.1
gradlePluginVersion=8.5.2
googleServicesVersion=4.3.12
kotlinVersion=1.9.24
android.useAndroidX=true
@ -40,3 +40,6 @@ phraseVersion=1.2.0
preferenceVersion=1.2.0
protobufVersion=2.5.0
testCoreVersion=1.5.0
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@ -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

View File

@ -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>

View File

@ -17,6 +17,12 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
namespace 'org.session.libsession'
}
dependencies {

View File

@ -1,2 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="org.session.libsession" />
<manifest />

View File

@ -12,6 +12,11 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
namespace 'org.session.libsignal'
}
dependencies {

View File

@ -1,2 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="org.session.libsignal" />
<manifest />

View File

@ -5,4 +5,3 @@ include ':liblazysodium'
include ':libsession'
include ':libsignal'
include ':libsession-util'
include ':stickyheader'

View File

@ -1,2 +0,0 @@
configurations.maybeCreate("default")
artifacts.add("default", file('stickyheadergrid-0.9.4.aar'))