Merge branch 'dev' into strings-squashed

This commit is contained in:
alansley 2024-08-20 13:27:35 +10:00
commit e812527358
72 changed files with 1924 additions and 1693 deletions

View File

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

View File

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

View File

@ -44,6 +44,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 {
@ -108,23 +112,22 @@ class HomeActivityTests {
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
// private fun goToMyChat() {
// onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
// onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
// // new chat
// onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
// onView(withId(R.id.copyButton)).perform(ViewActions.click())
// val context = InstrumentationRegistry.getInstrumentation().targetContext
// lateinit var copied: String
// InstrumentationRegistry.getInstrumentation().runOnMainSync {
// val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
// copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
// }
// 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())
// }
/* private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
// new chat
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.copyButton)).perform(ViewActions.click())
val context = InstrumentationRegistry.getInstrumentation().targetContext
lateinit var copied: String
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
}
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() {
@ -147,44 +150,39 @@ class HomeActivityTests {
onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
}
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
// @Test
// fun testChat_withSelf() {
// setupLoggedInState()
// goToMyChat()
// TextSecurePreferences.setLinkPreviewsEnabled(context, true)
// sendMessage("howdy")
// sendMessage("test")
// // tests url rewriter doesn't crash
// sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
// sendMessage("https://www.ámazon.com")
// }
/* @Test
fun testChat_withSelf() {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
sendMessage("howdy")
sendMessage("test")
// tests url rewriter doesn't crash
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
sendMessage("https://www.ámazon.com")
}
@Test
fun testChat_displaysCorrectUrl() {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// given the link url text
val url = "https://www.ámazon.com"
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
// when the URL span is clicked
onView(withSubstring(url)).perform(ViewActions.click())
// then the URL dialog should be displayed with a known punycode url
val amazonPuny = "https://www.xn--mazon-wqa.com/"
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
}*/
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
// @Test
// fun testChat_displaysCorrectUrl() {
// setupLoggedInState()
// goToMyChat()
// TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// // given the link url text
// val url = "https://www.ámazon.com"
// sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
//
// // when the URL span is clicked
// onView(withSubstring(url)).perform(ViewActions.click())
//
// // then the URL dialog should be displayed with a known punycode url
// val amazonPuny = "https://www.xn--mazon-wqa.com/"
//
// // Substitute the URL into our string
// val c = InstrumentationRegistry.getInstrumentation().targetContext
// val dialogPromptText = Phrase.from(c, R.string.urlOpenDescription)
// .put(URL_KEY, amazonPuny)
// .format().toString()
//
// 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.

View File

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

View File

@ -1,135 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import org.thoughtcrime.securesms.MediaDocumentsAdapter.HeaderViewHolder;
import org.thoughtcrime.securesms.MediaDocumentsAdapter.ViewHolder;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.mms.DocumentSlide;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.session.libsession.utilities.Util;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import network.loki.messenger.R;
import static com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager.TAG;
public class MediaDocumentsAdapter extends CursorRecyclerViewAdapter<ViewHolder> implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder> {
private final Calendar calendar;
private final Locale locale;
MediaDocumentsAdapter(Context context, Cursor cursor, Locale locale) {
super(context, cursor);
this.calendar = Calendar.getInstance();
this.locale = locale;
}
@Override
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item, parent, false));
}
@Override
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
if (slide != null && slide.hasDocument()) {
viewHolder.documentView.setDocument((DocumentSlide)slide, false);
String relativeDate = DateUtils.INSTANCE.getRelativeDate(getContext(), locale, mediaRecord.getDate());
viewHolder.date.setText(relativeDate);
viewHolder.documentView.setVisibility(View.VISIBLE);
viewHolder.date.setVisibility(View.VISIBLE);
viewHolder.documentView.setOnClickListener(view -> {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(PartAuthority.getAttachmentPublicUri(slide.getUri()), slide.getContentType());
try {
getContext().startActivity(intent);
} catch (ActivityNotFoundException anfe) {
Log.w(TAG, "No activity existed to view the media.");
Toast.makeText(getContext(), R.string.attachmentsErrorOpen, Toast.LENGTH_LONG).show();
}
});
} else {
viewHolder.documentView.setVisibility(View.GONE);
viewHolder.date.setVisibility(View.GONE);
}
}
@Override
public long getHeaderId(int position) {
if (!isActiveCursor()) return -1;
if (isHeaderPosition(position)) return -1;
if (isFooterPosition(position)) return -1;
if (position >= getItemCount()) return -1;
if (position < 0) return -1;
Cursor cursor = getCursorAtPositionOrThrow(position);
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
calendar.setTime(new Date(mediaRecord.getDate()));
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) {
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item_header, parent, false));
}
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
Cursor cursor = getCursorAtPositionOrThrow(position);
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
viewHolder.textView.setText(DateUtils.INSTANCE.getRelativeDate(getContext(), locale, mediaRecord.getDate()));
}
public static class ViewHolder extends RecyclerView.ViewHolder {
private final DocumentView documentView;
private final TextView date;
public ViewHolder(View itemView) {
super(itemView);
this.documentView = itemView.findViewById(R.id.document_view);
this.date = itemView.findViewById(R.id.date);
}
}
static class HeaderViewHolder extends RecyclerView.ViewHolder {
private final TextView textView;
HeaderViewHolder(View itemView) {
super(itemView);
this.textView = itemView.findViewById(R.id.text);
}
}
}

View File

@ -1,173 +0,0 @@
/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
import com.bumptech.glide.RequestManager;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import network.loki.messenger.R;
class MediaGalleryAdapter extends StickyHeaderGridAdapter {
@SuppressWarnings("unused")
private static final String TAG = MediaGalleryAdapter.class.getSimpleName();
private final Context context;
private final RequestManager glideRequests;
private final Locale locale;
private final ItemClickListener itemClickListener;
private final Set<MediaRecord> selected;
private BucketedThreadMedia media;
private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder {
ThumbnailView imageView;
View selectedIndicator;
ViewHolder(View v) {
super(v);
imageView = v.findViewById(R.id.image);
selectedIndicator = v.findViewById(R.id.selected_indicator);
}
}
private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder {
TextView textView;
HeaderHolder(View itemView) {
super(itemView);
textView = itemView.findViewById(R.id.text);
}
}
MediaGalleryAdapter(@NonNull Context context,
@NonNull RequestManager glideRequests,
BucketedThreadMedia media,
Locale locale,
ItemClickListener clickListener)
{
this.context = context;
this.glideRequests = glideRequests;
this.locale = locale;
this.media = media;
this.itemClickListener = clickListener;
this.selected = new HashSet<>();
}
public void setMedia(BucketedThreadMedia media) {
this.media = media;
}
@Override
public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) {
return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item_header, parent, false));
}
@Override
public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) {
return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false));
}
@Override
public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) {
((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale));
}
@Override
public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) {
MediaRecord mediaRecord = media.get(section, offset);
ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView;
View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator;
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
if (slide != null) {
thumbnailView.setImageResource(glideRequests, slide, false);
}
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
thumbnailView.setOnLongClickListener(view -> {
itemClickListener.onMediaLongClicked(mediaRecord);
return true;
});
selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE);
}
@Override
public int getSectionCount() {
return media.getSectionCount();
}
@Override
public int getSectionItemCount(int section) {
return media.getSectionItemCount(section);
}
public void toggleSelection(@NonNull MediaRecord mediaRecord) {
if (!selected.remove(mediaRecord)) {
selected.add(mediaRecord);
}
notifyDataSetChanged();
}
public int getSelectedMediaCount() {
return selected.size();
}
@NonNull
public Collection<MediaRecord> getSelectedMedia() {
return new HashSet<>(selected);
}
public void clearSelection() {
selected.clear();
notifyDataSetChanged();
}
void selectAllMedia() {
for (int section = 0; section < media.getSectionCount(); section++) {
for (int item = 0; item < media.getSectionItemCount(section); item++) {
selected.add(media.get(section, item));
}
}
this.notifyDataSetChanged();
}
interface ItemClickListener {
void onMediaClicked(@NonNull MediaRecord mediaRecord);
void onMediaLongClicked(MediaRecord mediaRecord);
}
}

