diff --git a/app/build.gradle b/app/build.gradle index 0b1116434c..bfb2c0cdd0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index deab87dd62..023120e1a8 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,6 +1,5 @@ + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b6bcdf6293..98f9aa4b5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -306,6 +306,8 @@ android:name="android.support.PARENT_ACTIVITY" android:value="org.thoughtcrime.securesms.home.HomeActivity" /> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaDocumentsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaDocumentsAdapter.java deleted file mode 100644 index bbc00472a5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaDocumentsAdapter.java +++ /dev/null @@ -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 implements StickyHeaderDecoration.StickyHeaderAdapter { - - 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); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java deleted file mode 100644 index 9a262a2b0a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ /dev/null @@ -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 . - */ -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 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 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); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java deleted file mode 100644 index 8cb8fcd195..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ /dev/null @@ -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 . - */ -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 extends Fragment implements LoaderManager.LoaderCallbacks { - - 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 - 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 onCreateLoader(int i, Bundle bundle) { - return new BucketedThreadMediaLoader(getContext(), recipient.getAddress()); - } - - @Override - public void onLoadFinished(@NonNull Loader 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 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 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>( - context, - R.string.MediaOverviewActivity_collecting_attachments, - R.string.please_wait) { - @Override - protected List doInBackground(Void... params) { - List 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 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 mediaRecords) { - int recordCount = mediaRecords.size(); - - DeleteMediaDialog.show( - requireContext(), - recordCount, - () -> new ProgressDialogAsyncTask( - 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 { - - @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 onCreateLoader(int id, Bundle args) { - return new ThreadMediaLoader(getContext(), recipient.getAddress(), false); - } - - @Override - public void onLoadFinished(@NonNull Loader 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 loader) { - ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); - getActivity().invalidateOptionsMenu(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index f0493d043a..c71f5d041c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt index fcf13c9cf1..15ca216777 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt index 54abf66303..3453fb5722 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index f0a6e21b4c..a2cb95a6b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -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]) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 177becd497..8d018d6813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/AttachmentHeader.kt b/app/src/main/java/org/thoughtcrime/securesms/media/AttachmentHeader.kt new file mode 100644 index 0000000000..7191450ac4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/AttachmentHeader.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt new file mode 100644 index 0000000000..0f4b8c58b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt @@ -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, + ) + } + } + + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt new file mode 100644 index 0000000000..f01276f78e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt new file mode 100644 index 0000000000..7111c00017 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt new file mode 100644 index 0000000000..f79d387138 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -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 + } + diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt new file mode 100644 index 0000000000..7ae5ba516d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt @@ -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, + ) + } + } + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt new file mode 100644 index 0000000000..a0b629737d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -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 = 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 = recipient + .map { it.toShortString() } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + + val mediaListState: StateFlow = 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()) + val selectedItemIDs: StateFlow> get() = mutableSelectedItemIDs + + val inSelectionMode: StateFlow = selectedItemIDs + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.Eagerly, mutableSelectedItemIDs.value.isNotEmpty()) + + val canLongPress: StateFlow = inSelectionMode + .map { !it } + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + private val mutableEvents = MutableSharedFlow() + val events get() = mutableEvents + + private val mutableSelectedTab = MutableStateFlow(MediaOverviewTab.Media) + val selectedTab: StateFlow get() = mutableSelectedTab + + private val mutableShowingActionProgress = MutableStateFlow(null) + val showingActionProgress: StateFlow get() = mutableShowingActionProgress + + private val selectedMedia: Sequence + get() { + val selected = selectedItemIDs.value + return mediaListState.value + ?.mediaContent + ?.asSequence() + .orEmpty() + .flatMap { it.second.asSequence() } + .filter { it.id in selected } + } + + private fun Sequence.groupRecordsByTimeBuckets(): List>> { + 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.groupRecordsByRelativeTime(): List>> { + 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 create(modelClass: Class): 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>> + +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) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt new file mode 100644 index 0000000000..b7dfe68b24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt @@ -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, + 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, + rowIndex: Int, + onItemClicked: (MediaOverviewItem) -> Unit, + onItemLongClicked: ((Long) -> Unit)?, + selectedItemIDs: Set +) { + 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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index fb8b0ee3ef..8404a4f8e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index df66cef471..5894e1072a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index b64a482d8a..8cc0f9acab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -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 + ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index 8df2a7cd2b..17ef3bd7f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -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 = 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 { + 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 + 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 { 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 = 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 { - 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) { 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?) + } \ No newline at end of file diff --git a/app/src/main/res/layout/media_overview_activity.xml b/app/src/main/res/layout/media_overview_activity.xml deleted file mode 100644 index 6956adc454..0000000000 --- a/app/src/main/res/layout/media_overview_activity.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/media_overview_document_item.xml b/app/src/main/res/layout/media_overview_document_item.xml deleted file mode 100644 index 231592530f..0000000000 --- a/app/src/main/res/layout/media_overview_document_item.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/media_overview_document_item_header.xml b/app/src/main/res/layout/media_overview_document_item_header.xml deleted file mode 100644 index 0bbc21e05c..0000000000 --- a/app/src/main/res/layout/media_overview_document_item_header.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/media_overview_documents_fragment.xml b/app/src/main/res/layout/media_overview_documents_fragment.xml deleted file mode 100644 index 03d041c5c4..0000000000 --- a/app/src/main/res/layout/media_overview_documents_fragment.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/media_overview_gallery_fragment.xml b/app/src/main/res/layout/media_overview_gallery_fragment.xml deleted file mode 100644 index 426e2fc694..0000000000 --- a/app/src/main/res/layout/media_overview_gallery_fragment.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/media_overview_gallery_item.xml b/app/src/main/res/layout/media_overview_gallery_item.xml deleted file mode 100644 index a4c3f324af..0000000000 --- a/app/src/main/res/layout/media_overview_gallery_item.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/media_overview_gallery_item_header.xml b/app/src/main/res/layout/media_overview_gallery_item_header.xml deleted file mode 100644 index 4a713babc8..0000000000 --- a/app/src/main/res/layout/media_overview_gallery_item_header.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f00717c57c..537038921d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1139,4 +1139,5 @@ You cannot go back further. In order to stop loading your account, Session needs to quit. You cannot go back further. In order to cancel your account creation, Session needs to quit. Quit + Back diff --git a/gradle.properties b/gradle.properties index b40f4b59ca..d0e7a7e371 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cd825d0848..a102525e6a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/libsession-util/src/main/AndroidManifest.xml b/libsession-util/src/main/AndroidManifest.xml index 65483324a6..a5918e68ab 100644 --- a/libsession-util/src/main/AndroidManifest.xml +++ b/libsession-util/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index 031555d41f..7e573ee113 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -17,6 +17,12 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = '1.8' + } + + namespace 'org.session.libsession' } dependencies { diff --git a/libsession/src/main/AndroidManifest.xml b/libsession/src/main/AndroidManifest.xml index d9af5e1f89..568741e54f 100644 --- a/libsession/src/main/AndroidManifest.xml +++ b/libsession/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 5ac0d0b4fc..e2bea01f22 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -12,6 +12,11 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = '1.8' + } + namespace 'org.session.libsignal' } dependencies { diff --git a/libsignal/src/main/AndroidManifest.xml b/libsignal/src/main/AndroidManifest.xml index ff0be1b305..568741e54f 100644 --- a/libsignal/src/main/AndroidManifest.xml +++ b/libsignal/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index e2388ee105..7ab26e097c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,4 +5,3 @@ include ':liblazysodium' include ':libsession' include ':libsignal' include ':libsession-util' -include ':stickyheader' diff --git a/stickyheader/build.gradle b/stickyheader/build.gradle deleted file mode 100644 index b7a36ae090..0000000000 --- a/stickyheader/build.gradle +++ /dev/null @@ -1,2 +0,0 @@ -configurations.maybeCreate("default") -artifacts.add("default", file('stickyheadergrid-0.9.4.aar')) \ No newline at end of file diff --git a/stickyheader/stickyheadergrid-0.9.4.aar b/stickyheader/stickyheadergrid-0.9.4.aar deleted file mode 100644 index a704f0985f..0000000000 Binary files a/stickyheader/stickyheadergrid-0.9.4.aar and /dev/null differ