From 16d6efbb5b31493f7d4015dfba17c9133b046c9f Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Mon, 19 Aug 2024 13:46:38 +1000 Subject: [PATCH 1/5] SES-2524 - Rewrite media gallery in Compose (#1619) --- app/build.gradle | 55 +- app/src/androidTest/AndroidManifest.xml | 3 +- app/src/main/AndroidManifest.xml | 2 + .../securesms/MediaDocumentsAdapter.java | 132 ----- .../securesms/MediaGalleryAdapter.java | 173 ------ .../securesms/MediaOverviewActivity.java | 509 ------------------ .../securesms/MediaPreviewActivity.java | 5 +- .../start/home/StartConversation.kt | 20 +- .../start/invitefriend/InviteFriend.kt | 19 +- .../start/newmessage/NewMessage.kt | 14 +- .../v2/menus/ConversationMenuHelper.kt | 6 +- .../securesms/media/AttachmentHeader.kt | 30 ++ .../securesms/media/DocumentsPage.kt | 111 ++++ .../securesms/media/FixedTimeBuckets.kt | 45 ++ .../securesms/media/MediaOverviewActivity.kt | 43 ++ .../securesms/media/MediaOverviewScreen.kt | 313 +++++++++++ .../securesms/media/MediaOverviewTopAppBar.kt | 57 ++ .../securesms/media/MediaOverviewViewModel.kt | 431 +++++++++++++++ .../thoughtcrime/securesms/media/MediaPage.kt | 191 +++++++ .../PrivacySettingsPreferenceFragment.kt | 5 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 28 +- .../securesms/ui/components/AppBar.kt | 173 +++++- .../securesms/util/SaveAttachmentTask.kt | 266 +++++---- .../res/layout/media_overview_activity.xml | 37 -- .../layout/media_overview_document_item.xml | 32 -- .../media_overview_document_item_header.xml | 16 - .../media_overview_documents_fragment.xml | 25 - .../media_overview_gallery_fragment.xml | 21 - .../layout/media_overview_gallery_item.xml | 28 - .../media_overview_gallery_item_header.xml | 19 - app/src/main/res/values/strings.xml | 1 + gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 2 +- libsession-util/src/main/AndroidManifest.xml | 3 +- libsession/build.gradle | 6 + libsession/src/main/AndroidManifest.xml | 2 +- libsignal/build.gradle | 5 + libsignal/src/main/AndroidManifest.xml | 2 +- settings.gradle | 1 - stickyheader/build.gradle | 2 - stickyheader/stickyheadergrid-0.9.4.aar | Bin 41521 -> 0 bytes 41 files changed, 1608 insertions(+), 1230 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/MediaDocumentsAdapter.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/AttachmentHeader.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt delete mode 100644 app/src/main/res/layout/media_overview_activity.xml delete mode 100644 app/src/main/res/layout/media_overview_document_item.xml delete mode 100644 app/src/main/res/layout/media_overview_document_item_header.xml delete mode 100644 app/src/main/res/layout/media_overview_documents_fragment.xml delete mode 100644 app/src/main/res/layout/media_overview_gallery_fragment.xml delete mode 100644 app/src/main/res/layout/media_overview_gallery_item.xml delete mode 100644 app/src/main/res/layout/media_overview_gallery_item_header.xml delete mode 100644 stickyheader/build.gradle delete mode 100644 stickyheader/stickyheadergrid-0.9.4.aar 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 a704f0985f0c3492ae544b6f36356645966fa420..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41521 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ɐ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 From 80933938e105770633c9a9f7c7a6d9467919cb1c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 Aug 2024 13:56:35 +1000 Subject: [PATCH 2/5] Removing unsed UI tests (#1625) We need to rethink the UI tests if we do indeed want to use them as they are not tied to the existing flow anymore --- .../java/network/loki/messenger/HomeActivityTests.kt | 12 ++++++++---- .../messenger/libsession_util/InstrumentedTests.kt | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index af1d8f6d22..2f3ba9fb10 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -42,6 +42,10 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar import org.thoughtcrime.securesms.home.HomeActivity import com.bumptech.glide.Glide +/** + * Currently not used as part of our CI/Deployment processes !!!! + */ + @RunWith(AndroidJUnit4::class) @LargeTest class HomeActivityTests { @@ -107,7 +111,7 @@ class HomeActivityTests { } - private fun goToMyChat() { +/* private fun goToMyChat() { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) // new chat @@ -122,7 +126,7 @@ class HomeActivityTests { onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied)) onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) - } + }*/ @Test fun testLaunches_dismiss_seedView() { @@ -145,7 +149,7 @@ class HomeActivityTests { onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } - @Test +/* @Test fun testChat_withSelf() { setupLoggedInState() goToMyChat() @@ -176,7 +180,7 @@ class HomeActivityTests { onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently onView(withText(dialogPromptText)).check(matches(isDisplayed())) - } + }*/ /** * Perform action of waiting for a specific time. diff --git a/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt b/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt index 2c75de50ce..4f569060f9 100644 --- a/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt +++ b/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt @@ -551,6 +551,7 @@ class InstrumentedTests { is Conversation.OneToOne -> seen.add("1-to-1: ${convo.accountId}") is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}") is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}") + null -> { /* ignore null cases */ } } } From 2a438ac24d0deab1011506b6aa14cfe134d95221 Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:38:55 +1000 Subject: [PATCH 3/5] Add missing play button on media gallery page (#1626) --- .../thoughtcrime/securesms/media/MediaPage.kt | 24 +++++++++++++++---- scripts/drone-static-upload.sh | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt index b7dfe68b24..c94b08b68b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt @@ -14,16 +14,20 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -34,6 +38,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import kotlin.math.ceil @@ -158,10 +163,21 @@ private fun ThumbnailRow( when { item.showPlayOverlay -> { - Image( - painter = painterResource(R.drawable.ic_baseline_play_circle_filled_48), - contentDescription = null - ) + // The code below is translated from thumbnail_view.xml: + // Trying to show a green play button on a white background. + Box( + modifier = Modifier + .size(48.dp) + .background(Color.White, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.padding(start = LocalDimensions.current.xxxsSpacing), + painter = painterResource(R.drawable.triangle_right), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.primary) + ) + } } } diff --git a/scripts/drone-static-upload.sh b/scripts/drone-static-upload.sh index b5c9ee83f7..0e5209c942 100755 --- a/scripts/drone-static-upload.sh +++ b/scripts/drone-static-upload.sh @@ -19,7 +19,7 @@ chmod 600 ssh_key # Define the output paths build_dir="app/build/outputs/apk/play/debug" -target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal.apk')" +target_path="${build_dir}/$(ls ${build_dir} | grep -o 'app-[^[:space:]]*-universal-debug.apk')" # Validate the paths exist if [ ! -d $build_path ]; then From 6701cb1dc1adad84ffc6d2a1ca3c05308fd1fdae Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 19 Aug 2024 15:44:18 +1000 Subject: [PATCH 4/5] SES-2437 - fix broken path animation (#1627) --- .../main/java/org/thoughtcrime/securesms/home/PathActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index e65f2d2f26..f399b602b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -264,8 +264,8 @@ class PathActivity : PassphraseRequiredActionBarActivity() { job?.cancel() job = GlobalScope.launch { withContext(Dispatchers.Main) { + delay(dotAnimationStartDelay) while (isActive) { - delay(dotAnimationStartDelay) expand() delay(EXPAND_ANIM_DELAY_MILLS) collapse() From 9919f716a7cb93a7946b481fc3ce6510805866e5 Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:32:30 +1000 Subject: [PATCH 5/5] [SES-2512] Rewrite ProfilePictureView (#1622) --- .../securesms/calls/WebRtcCallActivity.kt | 19 +- .../components/ProfilePictureView.kt | 328 +++++++++++------- .../securesms/contacts/UserView.kt | 3 +- .../conversation/ConversationActionBarView.kt | 2 +- .../conversation/v2/ConversationAdapter.kt | 1 + .../MentionCandidateSelectionView.kt | 87 ----- .../v2/components/MentionCandidateView.kt | 42 --- .../mentions/MentionCandidateView.kt | 6 +- .../v2/messages/VisibleMessageView.kt | 9 +- .../securesms/glide/ContactPhotoFetcher.java | 4 +- .../glide/PlaceholderAvatarFetcher.kt | 9 +- .../glide/PlaceholderAvatarLoader.kt | 13 +- .../securesms/home/ConversationView.kt | 4 +- .../securesms/home/HomeActivity.kt | 8 +- .../securesms/home/UserDetailsBottomSheet.kt | 3 +- .../home/search/GlobalSearchAdapter.kt | 8 - .../home/search/GlobalSearchAdapterUtils.kt | 8 +- .../messagerequests/MessageRequestView.kt | 3 +- .../securesms/mms/SignalGlideModule.java | 2 + .../preferences/BlockedContactsAdapter.kt | 9 +- .../securesms/preferences/SettingsActivity.kt | 17 +- .../reactions/ReactionRecipientsAdapter.java | 4 +- .../thoughtcrime/securesms/ui/Components.kt | 3 +- .../res/layout/view_mention_candidate.xml | 42 --- .../avatars/PlaceholderAvatarPhoto.kt | 8 +- 25 files changed, 251 insertions(+), 391 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt delete mode 100644 app/src/main/res/layout/view_mention_candidate.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 2bded3cccb..04acb8908b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -27,8 +27,8 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityWebrtcBinding import org.apache.commons.lang3.time.DurationFormatUtils import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -194,13 +194,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { clipFloatingInsets() // set up the user avatar - TextSecurePreferences.getLocalNumber(this)?.let{ - val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it) - binding.userAvatar.apply { - publicKey = it - displayName = username - update() - } + TextSecurePreferences.getLocalNumber(this)?.let { + binding.userAvatar.load(Address.fromSerialized(it)) } } @@ -332,8 +327,6 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { launch { viewModel.recipient.collect { latestRecipient -> - binding.contactAvatar.recycle() - if (latestRecipient.recipient != null) { val contactPublicKey = latestRecipient.recipient.address.serialize() val contactDisplayName = getUserDisplayName(contactPublicKey) @@ -341,11 +334,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { binding.remoteRecipientName.text = contactDisplayName // sort out the contact's avatar - binding.contactAvatar.apply { - publicKey = contactPublicKey - displayName = contactDisplayName - update() - } + binding.contactAvatar.load(latestRecipient.recipient) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 9511bddb6a..f2ea090179 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -3,164 +3,224 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View -import android.widget.ImageView -import android.widget.RelativeLayout +import android.widget.FrameLayout +import androidx.core.view.isVisible +import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding import org.session.libsession.avatars.ContactColors +import org.session.libsession.avatars.ContactPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto -import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ResourceContactPhoto -import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address -import org.session.libsession.utilities.AppTextSecurePreferences -import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager +import org.thoughtcrime.securesms.database.GroupDatabase +import javax.inject.Inject -class ProfilePictureView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : RelativeLayout(context, attrs) { - private val TAG = "ProfilePictureView" +@AndroidEntryPoint +class ProfilePictureView : FrameLayout { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + @Inject + lateinit var groupDatabase: GroupDatabase private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) - private val glide: RequestManager = Glide.with(this) - private val prefs = AppTextSecurePreferences(context) - private val userPublicKey = prefs.getLocalNumber() - var publicKey: String? = null - var displayName: String? = null - var additionalPublicKey: String? = null - var additionalDisplayName: String? = null + private var lastLoadJob: Job? = null + private var lastLoadAddress: Address? = null - private val profilePicturesCache = mutableMapOf() - private val resourcePadding by lazy { - context.resources.getDimensionPixelSize(R.dimen.normal_padding).toFloat() - } - private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) } - private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) } - - constructor(context: Context, sender: Recipient): this(context) { - update(sender) + private val unknownRecipientDrawable by lazy(LazyThreadSafetyMode.NONE) { + ResourceContactPhoto(R.drawable.ic_profile_default) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } - fun update(recipient: Recipient) { - recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } + private val unknownOpenGroupDrawable by lazy(LazyThreadSafetyMode.NONE) { + ResourceContactPhoto(R.drawable.ic_notification) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } - fun update( - address: Address, - isClosedGroupRecipient: Boolean = false, - isOpenGroupInboxRecipient: Boolean = false - ) { - fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() - ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) - ?: publicKey + private fun setShowAsDoubleMode(showAsDouble: Boolean) { + binding.doubleModeImageViewContainer.isVisible = showAsDouble + binding.singleModeImageView.isVisible = !showAsDouble + } - if (isClosedGroupRecipient) { - val members = DatabaseComponent.get(context).groupDatabase() - .getGroupMemberAddresses(address.toGroupString(), true) - .sorted() - .take(2) - if (members.size <= 1) { - publicKey = "" - displayName = "" - additionalPublicKey = "" - additionalDisplayName = "" - } else { - val pk = members.getOrNull(0)?.serialize() ?: "" - publicKey = pk - displayName = getUserDisplayName(pk) - val apk = members.getOrNull(1)?.serialize() ?: "" - additionalPublicKey = apk - additionalDisplayName = getUserDisplayName(apk) + private fun cancelLastLoadJob() { + lastLoadJob?.cancel() + lastLoadJob = null + } + + @OptIn(DelicateCoroutinesApi::class) + private fun loadAsDoubleImages(model: LoadModel) { + cancelLastLoadJob() + + // The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use + // to launch a coroutine from a view. The potential memory leak is not a concern here, as + // the coroutine is very short-lived. If you change the code here to be long live then you'll + // need to find a better scope to launch the coroutine from. + lastLoadJob = GlobalScope.launch(Dispatchers.Main) { + data class GroupMemberInfo( + val contactPhoto: ContactPhoto?, + val placeholderAvatarPhoto: PlaceholderAvatarPhoto, + ) + + // Load group avatar if available, otherwise load member avatars + val groupAvatarOrMemberAvatars = withContext(Dispatchers.Default) { + model.loadRecipient(context).contactPhoto + ?: groupDatabase.getGroupMembers(model.address.toGroupString(), true) + .map { + GroupMemberInfo( + contactPhoto = it.contactPhoto, + placeholderAvatarPhoto = PlaceholderAvatarPhoto( + hashString = it.address.serialize(), + displayName = it.displayName() + ) + ) + } } - } else if(isOpenGroupInboxRecipient) { - val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize()) - this.publicKey = publicKey - displayName = getUserDisplayName(publicKey) - additionalPublicKey = null - } else { - val publicKey = address.serialize() - this.publicKey = publicKey - displayName = getUserDisplayName(publicKey) - additionalPublicKey = null - } - update() - } - fun update() { - val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture") - val additionalPublicKey = additionalPublicKey - // if we have a multi avatar setup - if (additionalPublicKey != null) { - setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName) - setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName) - binding.doubleModeImageViewContainer.visibility = View.VISIBLE + when (groupAvatarOrMemberAvatars) { + is ContactPhoto -> { + setShowAsDoubleMode(false) + Glide.with(this@ProfilePictureView) + .load(groupAvatarOrMemberAvatars) + .error(unknownRecipientDrawable) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.singleModeImageView) + } - // clear single image - glide.clear(binding.singleModeImageView) - binding.singleModeImageView.visibility = View.INVISIBLE - } else { // single image mode - setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName) - binding.singleModeImageView.visibility = View.VISIBLE + is List<*> -> { + val first = groupAvatarOrMemberAvatars.getOrNull(0) as? GroupMemberInfo + val second = groupAvatarOrMemberAvatars.getOrNull(1) as? GroupMemberInfo + setShowAsDoubleMode(true) + Glide.with(binding.doubleModeImageView1) + .load(first?.let { it.contactPhoto ?: it.placeholderAvatarPhoto }) + .error(first?.placeholderAvatarPhoto ?: unknownRecipientDrawable) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.doubleModeImageView1) - // clear multi image - glide.clear(binding.doubleModeImageView1) - glide.clear(binding.doubleModeImageView2) - binding.doubleModeImageViewContainer.visibility = View.INVISIBLE - } + Glide.with(binding.doubleModeImageView2) + .load(second?.let { it.contactPhoto ?: it.placeholderAvatarPhoto }) + .error(second?.placeholderAvatarPhoto ?: unknownRecipientDrawable) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.doubleModeImageView2) + } - } - - private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) { - if (publicKey.isNotEmpty()) { - val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) - if (profilePicturesCache[imageView] == recipient) return - profilePicturesCache[imageView] = recipient - val signalProfilePicture = recipient.contactPhoto - val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject - - glide.clear(imageView) - - val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") - - if (signalProfilePicture != null && avatar != "0" && avatar != "") { - glide.load(signalProfilePicture) - .placeholder(unknownRecipientDrawable) - .centerCrop() - .error(glide.load(placeholder)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() - .into(imageView) - } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) { - glide.clear(imageView) - glide.load(unknownOpenGroupDrawable) - .centerCrop() - .circleCrop() - .into(imageView) - } else { - glide.load(placeholder) - .placeholder(unknownRecipientDrawable) - .centerCrop() - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) + else -> { + setShowAsDoubleMode(false) + binding.singleModeImageView.setImageDrawable(unknownRecipientDrawable) + } } - } else { - glide.load(unknownRecipientDrawable) - .centerCrop() - .into(imageView) } } - fun recycle() { - profilePicturesCache.clear() + @OptIn(DelicateCoroutinesApi::class) + private fun loadAsSingleImage(model: LoadModel) { + cancelLastLoadJob() + + setShowAsDoubleMode(false) + + // Only clear the old image if the address has changed. This is important as we have a delay + // in loading the image, if this view is reused for another address before the image is loaded, + // the previous image could be displayed for a short period of time. We would want to avoid + // displaying the wrong image, even for a short time. + // However, if we are displaying the same user's image again, it's ok to show the old + // image until the new one is loaded. This is a trade-off between performance and correctness. + if (lastLoadAddress != model.address) { + Glide.with(this).clear(this) + } + + // The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use + // to launch a coroutine from a view. The potential memory leak is not a concern here, as + // the coroutine is very short-lived. If you change the code here to be long live then you'll + // need to find a better scope to launch the coroutine from. + lastLoadJob = GlobalScope.launch(Dispatchers.Main) { + val (contactPhoto, avatarPlaceholder) = withContext(Dispatchers.Default) { + model.loadRecipient(context).let { + it.contactPhoto to PlaceholderAvatarPhoto(it.address.serialize(), it.displayName()) + } + } + + val address = model.address + + val errorModel: Any = when { + address.isCommunity -> unknownOpenGroupDrawable + address.isContact -> avatarPlaceholder + else -> unknownRecipientDrawable + } + + Glide.with(this@ProfilePictureView) + .load(contactPhoto) + .error(errorModel) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.singleModeImageView) + } } - // endregion -} + + fun load(recipient: Recipient) { + if (recipient.address.isClosedGroup) { + loadAsDoubleImages(LoadModel.RecipientModel(recipient)) + } else { + loadAsSingleImage(LoadModel.RecipientModel(recipient)) + } + + lastLoadAddress = recipient.address + } + + fun load(address: Address) { + if (address.isClosedGroup) { + loadAsDoubleImages(LoadModel.AddressModel(address)) + } else { + loadAsSingleImage(LoadModel.AddressModel(address)) + } + + lastLoadAddress = address + } + + private fun Recipient.displayName(): String { + return if (isLocalNumber) { + TextSecurePreferences.getProfileName(context).orEmpty() + } else { + profileName ?: name ?: "" + } + } + + private sealed interface LoadModel { + val address: Address + + /** + * Load the recipient if it's not already loaded. + */ + fun loadRecipient(context: Context): Recipient + + data class AddressModel(override val address: Address) : LoadModel { + override fun loadRecipient(context: Context): Recipient { + return Recipient.from(context, address, false) + } + } + + data class RecipientModel(val recipient: Recipient) : LoadModel { + override val address: Address + get() = recipient.address + + override fun loadRecipient(context: Context): Recipient = recipient + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e0ca2a4242..3af63670dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -53,7 +53,7 @@ class UserView : LinearLayout { return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } val address = user.address.serialize() - binding.profilePictureView.update(user) + binding.profilePictureView.load(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { @@ -85,7 +85,6 @@ class UserView : LinearLayout { } fun unbind() { - binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 8f2da7a733..d8ba8122ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -81,7 +81,7 @@ class ConversationActionBarView @JvmOverloads constructor( } fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { - binding.profilePictureView.update(recipient) + binding.profilePictureView.load(recipient) binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self) updateSubtitle(recipient, openGroup, config) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 1c57dc8d5f..0a0829a02c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import com.bumptech.glide.RequestManager +import org.thoughtcrime.securesms.database.getLong import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.showSessionDialog import java.util.concurrent.atomic.AtomicLong diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt deleted file mode 100644 index 5698ddd0bb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.ListView -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import com.bumptech.glide.RequestManager -import org.thoughtcrime.securesms.util.toPx - -class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) { - private var mentionCandidates = listOf() - set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue } - var glide: RequestManager? = null - set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue } - var openGroupServer: String? = null - set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupServer = openGroupServer } - var openGroupRoom: String? = null - set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupRoom = openGroupRoom } - var onMentionCandidateSelected: ((Mention) -> Unit)? = null - - private val mentionCandidateSelectionViewAdapter by lazy { Adapter(context) } - - private class Adapter(private val context: Context) : BaseAdapter() { - var mentionCandidates = listOf() - set(newValue) { field = newValue; notifyDataSetChanged() } - var glide: RequestManager? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - override fun getCount(): Int { - return mentionCandidates.count() - } - - override fun getItemId(position: Int): Long { - return position.toLong() - } - - override fun getItem(position: Int): Mention { - return mentionCandidates[position] - } - - override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context) - val mentionCandidate = getItem(position) - cell.glide = glide - cell.mentionCandidate = mentionCandidate - cell.openGroupServer = openGroupServer - cell.openGroupRoom = openGroupRoom - return cell - } - } - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context) : this(context, null) - - init { - clipToOutline = true - adapter = mentionCandidateSelectionViewAdapter - mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates - setOnItemClickListener { _, _, position, _ -> - onMentionCandidateSelected?.invoke(mentionCandidates[position]) - } - } - - fun show(mentionCandidates: List, threadID: Long) { - val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) - if (openGroup != null) { - openGroupServer = openGroup.server - openGroupRoom = openGroup.room - } - this.mentionCandidates = mentionCandidates - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources) - this.layoutParams = layoutParams - } - - fun hide() { - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = 0 - this.layoutParams = layoutParams - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt deleted file mode 100644 index 14dc6263ab..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.LinearLayout -import network.loki.messenger.databinding.ViewMentionCandidateBinding -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.groups.OpenGroupManager -import com.bumptech.glide.RequestManager - -class MentionCandidateView : LinearLayout { - private lateinit var binding: ViewMentionCandidateBinding - var mentionCandidate = Mention("", "") - set(newValue) { field = newValue; update() } - var glide: RequestManager? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewMentionCandidateBinding.inflate(LayoutInflater.from(context), this, true) - } - - private fun update() = with(binding) { - mentionCandidateNameTextView.text = mentionCandidate.displayName - profilePictureView.publicKey = mentionCandidate.publicKey - profilePictureView.displayName = mentionCandidate.displayName - profilePictureView.additionalPublicKey = null - profilePictureView.update() - if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) - moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE - } else { - moderatorIconImageView.visibility = View.GONE - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index f790e7f1c6..c1df0cb631 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -2,13 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions import android.view.View import network.loki.messenger.databinding.ViewMentionCandidateV2Binding +import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) { mentionCandidateNameTextView.text = candidate.nameHighlighted - profilePictureView.publicKey = candidate.member.publicKey - profilePictureView.displayName = candidate.member.name - profilePictureView.additionalPublicKey = null - profilePictureView.update() + profilePictureView.load(Address.fromSerialized(candidate.member.publicKey)) moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9f7f620ab5..5f26d50251 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -16,7 +16,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -26,6 +25,8 @@ import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.view.marginBottom +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding @@ -52,8 +53,6 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toDp @@ -179,8 +178,7 @@ class VisibleMessageView : FrameLayout { if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.publicKey = senderAccountID - binding.profilePictureView.update(message.individualRecipient) + binding.profilePictureView.load(message.individualRecipient) binding.profilePictureView.setOnClickListener { if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) @@ -456,7 +454,6 @@ class VisibleMessageView : FrameLayout { } fun recycle() { - binding.profilePictureView.recycle() binding.messageContentView.root.recycle() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java index 6ab528b785..7f74365604 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java @@ -13,14 +13,14 @@ import org.session.libsession.avatars.ContactPhoto; import java.io.IOException; import java.io.InputStream; -class ContactPhotoFetcher implements DataFetcher { +public class ContactPhotoFetcher implements DataFetcher { private final Context context; private final ContactPhoto contactPhoto; private InputStream inputStream; - ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { + public ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { this.context = context.getApplicationContext(); this.contactPhoto = contactPhoto; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt index 38d51877de..36ff89580f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt @@ -9,12 +9,15 @@ import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator -class PlaceholderAvatarFetcher(private val context: Context, - private val photo: PlaceholderAvatarPhoto): DataFetcher { +class PlaceholderAvatarFetcher( + private val context: Context, + private val hashString: String, + private val displayName: String +): DataFetcher { override fun loadData(priority: Priority,callback: DataFetcher.DataCallback) { try { - val avatar = AvatarPlaceholderGenerator.generate(context, 128, photo.hashString, photo.displayName) + val avatar = AvatarPlaceholderGenerator.generate(context, 128, hashString, displayName) callback.onDataReady(avatar) } catch (e: Exception) { Log.e("Loki", "Error in fetching avatar") diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt index b163b5ed90..235161ae32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -8,6 +8,9 @@ import com.bumptech.glide.load.model.ModelLoader.LoadData import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import org.session.libsession.avatars.PlaceholderAvatarPhoto +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { @@ -17,7 +20,15 @@ class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { - return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) + val displayName: String = when { + !model.displayName.isNullOrBlank() -> model.displayName.orEmpty() + model.hashString == TextSecurePreferences.getLocalNumber(appContext) -> TextSecurePreferences.getProfileName(appContext).orEmpty() + else -> Recipient.from(appContext, Address.fromSerialized(model.hashString), false).let { + it.profileName ?: it.name ?: "" + } + } + + return LoadData(model, PlaceholderAvatarFetcher(appContext, model.hashString, displayName)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 68aea84417..875c96ed23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable -import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -128,11 +127,10 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } - binding.profilePictureView.update(thread.recipient) + binding.profilePictureView.load(thread.recipient) } fun recycle() { - binding.profilePictureView.recycle() } private fun getTitle(recipient: Recipient): String? = when { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2d683abe8f..bd8919ddc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -347,8 +347,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - binding.profileButton.recycle() // clear cached image before update tje profilePictureView - binding.profileButton.update() + binding.profileButton.load(Address.fromSerialized(publicKey)) if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } @@ -388,10 +387,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateProfileButton() { - binding.profileButton.publicKey = publicKey - binding.profileButton.displayName = textSecurePreferences.getProfileName() - binding.profileButton.recycle() - binding.profileButton.update() + binding.profileButton.load(Address.fromSerialized(publicKey)) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index cae399dcbf..a6d0627b27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -55,8 +55,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.publicKey = publicKey - profilePictureView.update(recipient) + profilePictureView.load(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 71c2c62506..f70053e44a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -11,7 +11,6 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException @@ -99,12 +98,6 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } } - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is ContentView) { - holder.binding.searchResultProfilePicture.recycle() - } - } - class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { val binding = ViewGlobalSearchResultBinding.bind(view) @@ -114,7 +107,6 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } fun bind(query: String, model: Model) { - binding.searchResultProfilePicture.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index d390776d1c..7ee46b8c8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -93,7 +93,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) - binding.searchResultProfilePicture.update(threadRecipient) + binding.searchResultProfilePicture.load(threadRecipient) val nameString = model.groupRecord.title binding.searchResultTitle.text = getHighlight(query, nameString) @@ -111,7 +111,7 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { searchResultTimestamp.isVisible = false searchResultSubtitle.text = null val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false) - searchResultProfilePicture.update(recipient) + searchResultProfilePicture.load(recipient) val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self) else model.contact.getSearchName() searchResultTitle.text = getHighlight(query, nameString) @@ -121,7 +121,7 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey)) + binding.searchResultProfilePicture.load(Address.fromSerialized(model.currentUserPublicKey)) binding.searchResultProfilePicture.isVisible = true } @@ -134,7 +134,7 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply { // unreadCountTextView.text = model.unread.toString() // } searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) - searchResultProfilePicture.update(model.messageResult.conversationRecipient) + searchResultProfilePicture.load(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 1fb1f38ce4..6e5ca6d8cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -49,12 +49,11 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.update(thread.recipient) + binding.profilePictureView.load(thread.recipient) } } fun recycle() { - binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java index 02172b7248..ed8d3c7d85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -56,6 +56,7 @@ public class SignalGlideModule extends AppGlideModule { // builder.setDiskCache(new NoopDiskCacheFactory()); } + /** @noinspection unchecked*/ @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); @@ -74,6 +75,7 @@ public class SignalGlideModule extends AppGlideModule { registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context)); + registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt index e59d86c912..589aabef1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt @@ -36,11 +36,6 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap else holder.select(getItem(position).isSelected) } - override fun onViewRecycled(holder: ViewHolder) { - super.onViewRecycled(holder) - holder.binding.profilePictureView.recycle() - } - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { val glide = Glide.with(itemView) @@ -48,9 +43,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { binding.recipientName.text = selectable.item.name - with (binding.profilePictureView) { - update(selectable.item) - } + binding.profilePictureView.load(selectable.item) binding.root.setOnClickListener { toggle(selectable) } binding.selectButton.isSelected = selectable.isSelected } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index fc52541987..72e9a5e1d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -168,7 +168,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { super.onStart() binding.run { - setupProfilePictureView(profilePictureView) + loadProfilePicture(profilePictureView) profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = getDisplayName() @@ -185,12 +185,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private fun getDisplayName(): String = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) - private fun setupProfilePictureView(view: ProfilePictureView) { - view.apply { - publicKey = hexEncodedPublicKey - displayName = getDisplayName() - update() - } + private fun loadProfilePicture(view: ProfilePictureView) { + // Always reload the profile picture as it can change on this page. + view.load(Address.fromSerialized(hexEncodedPublicKey)) } override fun onSaveInstanceState(outState: Bundle) { @@ -331,9 +328,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) - // Update our visuals - binding.profilePictureView.recycle() - binding.profilePictureView.update() + loadProfilePicture(binding.profilePictureView) } // If the sync failed then inform the user @@ -408,7 +403,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { cancelButton() }.apply { val profilePic = findViewById(R.id.profile_picture_view) - ?.also(::setupProfilePictureView) + ?.also(::loadProfilePicture) val pictureIcon = findViewById(R.id.ic_pictures) diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 79717eabb1..c9a3f4ea13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -13,7 +13,6 @@ import org.session.libsession.messaging.utilities.AccountId; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; -import com.bumptech.glide.Glide; import java.util.Collections; import java.util.List; @@ -154,7 +153,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter - - - - - - - - - - - - - \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt index 916e9112de..154825179c 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -3,10 +3,12 @@ package org.session.libsession.avatars import com.bumptech.glide.load.Key import java.security.MessageDigest -class PlaceholderAvatarPhoto(val hashString: String, - val displayName: String): Key { +data class PlaceholderAvatarPhoto( + val hashString: String, + val displayName: String? +) : Key { override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(hashString.encodeToByteArray()) - messageDigest.update(displayName.encodeToByteArray()) + messageDigest.update(displayName?.encodeToByteArray() ?: byteArrayOf()) } } \ No newline at end of file