View File

@ -1,523 +0,0 @@
/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.database.Cursor;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
import com.bumptech.glide.Glide;
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
import com.google.android.material.tabs.TabLayout;
import com.squareup.phrase.Phrase;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
/**
* Activity for displaying media attachments in-app
*/
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressWarnings("unused")
private final static String TAG = MediaOverviewActivity.class.getSimpleName();
public static final String ADDRESS_EXTRA = "address";
private Toolbar toolbar;
private TabLayout tabLayout;
private ViewPager viewPager;
private Recipient recipient;
@Override
protected void onCreate(Bundle bundle, boolean ready) {
setContentView(R.layout.media_overview_activity);
initializeResources();
initializeToolbar();
this.tabLayout.setupWithViewPager(viewPager);
this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home: finish(); return true;
}
return false;
}
private void initializeResources() {
this.viewPager = ViewUtil.findById(this, R.id.pager);
this.toolbar = ViewUtil.findById(this, R.id.search_toolbar);
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
if (address == null) {
Log.w(TAG, "Got null address in initializeResources.");
} else {
this.recipient = Recipient.from(this, address, true);
}
}
private void initializeToolbar() {
setSupportActionBar(this.toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar == null) {
Log.w(TAG, "Could not get support actionbar");
return;
}
// Implied else that the actionbar is fine to work with...
actionBar.setTitle(recipient.toShortString());
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setHomeButtonEnabled(true);
this.recipient.addListener(recipient -> {
Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
});
}
public void onEnterMultiSelect() {
tabLayout.setEnabled(false);
viewPager.setEnabled(false);
}
public void onExitMultiSelect() {
tabLayout.setEnabled(true);
viewPager.setEnabled(true);
}
private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
super(fragmentManager);
}
@Override
public Fragment getItem(int position) {
Fragment fragment;
if (position == 0) fragment = new MediaOverviewGalleryFragment();
else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
else throw new AssertionError();
Bundle args = new Bundle();
args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
fragment.setArguments(args);
return fragment;
}
@Override
public int getCount() {
return 2;
}
@Override
public CharSequence getPageTitle(int position) {
if (position == 0) return getString(R.string.media);
else if (position == 1) return getString(R.string.files);
else throw new AssertionError();
}
}
public static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
public static final String ADDRESS_EXTRA = "address";
public static final String LOCALE_EXTRA = "locale_extra";
protected TextView noMedia;
protected Recipient recipient;
protected RecyclerView recyclerView;
protected Locale locale;
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
String address = getArguments().getString(ADDRESS_EXTRA);
Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA);
if (address == null) throw new AssertionError();
if (locale == null) throw new AssertionError();
this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
this.locale = locale;
getLoaderManager().initLoader(0, null, this);
}
}
public static class MediaOverviewGalleryFragment
extends MediaOverviewFragment<BucketedThreadMedia>
implements MediaGalleryAdapter.ItemClickListener
{
private StickyHeaderGridLayoutManager gridManager;
private ActionMode actionMode;
private ActionModeCallback actionModeCallback = new ActionModeCallback();
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
this.noMedia = ViewUtil.findById(view, R.id.no_images);
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(),
Glide.with(this),
new BucketedThreadMedia(getContext()),
locale,
this));
this.recyclerView.setLayoutManager(gridManager);
this.recyclerView.setHasFixedSize(true);
return view;
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (gridManager != null) {
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
this.recyclerView.setLayoutManager(gridManager);
}
}
@Override
public @NonNull Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) {
return new BucketedThreadMediaLoader(getContext(), recipient.getAddress());
}
@Override
public void onLoadFinished(@NonNull Loader<BucketedThreadMedia> loader, BucketedThreadMedia bucketedThreadMedia) {
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia);
((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
getActivity().invalidateOptionsMenu();
}
@Override
public void onLoaderReset(@NonNull Loader<BucketedThreadMedia> cursorLoader) {
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
}
@Override
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
if (actionMode != null) {
handleMediaMultiSelectClick(mediaRecord);
} else {
handleMediaPreviewClick(mediaRecord);
}
}
private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
MediaGalleryAdapter adapter = getListAdapter();
adapter.toggleSelection(mediaRecord);
if (adapter.getSelectedMediaCount() == 0) {
actionMode.finish();
} else {
actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
}
}
private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
if (mediaRecord.getAttachment().getDataUri() == null) {
return;
}
Context context = getContext();
if (context == null) {
return;
}
Intent intent = new Intent(context, MediaPreviewActivity.class);
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
context.startActivity(intent);
}
@Override
public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
if (actionMode == null) {
((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
recyclerView.getAdapter().notifyDataSetChanged();
enterMultiSelect();
}
}
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
final Context context = requireContext();
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
.withPermanentDenialDialog(Phrase.from(context, R.string.permissionsStorageSaveDenied)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString())
.onAnyDenied(() -> Toast.makeText(getContext(),
Phrase.from(context, R.string.permissionsStorageSaveDenied)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString(),
Toast.LENGTH_LONG).show())
.onAllGranted(() -> {
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
context,
R.string.attachmentsCollecting,
R.string.waitOneMoment) {
@Override
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
if (mediaRecord.getAttachment().getDataUri() != null) {
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
mediaRecord.getContentType(),
mediaRecord.getDate(),
mediaRecord.getAttachment().getFileName()));
}
}
return attachments;
}
@Override
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
super.onPostExecute(attachments);
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
actionMode.finish();
boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
if (containsIncoming) {
sendMediaSavedNotificationIfNeeded();
}
}
}.execute();
})
.execute();
return Unit.INSTANCE;
});
}
private void sendMediaSavedNotificationIfNeeded() {
if (recipient.isGroupRecipient()) return;
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
MessageSender.send(message, recipient.getAddress());
}
@SuppressLint("StaticFieldLeak")
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size();
DeleteMediaDialog.show(
requireContext(),
recordCount,
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
requireContext(),
R.string.deleting,
R.string.deleting) {
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
}
private void handleSelectAllMedia() {
getListAdapter().selectAllMedia();
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
}
private MediaGalleryAdapter getListAdapter() {
return (MediaGalleryAdapter) recyclerView.getAdapter();
}
private void enterMultiSelect() {
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
}
private class ActionModeCallback implements ActionMode.Callback {
private int originalStatusBarColor;
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
mode.setTitle("1");
FragmentActivity activity = getActivity();
if (activity == null) return false;
Window window = activity.getWindow();
originalStatusBarColor = window.getStatusBarColor();
window.setStatusBarColor(ContextCompat.getColor(activity, R.color.action_mode_status_bar));
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
switch (menuItem.getItemId()) {
case R.id.save:
handleSaveMedia(getListAdapter().getSelectedMedia());
return true;
case R.id.delete:
handleDeleteMedia(getListAdapter().getSelectedMedia());
actionMode.finish();
return true;
case R.id.select_all:
handleSelectAllMedia();
return true;
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
actionMode = null;
getListAdapter().clearSelection();
MediaOverviewActivity activity = ((MediaOverviewActivity) getActivity());
if(activity == null) return;
activity.onExitMultiSelect();
activity.getWindow().setStatusBarColor(originalStatusBarColor);
}
}
}
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
this.noMedia = ViewUtil.findById(view, R.id.no_documents);
this.recyclerView.setAdapter(adapter);
this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true));
this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
return view;
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
getActivity().invalidateOptionsMenu();
this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
getActivity().invalidateOptionsMenu();
}
}
}

View File

@ -76,6 +76,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;
@ -387,9 +388,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
private void showOverview() {
Intent intent = new Intent(this, MediaOverviewActivity.class);
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, conversationRecipient.getAddress());
startActivity(intent);
startActivity(MediaOverviewActivity.createIntent(this, conversationRecipient.getAddress()));
}
private void forward() {

View File

@ -29,9 +29,9 @@ 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.StringSubstitutionConstants.APP_NAME_KEY
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
@ -197,13 +197,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))
}
@ -340,8 +335,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)
@ -349,11 +342,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)
}
}
}

View File

@ -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<View, Recipient>()
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
}
}
}

View File

@ -55,7 +55,7 @@ class UserView : LinearLayout {
}
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) {
@ -86,7 +86,6 @@ class UserView : LinearLayout {
}
}
fun unbind() { binding.profilePictureView.recycle() }
fun unbind() { /* Nothing to do */ }
// endregion
}

View File

@ -85,8 +85,9 @@ 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.noteToSelf)
updateSubtitle(recipient, openGroup, config)
binding.conversationTitleContainer.modifyLayoutParams<MarginLayoutParams> {

View File

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

View File

@ -8,10 +8,12 @@ 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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -19,7 +21,8 @@ import androidx.compose.ui.tooling.preview.Preview
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
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
@ -29,6 +32,7 @@ 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,
@ -41,10 +45,11 @@ internal fun InviteFriend(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(
stringResource(R.string.sessionInviteAFriend),
BackAppBar(
title = stringResource(R.string.sessionInviteAFriend),
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
onBack = onBack,
onClose = onClose
actions = { AppBarCloseIcon(onClose = onClose) }
)
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)

View File

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

View File

@ -31,6 +31,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

View File

@ -1,87 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import org.session.libsession.messaging.mentions.Mention
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.util.toPx
class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
private var mentionCandidates = listOf<Mention>()
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue }
var glide: RequestManager? = null
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue }
var openGroupServer: String? = null
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupServer = openGroupServer }
var openGroupRoom: String? = null
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupRoom = openGroupRoom }
var onMentionCandidateSelected: ((Mention) -> Unit)? = null
private val mentionCandidateSelectionViewAdapter by lazy { Adapter(context) }
private class Adapter(private val context: Context) : BaseAdapter() {
var mentionCandidates = listOf<Mention>()
set(newValue) { field = newValue; notifyDataSetChanged() }
var glide: RequestManager? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
override fun getCount(): Int {
return mentionCandidates.count()
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getItem(position: Int): Mention {
return mentionCandidates[position]
}
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context)
val mentionCandidate = getItem(position)
cell.glide = glide
cell.mentionCandidate = mentionCandidate
cell.openGroupServer = openGroupServer
cell.openGroupRoom = openGroupRoom
return cell
}
}
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context) : this(context, null)
init {
clipToOutline = true
adapter = mentionCandidateSelectionViewAdapter
mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates
setOnItemClickListener { _, _, position, _ ->
onMentionCandidateSelected?.invoke(mentionCandidates[position])
}
}
fun show(mentionCandidates: List<Mention>, threadID: Long) {
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
if (openGroup != null) {
openGroupServer = openGroup.server
openGroupRoom = openGroup.room
}
this.mentionCandidates = mentionCandidates
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources)
this.layoutParams = layoutParams
}
fun hide() {
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = 0
this.layoutParams = layoutParams
}
}

View File

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

View File

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

View File

@ -27,7 +27,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
@ -151,10 +151,8 @@ object ConversationMenuHelper {
}
private fun showAllMedia(context: Context, thread: Recipient) {
val intent = Intent(context, MediaOverviewActivity::class.java)
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, thread.address)
val activity = context as AppCompatActivity
activity.startActivity(intent)
activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address))
}
private fun search(context: Context) {

View File

@ -25,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 java.util.Date
import java.util.Locale
@ -58,8 +60,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
@ -178,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)
@ -464,7 +463,6 @@ class VisibleMessageView : FrameLayout {
}
fun recycle() {
binding.profilePictureView.recycle()
binding.messageContentView.root.recycle()
}

View File

@ -13,14 +13,14 @@ import org.session.libsession.avatars.ContactPhoto;
import java.io.IOException;
import java.io.InputStream;
class ContactPhotoFetcher implements DataFetcher<InputStream> {
public class ContactPhotoFetcher implements DataFetcher<InputStream> {
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;
}

View File

@ -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<BitmapDrawable> {
class PlaceholderAvatarFetcher(
private val context: Context,
private val hashString: String,
private val displayName: String
): DataFetcher<BitmapDrawable> {
override fun loadData(priority: Priority,callback: DataFetcher.DataCallback<in BitmapDrawable>) {
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")

View File

@ -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<PlaceholderAvatarPhoto, BitmapDrawable> {
@ -17,7 +20,15 @@ class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader<Plac
height: Int,
options: Options
): LoadData<BitmapDrawable> {
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

View File

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

View File

@ -478,8 +478,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
}
@ -519,10 +518,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

View File

@ -273,8 +273,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()

View File

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

View File

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

View File

@ -92,7 +92,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)
@ -110,7 +110,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.noteToSelf)
else model.contact.getSearchName()
searchResultTitle.text = getHighlight(query, nameString)
@ -120,7 +120,7 @@ fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.noteToSelf)
binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey))
binding.searchResultProfilePicture.load(Address.fromSerialized(model.currentUserPublicKey))
binding.searchResultProfilePicture.isVisible = true
}
@ -135,7 +135,7 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
// }
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

View File

@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.media
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
@Composable
fun AttachmentHeader(
text: String,
modifier: Modifier = Modifier
){
Text(
modifier = modifier
.background(LocalColors.current.background)
.fillMaxWidth()
.padding(
horizontal = LocalDimensions.current.smallSpacing,
vertical = LocalDimensions.current.xsSpacing
),
text = text,
style = LocalType.current.xl,
color = LocalColors.current.text
)
}

View File

@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.media
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.session.libsession.utilities.Util
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DocumentsPage(
nestedScrollConnection: NestedScrollConnection,
content: TabContent?,
onItemClicked: (MediaOverviewItem) -> Unit,
) {
when {
content == null -> {
// Loading
}
content.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.attachmentsFilesEmpty),
style = LocalType.current.base,
color = LocalColors.current.text
)
}
}
else -> {
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing)
) {
for ((bucketTitle, files) in content) {
stickyHeader {
AttachmentHeader(text = bucketTitle)
}
items(files) { file ->
Row(
modifier = Modifier
.clickable(onClick = { onItemClicked(file) })
.padding(LocalDimensions.current.smallSpacing),
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painterResource(R.drawable.ic_document_large_dark),
contentDescription = null
)
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) {
Text(
text = file.fileName.orEmpty(),
style = LocalType.current.large,
color = LocalColors.current.text
)
Row(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.weight(1f),
text = Util.getPrettyFileSize(file.fileSize),
style = LocalType.current.small,
color = LocalColors.current.textSecondary,
textAlign = TextAlign.Start,
)
Text(
text = file.date,
style = LocalType.current.small,
color = LocalColors.current.textSecondary,
textAlign = TextAlign.End,
)
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.media
import androidx.annotation.StringRes
import network.loki.messenger.R
import java.time.ZonedDateTime
import java.time.temporal.WeekFields
import java.util.Locale
/**
* A data structure that describes a series of time points in the past. It's primarily
* used to bucket items into categories like "Today", "Yesterday", "This week", "This month", etc.
*
* Call [getBucketText] to get the appropriate string resource for a given time. If no bucket is
* appropriate, it will return null.
*/
class FixedTimeBuckets(
private val startOfToday: ZonedDateTime,
private val startOfYesterday: ZonedDateTime,
private val startOfThisWeek: ZonedDateTime,
private val startOfThisMonth: ZonedDateTime
) {
constructor(now: ZonedDateTime = ZonedDateTime.now()) : this(
startOfToday = now.toLocalDate().atStartOfDay(now.zone),
startOfYesterday = now.toLocalDate().minusDays(1).atStartOfDay(now.zone),
startOfThisWeek = now.toLocalDate()
.with(WeekFields.of(Locale.getDefault()).dayOfWeek(), 1)
.atStartOfDay(now.zone),
startOfThisMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay(now.zone)
)
/**
* Test the given time against the buckets and return the appropriate string resource the time
* bucket. If no bucket is appropriate, it will return null.
*/
@StringRes
fun getBucketText(time: ZonedDateTime): Int? {
return when {
time >= startOfToday -> R.string.BucketedThreadMedia_Today // Should be replaced with call to getLocalisedRelativeDayString
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday // Should be replaced with call to getLocalisedRelativeDayString
time >= startOfThisWeek -> R.string.attachmentsThisWeek
time >= startOfThisMonth -> R.string.attachmentsThisMonth
else -> null
}
}
}

View File

@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.media
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.content.IntentCompat
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.Address
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.ui.setComposeContent
import javax.inject.Inject
@AndroidEntryPoint
class MediaOverviewActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var viewModelFactory: MediaOverviewViewModel.AssistedFactory
private val viewModel: MediaOverviewViewModel by viewModels {
viewModelFactory.create(IntentCompat.getParcelableExtra(intent, EXTRA_ADDRESS, Address::class.java)!!)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setComposeContent {
MediaOverviewScreen(viewModel, onClose = this::finish)
}
supportActionBar?.hide()
}
companion object {
private const val EXTRA_ADDRESS = "address"
@JvmStatic
fun createIntent(context: Context, address: Address): Intent {
return Intent(context, MediaOverviewActivity::class.java).apply {
putExtra(EXTRA_ADDRESS, address)
}
}
}
}

View File

@ -0,0 +1,296 @@
package org.thoughtcrime.securesms.media
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.content.ActivityNotFoundException
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
@OptIn(
ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class,
)
@Composable
fun MediaOverviewScreen(
viewModel: MediaOverviewViewModel,
onClose: () -> Unit,
) {
val selectedItems by viewModel.selectedItemIDs.collectAsState()
val selectionMode by viewModel.inSelectionMode.collectAsState()
val topAppBarState = rememberTopAppBarState()
var showingDeleteConfirmation by remember { mutableStateOf(false) }
var showingSaveAttachmentWarning by remember { mutableStateOf(false) }
val context = LocalContext.current
val requestStoragePermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
viewModel.onSaveClicked()
} else {
Toast.makeText(
context,
R.string.cameraGrantAccessDenied,
Toast.LENGTH_LONG
).show()
}
}
// In selection mode, the app bar should not be scrollable and should be pinned
val appBarScrollBehavior = if (selectionMode) {
TopAppBarDefaults.pinnedScrollBehavior(topAppBarState, canScroll = { false })
} else {
TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
}
// Reset the top app bar offset (so that it shows up) when entering selection mode
LaunchedEffect(selectionMode) {
if (selectionMode) {
topAppBarState.heightOffset = 0f
}
}
BackHandler(onBack = viewModel::onBackClicked)
// Event handling
LaunchedEffect(viewModel.events) {
viewModel.events.collect { event ->
when (event) {
MediaOverviewEvent.Close -> onClose()
is MediaOverviewEvent.NavigateToActivity -> {
try {
context.startActivity(event.intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
R.string.attachmentsErrorOpen,
Toast.LENGTH_LONG
).show()
}
}
is MediaOverviewEvent.ShowSaveAttachmentError -> {
Toast.makeText(context, R.string.attachmentsSaveError, Toast.LENGTH_LONG).show()
}
is MediaOverviewEvent.ShowSaveAttachmentSuccess -> {
Toast.makeText(context, R.string.saved, Toast.LENGTH_LONG).show()
}
}
}
}
Scaffold(
modifier = Modifier.nestedScroll(appBarScrollBehavior.nestedScrollConnection),
topBar = {
MediaOverviewTopAppBar(
selectionMode = selectionMode,
title = viewModel.title.collectAsState().value,
onBackClicked = viewModel::onBackClicked,
onSaveClicked = { showingSaveAttachmentWarning = true },
onDeleteClicked = { showingDeleteConfirmation = true },
onSelectAllClicked = viewModel::onSelectAllClicked,
appBarScrollBehavior = appBarScrollBehavior
)
}
) { paddings ->
Column(
modifier = Modifier
.padding(paddings)
.fillMaxSize()
) {
val pagerState = rememberPagerState(pageCount = { MediaOverviewTab.entries.size })
val selectedTab by viewModel.selectedTab.collectAsState()
// Apply "selectedTab" view model state to pager
LaunchedEffect(selectedTab) {
pagerState.animateScrollToPage(selectedTab.ordinal)
}
// Apply "selectedTab" pager state to view model
LaunchedEffect(pagerState.currentPage) {
viewModel.onTabItemClicked(MediaOverviewTab.entries[pagerState.currentPage])
}
SessionTabRow(
pagerState = pagerState,
titles = MediaOverviewTab.entries.map { it.titleResId }
)
val content = viewModel.mediaListState.collectAsState()
val canLongPress = viewModel.canLongPress.collectAsState().value
HorizontalPager(
pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { index ->
when (MediaOverviewTab.entries[index]) {
MediaOverviewTab.Media -> {
val haptics = LocalHapticFeedback.current
MediaPage(
content = content.value?.mediaContent,
selectedItemIDs = selectedItems,
onItemClicked = viewModel::onItemClicked,
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
onItemLongClicked = if(canLongPress){{
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.onItemLongClicked(it)
}} else null
)
}
MediaOverviewTab.Documents -> DocumentsPage(
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
content = content.value?.documentContent,
onItemClicked = viewModel::onItemClicked
)
}
}
}
}
if (showingDeleteConfirmation) {
DeleteConfirmationDialog(
onDismissRequest = { showingDeleteConfirmation = false },
onAccepted = viewModel::onDeleteClicked,
numSelected = selectedItems.size
)
}
if (showingSaveAttachmentWarning) {
SaveAttachmentWarningDialog(
onDismissRequest = { showingSaveAttachmentWarning = false },
onAccepted = {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
requestStoragePermission.launch(WRITE_EXTERNAL_STORAGE)
} else {
viewModel.onSaveClicked()
}
},
numSelected = selectedItems.size
)
}
val showingActionDialog = viewModel.showingActionProgress.collectAsState().value
if (showingActionDialog != null) {
ActionProgressDialog(showingActionDialog)
}
}
@Composable
private fun SaveAttachmentWarningDialog(
onDismissRequest: () -> Unit,
onAccepted: () -> Unit,
numSelected: Int,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismissRequest,
title = context.getString(R.string.permissionsRequired),
text = context.resources.getString(R.string.attachmentsWarning),
buttons = listOf(
DialogButtonModel(GetString(R.string.save), onClick = onAccepted),
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
)
)
}
@Composable
private fun DeleteConfirmationDialog(
onDismissRequest: () -> Unit,
onAccepted: () -> Unit,
numSelected: Int,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismissRequest,
title = context.resources.getQuantityString(
R.plurals.ConversationFragment_delete_selected_messages, numSelected
),
text = context.resources.getQuantityString(
R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages,
numSelected,
numSelected,
),
buttons = listOf(
DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted),
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
)
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ActionProgressDialog(
text: String
) {
BasicAlertDialog(
onDismissRequest = {},
) {
Row(
modifier = Modifier
.background(LocalColors.current.background, shape = MaterialTheme.shapes.medium)
.padding(LocalDimensions.current.mediumSpacing),
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(color = LocalColors.current.primary)
Text(
text,
style = LocalType.current.large,
color = LocalColors.current.text
)
}
}
}
private val MediaOverviewTab.titleResId: Int
get() = when (this) {
MediaOverviewTab.Media -> R.string.media
MediaOverviewTab.Documents -> R.string.document
}

View File

@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.media
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.components.ActionAppBar
import org.thoughtcrime.securesms.ui.components.AppBarBackIcon
import org.thoughtcrime.securesms.ui.theme.LocalColors
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun MediaOverviewTopAppBar(
selectionMode: Boolean,
title: String,
onBackClicked: () -> Unit,
onSaveClicked: () -> Unit,
onDeleteClicked: () -> Unit,
onSelectAllClicked: () -> Unit,
appBarScrollBehavior: TopAppBarScrollBehavior
) {
ActionAppBar(
title = title,
navigationIcon = {AppBarBackIcon(onBack = onBackClicked)},
scrollBehavior = appBarScrollBehavior,
actionMode = selectionMode,
actionModeActions = {
IconButton(onClick = onSaveClicked) {
Icon(
painterResource(R.drawable.ic_baseline_save_24),
contentDescription = stringResource(R.string.save),
tint = LocalColors.current.text,
)
}
IconButton(onClick = onDeleteClicked) {
Icon(
painterResource(R.drawable.ic_baseline_delete_24),
contentDescription = stringResource(R.string.delete),
tint = LocalColors.current.text,
)
}
IconButton(onClick = onSelectAllClicked) {
Icon(
painterResource(R.drawable.ic_baseline_select_all_24),
contentDescription = stringResource(R.string.selectAll),
tint = LocalColors.current.text,
)
}
}
)
}

View File

@ -0,0 +1,427 @@
package org.thoughtcrime.securesms.media
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MediaPreviewActivity
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.asSequence
import org.thoughtcrime.securesms.util.observeChanges
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
class MediaOverviewViewModel(
private val address: Address,
private val application: Application,
private val threadDatabase: ThreadDatabase,
private val mediaDatabase: MediaDatabase
) : AndroidViewModel(application) {
private val timeBuckets by lazy { FixedTimeBuckets() }
private val monthTimeBucketFormatter =
DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
private val recipient: SharedFlow<Recipient> = application.contentResolver
.observeChanges(DatabaseContentProviders.Attachment.CONTENT_URI)
.onStart { emit(DatabaseContentProviders.Attachment.CONTENT_URI) }
.map { Recipient.from(application, address, false) }
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val title: StateFlow<String> = recipient
.map { it.toShortString() }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
val mediaListState: StateFlow<MediaOverviewContent?> = recipient
.map { recipient ->
withContext(Dispatchers.Default) {
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
val mediaItems = mediaDatabase.getGalleryMediaForThread(threadId)
.use { cursor ->
cursor.asSequence()
.map { MediaRecord.from(application, it) }
.groupRecordsByTimeBuckets()
}
val documentItems = mediaDatabase.getDocumentMediaForThread(threadId)
.use { cursor ->
cursor.asSequence()
.map { MediaRecord.from(application, it) }
.groupRecordsByRelativeTime()
}
MediaOverviewContent(
mediaContent = mediaItems,
documentContent = documentItems,
)
}
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val mutableSelectedItemIDs = MutableStateFlow(emptySet<Long>())
val selectedItemIDs: StateFlow<Set<Long>> get() = mutableSelectedItemIDs
val inSelectionMode: StateFlow<Boolean> = selectedItemIDs
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.Eagerly, mutableSelectedItemIDs.value.isNotEmpty())
val canLongPress: StateFlow<Boolean> = inSelectionMode
.map { !it }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
private val mutableEvents = MutableSharedFlow<MediaOverviewEvent>()
val events get() = mutableEvents
private val mutableSelectedTab = MutableStateFlow(MediaOverviewTab.Media)
val selectedTab: StateFlow<MediaOverviewTab> get() = mutableSelectedTab
private val mutableShowingActionProgress = MutableStateFlow<String?>(null)
val showingActionProgress: StateFlow<String?> get() = mutableShowingActionProgress
private val selectedMedia: Sequence<MediaOverviewItem>
get() {
val selected = selectedItemIDs.value
return mediaListState.value
?.mediaContent
?.asSequence()
.orEmpty()
.flatMap { it.second.asSequence() }
.filter { it.id in selected }
}
private fun Sequence<MediaRecord>.groupRecordsByTimeBuckets(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
return this
.groupBy { record ->
val time =
ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC"))
timeBuckets.getBucketText(time)?.let(application::getString)
?: time.toLocalDate().withDayOfMonth(1)
}
.map { (bucket, records) ->
val bucketTitle = when (bucket) {
is String -> bucket
is LocalDate -> bucket.format(monthTimeBucketFormatter)
else -> error("Invalid bucket type: $bucket")
}
bucketTitle to records.map { record ->
MediaOverviewItem(
id = record.attachment.attachmentId.rowId,
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
mediaRecord = record,
date = bucketTitle
)
}
}
}
private fun Sequence<MediaRecord>.groupRecordsByRelativeTime(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
return this
.groupBy { record ->
DateUtils.getRelativeDate(application, Locale.getDefault(), record.date)
}
.map { (bucket, records) ->
bucket to records.map { record ->
MediaOverviewItem(
id = record.attachment.attachmentId.rowId,
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
mediaRecord = record,
date = bucket
)
}
}
}
fun onItemClicked(item: MediaOverviewItem) {
if (inSelectionMode.value) {
val newSet = mutableSelectedItemIDs.value.toMutableSet()
if (item.id in newSet) {
newSet.remove(item.id)
} else {
newSet.add(item.id)
}
mutableSelectedItemIDs.value = newSet
} else if (!item.slide.hasDocument()) {
val mediaRecord = item.mediaRecord
// The item clicked is a media item, so we should open the media viewer
val intent = Intent(application, MediaPreviewActivity::class.java)
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.date)
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.attachment.size)
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, address)
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing)
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true)
intent.setDataAndType(
mediaRecord.attachment.dataUri,
mediaRecord.contentType
)
viewModelScope.launch {
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
}
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(
PartAuthority.getAttachmentPublicUri(item.slide.uri),
item.slide.contentType
)
viewModelScope.launch {
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
}
}
}
fun onTabItemClicked(tab: MediaOverviewTab) {
if (inSelectionMode.value) {
// Not allowing to switch tabs while in selection mode
return
}
mutableSelectedTab.value = tab
}
fun onItemLongClicked(id: Long) {
mutableSelectedItemIDs.value = setOf(id)
}
fun onSaveClicked() {
if (!inSelectionMode.value) {
// Not in selection mode, so we should not be able to save
return
}
viewModelScope.launch {
val selectedMedia = selectedMedia.toList()
mutableShowingActionProgress.value = application.resources.getString(R.string.saving)
val attachments = selectedMedia
.asSequence()
.mapNotNull {
val uri = it.mediaRecord.attachment.dataUri ?: return@mapNotNull null
SaveAttachmentTask.Attachment(
uri = uri,
contentType = it.mediaRecord.contentType,
date = it.mediaRecord.date,
fileName = it.mediaRecord.attachment.fileName,
)
}
var savedDirectory: String? = null
var successCount = 0
var errorCount = 0
for (attachment in attachments) {
val directory = withContext(Dispatchers.Default) {
kotlin.runCatching {
SaveAttachmentTask.saveAttachment(application, attachment)
}.getOrNull()
}
if (directory == null) {
errorCount += 1
} else {
savedDirectory = directory
successCount += 1
}
}
if (successCount > 0) {
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentSuccess(
savedDirectory.orEmpty(),
successCount
))
} else if (errorCount > 0) {
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentError(errorCount))
}
// Send a notification of attachment saved if we are in a 1to1 chat and the
// attachments saved are from the other party (a.k.a let other person know
// that you saved their attachments, but don't need to let the whole world know as
// in groups/communities)
if (selectedMedia.any { !it.mediaRecord.isOutgoing } &&
successCount > 0 &&
!address.isGroup) {
withContext(Dispatchers.Default) {
val timestamp = SnodeAPI.nowWithOffset
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
val message = DataExtractionNotification(kind)
MessageSender.send(message, address)
}
}
mutableShowingActionProgress.value = null
mutableSelectedItemIDs.value = emptySet()
}
}
fun onDeleteClicked() {
if (!inSelectionMode.value) {
// Not in selection mode, so we should not be able to delete
return
}
viewModelScope.launch {
mutableShowingActionProgress.value = application.getString(R.string.deleting)
// Delete the selected media items, and retrieve the thread ID for the address if any
val threadId = withContext(Dispatchers.Default) {
for (media in selectedMedia) {
kotlin.runCatching {
AttachmentUtil.deleteAttachment(application, media.mediaRecord.attachment)
}
}
threadDatabase.getThreadIdIfExistsFor(address.serialize())
}
// Notify the content provider that the thread has been updated
if (threadId >= 0) {
application.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null)
}
mutableShowingActionProgress.value = null
mutableSelectedItemIDs.value = emptySet()
}
}
fun onSelectAllClicked() {
if (!inSelectionMode.value) {
// Not in selection mode, so we should not be able to select all
return
}
val allItems = mediaListState.value?.let { content ->
when (selectedTab.value) {
MediaOverviewTab.Media -> content.mediaContent
MediaOverviewTab.Documents -> content.documentContent
}
} ?: return
mutableSelectedItemIDs.value = allItems
.asSequence()
.flatMap { it.second }
.mapTo(hashSetOf()) { it.id }
}
fun onBackClicked() {
if (inSelectionMode.value) {
// Clear selection mode by clear selecting items
mutableSelectedItemIDs.value = emptySet()
} else {
viewModelScope.launch {
mutableEvents.emit(MediaOverviewEvent.Close)
}
}
}
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(address: Address): Factory
}
class Factory @AssistedInject constructor(
@Assisted private val address: Address,
private val application: Application,
private val threadDatabase: ThreadDatabase,
private val mediaDatabase: MediaDatabase
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = MediaOverviewViewModel(
address,
application,
threadDatabase,
mediaDatabase
) as T
}
}
enum class MediaOverviewTab {
Media,
Documents,
}
sealed interface MediaOverviewEvent {
data object Close : MediaOverviewEvent
data class ShowSaveAttachmentError(val errorCount: Int) : MediaOverviewEvent
data class ShowSaveAttachmentSuccess(val directory: String, val successCount: Int) : MediaOverviewEvent
data class NavigateToActivity(val intent: Intent) : MediaOverviewEvent
}
typealias BucketTitle = String
typealias TabContent = List<Pair<BucketTitle, List<MediaOverviewItem>>>
data class MediaOverviewContent(
val mediaContent: TabContent,
val documentContent: TabContent
)
data class MediaOverviewItem(
val id: Long,
val slide: Slide,
val date: String,
val mediaRecord: MediaRecord,
) {
val showPlayOverlay: Boolean
get() = slide.hasPlayOverlay()
val thumbnailUri: Uri?
get() = slide.thumbnailUri
val hasPlaceholder: Boolean
get() = slide.hasPlaceholder()
val fileName: String?
get() = slide.fileName.orNull()
val fileSize: Long
get() = slide.fileSize
fun placeholder(context: Context): Int {
return slide.getPlaceholderRes(context.theme)
}
}

View File

@ -0,0 +1,207 @@
package org.thoughtcrime.securesms.media
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
import kotlin.math.ceil
private val MEDIA_SPACING = 2.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MediaPage(
nestedScrollConnection: NestedScrollConnection,
content: TabContent?,
selectedItemIDs: Set<Long>,
onItemClicked: (MediaOverviewItem) -> Unit,
onItemLongClicked: ((Long) -> Unit)?,
) {
val columnCount = LocalContext.current.resources.getInteger(R.integer.media_overview_cols)
Crossfade(content, label = "Media content animation") { state ->
when {
state == null -> {
// Loading state
}
state.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.attachmentsMediaEmpty),
style = LocalType.current.base,
color = LocalColors.current.text
)
}
}
else -> {
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.fillMaxSize()
.padding(MEDIA_SPACING),
verticalArrangement = Arrangement.spacedBy(MEDIA_SPACING)
) {
for ((header, thumbnails) in state) {
stickyHeader {
AttachmentHeader(text = header)
}
val numRows = ceil(thumbnails.size / columnCount.toFloat()).toInt()
// Row of thumbnails
items(numRows) { rowIndex ->
ThumbnailRow(
columnCount = columnCount,
thumbnails = thumbnails,
rowIndex = rowIndex,
onItemClicked = onItemClicked,
onItemLongClicked = onItemLongClicked,
selectedItemIDs = selectedItemIDs
)
}
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class)
private fun ThumbnailRow(
columnCount: Int,
thumbnails: List<MediaOverviewItem>,
rowIndex: Int,
onItemClicked: (MediaOverviewItem) -> Unit,
onItemLongClicked: ((Long) -> Unit)?,
selectedItemIDs: Set<Long>
) {
Row(horizontalArrangement = Arrangement.spacedBy(MEDIA_SPACING)) {
repeat(columnCount) { columnIndex ->
val item = thumbnails.getOrNull(rowIndex * columnCount + columnIndex)
if (item != null) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.let {
when {
onItemLongClicked != null -> {
it.combinedClickable(
onClick = { onItemClicked(item) },
onLongClick = { onItemLongClicked(item.id) }
)
}
else -> {
it.clickable { onItemClicked(item) }
}
}
},
contentAlignment = Alignment.Center
) {
val uri = item.thumbnailUri
if (uri != null) {
GlideImage(
DecryptableStreamUriLoader.DecryptableUri(uri),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null,
transition = CrossFade,
) {
it.diskCacheStrategy(DiskCacheStrategy.NONE)
}
} else if (item.hasPlaceholder) {
Image(
painter = painterResource(item.placeholder(LocalContext.current)),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Inside
)
}
when {
item.showPlayOverlay -> {
// The code below is translated from thumbnail_view.xml:
// Trying to show a green play button on a white background.
Box(
modifier = Modifier
.size(48.dp)
.background(Color.White, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.padding(start = LocalDimensions.current.xxxsSpacing),
painter = painterResource(R.drawable.triangle_right),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalColors.current.primary)
)
}
}
}
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = item.id in selectedItemIDs,
label = "Showing selected state"
) { selected ->
if (selected) {
Image(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)),
contentScale = ContentScale.Inside,
painter = painterResource(R.drawable.ic_check_white_48dp),
contentDescription = stringResource(R.string.AccessibilityId_select),
)
}
}
}
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -170,7 +170,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
super.onStart()
binding.run {
setupProfilePictureView(profilePictureView)
loadProfilePicture(profilePictureView)
profilePictureView.setOnClickListener { showEditProfilePictureUI() }
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
btnGroupNameDisplay.text = getDisplayName()
@ -190,12 +190,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) {
@ -336,9 +333,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
@ -417,7 +412,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
cancelButton()
}.apply {
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
?.also(::setupProfilePictureView)
?.also(::loadProfilePicture)
val pictureIcon = findViewById<View>(R.id.ic_pictures)

View File

@ -152,7 +152,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
callback.onRemoveReaction(reaction.getBaseEmoji(), messageId, reaction.getTimestamp());
});
this.avatar.update(reaction.getSender());
this.avatar.load(reaction.getSender());
if (reaction.getSender().isLocalNumber()) {
this.recipient.setText(R.string.you);
@ -170,7 +170,6 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
}
void unbind() {
avatar.recycle();
}
}

View File

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

View File

@ -11,7 +11,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -361,7 +360,7 @@ fun RowScope.Avatar(recipient: Recipient) {
) {
AndroidView(
factory = {
ProfilePictureView(it).apply { update(recipient) }
ProfilePictureView(it).apply { load(recipient) }
},
modifier = Modifier
.width(46.dp)

View File

@ -1,53 +1,176 @@
package org.thoughtcrime.securesms.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.theme.ThemeColors
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun AppBarPreview(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
AppBar(title = "Title", {}, {})
Column() {
BasicAppBar(title = "Basic App Bar")
Divider()
BasicAppBar(title = "Basic App Bar With Color", backgroundColor = LocalColors.current.backgroundSecondary)
Divider()
BackAppBar(title = "Back Bar", onBack = {})
Divider()
ActionAppBar(
title = "Action mode",
actionMode = true,
actionModeActions = {
IconButton(onClick = {}) {
Icon(
painter = painterResource(id = R.drawable.check),
contentDescription = "check"
)
}
})
}
}
}
/**
* Basic structure for an app bar.
* It can be passed navigation content and actions
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BasicAppBar(
title: String,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
backgroundColor: Color = LocalColors.current.background,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
){
CenterAlignedTopAppBar(
modifier = modifier,
title = {
AppBarText(title = title)
},
colors = appBarColors(backgroundColor),
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior
)
}
/**
* Common use case of an app bar with a back button
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackAppBar(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
backgroundColor: Color = LocalColors.current.background,
actions: @Composable RowScope.() -> Unit = {},
){
BasicAppBar(
modifier = modifier,
title = title,
navigationIcon = {
AppBarBackIcon(onBack = onBack)
},
actions = actions,
scrollBehavior = scrollBehavior,
backgroundColor = backgroundColor
)
}
@ExperimentalMaterial3Api
@Composable
fun ActionAppBar(
title: String,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
backgroundColor: Color = LocalColors.current.background,
actionMode: Boolean = false,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
actionModeActions: @Composable (RowScope.() -> Unit) = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = {
if (!actionMode) {
AppBarText(title = title)
}
},
navigationIcon =navigationIcon,
scrollBehavior = scrollBehavior,
colors = appBarColors(backgroundColor),
actions = {
if (actionMode) {
actionModeActions()
} else {
actions()
}
}
)
}
@Composable
fun AppBarText(title: String) {
Text(text = title, style = LocalType.current.h4)
}
@Composable
fun AppBarBackIcon(onBack: () -> Unit) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24),
contentDescription = stringResource(R.string.back)
)
}
}
@Composable
fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) {
Row(modifier = Modifier.height(LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
onBack?.let {
IconButton(onClick = it) {
Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back")
}
}
}
Spacer(modifier = Modifier.weight(1f))
Text(text = title, style = LocalType.current.h4)
Spacer(modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
IconButton(onClick = onClose) {
Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close")
}
}
fun AppBarCloseIcon(onClose: () -> Unit) {
IconButton(onClick = onClose) {
Icon(
painter = painterResource(id = R.drawable.ic_x),
contentDescription = stringResource(id = R.string.close)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun appBarColors(backgroundColor: Color) = TopAppBarDefaults.centerAlignedTopAppBarColors()
.copy(
containerColor = backgroundColor,
scrolledContainerColor = backgroundColor,
navigationIconContentColor = LocalColors.current.text,
titleContentColor = LocalColors.current.text,
actionIconContentColor = LocalColors.current.text
)

View File

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

View File

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="match_parent"
android:layout_width="match_parent"
tools:context="org.thoughtcrime.securesms.MediaOverviewActivity">
<com.google.android.material.appbar.AppBarLayout
style="@style/Widget.Session.AppBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stateListAnimator="@animator/appbar_elevation">
<androidx.appcompat.widget.Toolbar
android:id="@+id/search_toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
app:layout_scrollFlags="scroll|enterAlways"/>
<org.thoughtcrime.securesms.components.ControllableTabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"/>
</com.google.android.material.appbar.AppBarLayout>
<org.thoughtcrime.securesms.components.ControllableViewPager
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<org.thoughtcrime.securesms.components.DocumentView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/document_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:visibility="visible"
app:doc_titleColor="?android:textColorPrimary"
app:doc_captionColor="?android:textColorTertiary"
tools:visibility="visible"/>
<TextView android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:textSize="12sp"
android:textColor="?android:textColorTertiary"
android:paddingTop="20dp"
tools:text="Jun 1"/>
</LinearLayout>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/media_overview_toolbar_background"
android:padding="16dp">
<TextView android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:textColor="?attr/media_overview_header_foreground"
android:textSize="@dimen/small_font_size"
tools:text="March 1, 2015" />
</FrameLayout>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
tools:listitem="@layout/media_overview_document_item" />
<TextView
android:id="@+id/no_documents"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/attachmentsFilesEmpty"
android:textSize="@dimen/medium_font_size"
android:visibility="gone"
tools:visibility="visible" />
</RelativeLayout>

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/media_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
<TextView android:id="@+id/no_images"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textSize="@dimen/medium_font_size"
android:gravity="center"
android:textColor="?android:textColorPrimary"
android:visibility="gone"
android:text="@string/attachmentsFilesEmpty" />
</RelativeLayout>

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.SquareFrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="2dp">
<include layout="@layout/thumbnail_view"
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/selected_indicator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/MediaOverview_Media_selected_overlay"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/check"
android:layout_gravity="center"/>
</FrameLayout>
</org.thoughtcrime.securesms.components.SquareFrameLayout>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
android:padding="16dp">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/small_font_size"
tools:text="March 1, 2015" />
</FrameLayout>

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="44dp"
android:orientation="horizontal"
android:paddingStart="@dimen/medium_spacing"
android:paddingEnd="@dimen/medium_spacing"
android:gravity="center_vertical"
android:background="@drawable/mention_candidate_view_background">
<RelativeLayout
android:layout_width="26dp"
android:layout_height="32dp">
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/profilePictureView"
android:layout_width="@dimen/very_small_profile_picture_size"
android:layout_height="@dimen/very_small_profile_picture_size"
android:layout_marginTop="3dp" />
<ImageView
android:id="@+id/moderatorIconImageView"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_crown"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true" />
</RelativeLayout>
<TextView
android:id="@+id/mentionCandidateNameTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/medium_spacing"
android:textSize="@dimen/small_font_size"
android:textColor="?android:textColorPrimary"
android:maxLines="1"
android:ellipsize="end" />
</org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView>

View File

@ -5,4 +5,20 @@
'common' in the near future), and all content description / AccessibilityID_ strings are in
their own 'content-descriptions' module.
-->
<!-- TODO: These need to be removed and the text generated by DateUtils.getLocalisedRelativeDayString - but
it's going to be a nuisance because that returns a string not a resource ID so just leaving them for now -AL -->
<string name="BucketedThreadMedia_Today">Today</string>
<string name="BucketedThreadMedia_Yesterday">Yesterday</string>
<!-- TODO: We'll also need plurals for these strings -->
<plurals name="ConversationFragment_delete_selected_messages">
<item quantity="one">Delete selected message?</item>
<item quantity="other">Delete selected messages?</item>
</plurals>
<plurals name="ConversationFragment_this_will_permanently_delete_all_n_selected_messages">
<item quantity="one">This will permanently delete the selected message.</item>
<item quantity="other">This will permanently delete all %1$d selected messages.</item>
</plurals>
</resources>

View File

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

View File

@ -1,6 +1,6 @@
#Thu Dec 30 07:09:53 SAST 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@ -551,7 +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 -> TODO()
null -> { /* ignore null cases */ }
}
}

View File

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="network.loki.messenger.libsession_util">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
package org.session.libsession.utilities
// Non-translatable strings for use with the UI
object StringSubstitutionConstants {
object NonTranslatableStringConstants {
const val APP_NAME = "Session"
const val ARBISCAN = "Arbiscan"
const val ARBITRUM = "Arbitrum"

View File

@ -155,6 +155,7 @@
<string name="authenticateFailedTooManyAttempts">Too many failed authentication attempts. Please try again later.</string>
<string name="authenticateNotAccessed">Authentication could not be accessed.</string>
<string name="authenticateToOpen">Authenticate to open {app_name}.</string>
<string name="back">Back</string>
<string name="banDeleteAll">Ban and Delete All</string>
<string name="banErrorFailed">Ban failed</string>
<string name="banUnbanErrorFailed">Unban failed</string>

View File

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

View File

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

View File

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

View File

@ -6,4 +6,3 @@ include ':libsession'
include ':libsignal'
include ':libsession-util'
include ':content-descriptions' // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing
include ':stickyheader'

View File

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