Merge branch 'dev' into security

This commit is contained in:
Niels Andriesse 2021-07-12 14:27:14 +10:00
commit 5168e15640
537 changed files with 11436 additions and 14629 deletions

View File

@ -143,8 +143,8 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.2'
}
def canonicalVersionCode = 182
def canonicalVersionName = "1.10.13"
def canonicalVersionCode = 200
def canonicalVersionName = "1.11.4"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@ -194,8 +194,8 @@ android {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion 21
targetSdkVersion 30
minSdkVersion androidMinimumSdkVersion
targetSdkVersion androidCompileSdkVersion
multiDexEnabled = true

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="network.loki.messenger">
@ -7,7 +8,7 @@
<permission
android:name="network.loki.messenger.ACCESS_SESSION_SECRETS"
android:label="Access to TextSecure Secrets"
android:label="Access to Session secrets"
android:protectionLevel="signature" />
<uses-feature
@ -36,32 +37,32 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<!-- For conversation 'shortcuts' on the desktop -->
<uses-permission android:name="android.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.BROADCAST_STICKY" />
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Unused permissions that need to be removed -->
<uses-permission android:name="android.permission.BLUETOOTH" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" tools:node="remove"/>
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
<!-- The allowBackup="false" below is important to guard against potential malicious backups -->
<application
android:name="org.thoughtcrime.securesms.ApplicationContext"
android:allowBackup="false"
@ -73,7 +74,8 @@
android:theme="@style/Theme.Session.DayNight"
tools:replace="android:allowBackup">
<!-- Disable analytics -->
<!-- Disable all analytics -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
@ -90,88 +92,83 @@
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<!-- Session -->
<activity
android:name="org.thoughtcrime.securesms.loki.activities.LandingActivity"
android:name="org.thoughtcrime.securesms.onboarding.LandingActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"/>
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.RegisterActivity"
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"/>
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.RecoveryPhraseRestoreActivity"
android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.BackupRestoreActivity"
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.LinkDeviceActivity"
android:name="org.thoughtcrime.securesms.onboarding.DisplayNameActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.DisplayNameActivity"
android:name="org.thoughtcrime.securesms.onboarding.PNModeActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"/>
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.PNModeActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.HomeActivity"
android:name="org.thoughtcrime.securesms.home.HomeActivity"
android:screenOrientation="portrait"
android:launchMode="singleTask"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.SettingsActivity"
android:name="org.thoughtcrime.securesms.preferences.SettingsActivity"
android:screenOrientation="portrait"
android:label="@string/activity_settings_title"/>
android:label="@string/activity_settings_title" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.PathActivity"
android:name="org.thoughtcrime.securesms.home.PathActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.QRCodeActivity"
android:name="org.thoughtcrime.securesms.preferences.QRCodeActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"/>
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.CreatePrivateChatActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"/>
<activity
android:name="org.thoughtcrime.securesms.loki.activities.CreateClosedGroupActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity"
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.JoinPublicChatActivity"
android:name="org.thoughtcrime.securesms.dms.CreatePrivateChatActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.SeedActivity"
android:name="org.thoughtcrime.securesms.groups.CreateClosedGroupActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.SelectContactsActivity"
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.PrivacySettingsActivity"
android:name="org.thoughtcrime.securesms.groups.JoinPublicChatActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.SeedActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.contacts.SelectContactsActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.preferences.PrivacySettingsActivity"
android:label="@string/activity_privacy_settings_title"
android:screenOrientation="portrait"/>
<activity
android:name="org.thoughtcrime.securesms.loki.activities.NotificationSettingsActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.loki.activities.ChatSettingsActivity"
android:name="org.thoughtcrime.securesms.preferences.NotificationSettingsActivity"
android:screenOrientation="portrait" />
<!-- Session -->
<activity
android:name="org.thoughtcrime.securesms.preferences.ChatSettingsActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.ShareActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@ -184,9 +181,7 @@
android:windowSoftInputMode="stateHidden">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="text/plain" />
@ -195,7 +190,6 @@
<data android:mimeType="text/*" />
<data android:mimeType="*/*" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value=".service.DirectShareService" />
@ -204,14 +198,12 @@
<activity-alias
android:name=".RoutingActivity"
android:exported="true"
android:targetActivity="org.thoughtcrime.securesms.loki.activities.HomeActivity">
android:targetActivity="org.thoughtcrime.securesms.home.HomeActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher" />
@ -221,40 +213,22 @@
</activity-alias>
<activity
android:name="org.thoughtcrime.securesms.conversation.ConversationActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:launchMode="singleTask"
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight.NoActionBar"
android:parentActivityName="org.thoughtcrime.securesms.loki.activities.HomeActivity"
android:windowSoftInputMode="stateUnchanged">
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.DayNight.FlatActionBar">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.loki.activities.HomeActivity" />
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<activity
android:name="org.thoughtcrime.securesms.loki.activities.OpenGroupGuidelinesActivity"
android:name="org.thoughtcrime.securesms.groups.OpenGroupGuidelinesActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight"/>
android:theme="@style/Theme.TextSecure.DayNight" />
<activity
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight"/>
<activity
android:name="org.thoughtcrime.securesms.conversation.ConversationPopupActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:windowSoftInputMode="stateVisible" />
<activity
android:name="org.thoughtcrime.securesms.MessageDetailsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:label="Message Details"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight"
android:launchMode="singleTask"
android:windowSoftInputMode="stateHidden" />
android:theme="@style/Theme.TextSecure.DayNight" />
<activity
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@ -264,7 +238,7 @@
android:name="org.thoughtcrime.securesms.PassphrasePromptActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:launchMode="singleTask"
android:theme="@style/Theme.Session.DayNight.NoActionBar"/>
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity
android:name="org.thoughtcrime.securesms.giph.ui.GiphyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@ -306,7 +280,7 @@
<activity
android:name="org.thoughtcrime.securesms.scribbles.StickerSelectActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Theme.Session.ForceDark"/>
android:theme="@style/Theme.Session.ForceDark" />
<activity
android:name="com.theartofdev.edmodo.cropper.CropImageActivity"
android:screenOrientation="portrait"
@ -317,7 +291,7 @@
android:exported="true"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<service
android:name="org.thoughtcrime.securesms.loki.api.PushNotificationService"
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
android:enabled="true"
android:exported="false">
<intent-filter>
@ -436,15 +410,13 @@
<action android:name="info.guardianproject.panic.action.TRIGGER" />
</intent-filter>
</receiver>
<!-- Session -->
<receiver
android:name="org.thoughtcrime.securesms.loki.api.BackgroundPollWorker$BootBroadcastReceiver"
android:name="org.thoughtcrime.securesms.notifications.BackgroundPollWorker$BootBroadcastReceiver"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Session -->
<service
android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService"
android:enabled="@bool/enable_job_service"
@ -456,11 +428,9 @@
<receiver
android:name="org.thoughtcrime.securesms.jobmanager.AlarmManagerScheduler$RetryReceiver"
android:enabled="@bool/enable_alarm_manager" /> <!-- Probably don't need this one -->
<uses-library
android:name="com.sec.android.app.multiwindow"
android:required="false" />
<meta-data
android:name="com.sec.android.support.multiwindow"
android:value="true" />

View File

@ -27,10 +27,10 @@ import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.multidex.MultiDexApplication;
import org.conscrypt.Conscrypt;
import org.session.libsession.avatars.AvatarHelper;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2;
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
@ -42,11 +42,11 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ThreadUtils;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.dependencies.InjectableType;
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule;
@ -58,17 +58,14 @@ import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.logging.AndroidLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.loki.activities.HomeActivity;
import org.thoughtcrime.securesms.loki.api.BackgroundPollWorker;
import org.thoughtcrime.securesms.loki.api.LokiPushNotificationManager;
import org.thoughtcrime.securesms.loki.api.OpenGroupManager;
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
import org.thoughtcrime.securesms.loki.database.SessionContactDatabase;
import org.thoughtcrime.securesms.loki.utilities.Broadcaster;
import org.thoughtcrime.securesms.loki.utilities.ContactUtilities;
import org.thoughtcrime.securesms.loki.utilities.FcmUtils;
import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities;
import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.util.Broadcaster;
import org.thoughtcrime.securesms.notifications.FcmUtils;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
@ -84,12 +81,14 @@ import org.webrtc.PeerConnectionFactory;
import org.webrtc.PeerConnectionFactory.InitializationOptions;
import org.webrtc.voiceengine.WebRtcAudioManager;
import org.webrtc.voiceengine.WebRtcAudioUtils;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.security.Security;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import dagger.ObjectGraph;
import kotlin.Unit;
import kotlinx.coroutines.Job;
@ -154,8 +153,10 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
conversationListNotificationHandler = new Handler(Looper.getMainLooper());
LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this);
MessagingModuleConfiguration.Companion.configure(this,
DatabaseFactory.getStorage(this),
DatabaseFactory.getAttachmentProvider(this));
DatabaseFactory.getStorage(this),
DatabaseFactory.getAttachmentProvider(this),
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)
);
SnodeModule.Companion.configure(apiDB, broadcaster);
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey != null) {
@ -181,27 +182,6 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
Log.i(TAG, "App is now visible.");
KeyCachingService.onAppForegrounded(this);
boolean hasPerformedContactMigration = TextSecurePreferences.INSTANCE.hasPerformedContactMigration(this);
if (!hasPerformedContactMigration) {
TextSecurePreferences.INSTANCE.setPerformedContactMigration(this);
Set<Recipient> allContacts = ContactUtilities.getAllContacts(this);
SessionContactDatabase contactDB = DatabaseFactory.getSessionContactDatabase(this);
LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this);
for (Recipient recipient : allContacts) {
if (recipient.isGroupRecipient()) { continue; }
String sessionID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID);
if (contact == null) {
contact = new Contact(sessionID);
String name = userDB.getDisplayName(sessionID);
contact.setName(name);
contact.setProfilePictureURL(recipient.getProfileAvatar());
contact.setProfilePictureEncryptionKey(recipient.getProfileKey());
contact.setTrusted(true);
}
contactDB.setContact(contact);
}
}
if (poller != null) {
poller.setCaughtUp(false);
}
@ -487,7 +467,6 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setIsMigratingKeyPair(this, true);
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}

View File

@ -26,7 +26,7 @@ import android.widget.TextView;
import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
import org.thoughtcrime.securesms.mms.GlideRequests;

View File

@ -64,10 +64,12 @@ import org.session.libsignal.utilities.Log;
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.mediapreview.MediaPreviewViewModel;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.DateUtils;
@ -116,6 +118,22 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
private int restartItem = -1;
public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) {
Intent previewIntent = null;
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
previewIntent = new Intent(context, MediaPreviewActivity.class);
previewIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(slide.getUri(), slide.getContentType())
.putExtra(ADDRESS_EXTRA, threadRecipient.getAddress())
.putExtra(OUTGOING_EXTRA, mms.isOutgoing())
.putExtra(DATE_EXTRA, mms.getTimestamp())
.putExtra(SIZE_EXTRA, slide.asAttachment().getSize())
.putExtra(CAPTION_EXTRA, slide.getCaption().orNull())
.putExtra(LEFT_IS_RECENT_EXTRA, false);
}
return previewIntent;
}
@SuppressWarnings("ConstantConditions")
@Override
@ -171,7 +189,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
CharSequence relativeTimeSpan;
if (mediaItem.date > 0) {
relativeTimeSpan = DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), mediaItem.date);
relativeTimeSpan = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), mediaItem.date);
} else {
relativeTimeSpan = getString(R.string.MediaPreviewActivity_draft);
}

View File

@ -1,482 +0,0 @@
/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.database.Cursor;
import android.graphics.drawable.ColorDrawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.Loader;
import org.session.libsession.messaging.messages.visible.LinkPreview;
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation;
import org.session.libsession.messaging.messages.visible.Quote;
import org.session.libsession.messaging.messages.visible.VisibleMessage;
import org.session.libsession.messaging.open_groups.OpenGroupV2;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.messaging.utilities.UpdateMessageData;
import org.thoughtcrime.securesms.MessageDetailsRecipientAdapter.RecipientDeliveryStatus;
import org.session.libsession.utilities.MaterialColor;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.loaders.MessageDetailsLoader;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.recipients.RecipientModifiedListener;
import org.thoughtcrime.securesms.util.DateUtils;
import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.guava.Optional;
import java.lang.ref.WeakReference;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import network.loki.messenger.R;
/**
* @author Jake McGinty
*/
public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity implements LoaderCallbacks<Cursor>, RecipientModifiedListener {
private final static String TAG = MessageDetailsActivity.class.getSimpleName();
public final static String MESSAGE_ID_EXTRA = "message_id";
public final static String THREAD_ID_EXTRA = "thread_id";
public final static String IS_PUSH_GROUP_EXTRA = "is_push_group";
public final static String TYPE_EXTRA = "type";
public final static String ADDRESS_EXTRA = "address";
private GlideRequests glideRequests;
private long threadId;
private boolean isPushGroup;
private ConversationItem conversationItem;
private ViewGroup itemParent;
private View metadataContainer;
private View expiresContainer;
private TextView errorText;
private View resendButton;
private TextView sentDate;
private TextView receivedDate;
private TextView expiresInText;
private View receivedContainer;
private TextView transport;
private TextView toFrom;
private View separator;
private ListView recipientsList;
private LayoutInflater inflater;
private boolean running;
@Override
public void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle, ready);
setContentView(R.layout.message_details_activity);
running = true;
initializeResources();
initializeActionBar();
getSupportLoaderManager().initLoader(0, null, this);
}
@Override
protected void onResume() {
super.onResume();
assert getSupportActionBar() != null;
getSupportActionBar().setTitle("Message Details");
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(threadId);
}
@Override
protected void onPause() {
super.onPause();
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1L);
}
@Override
protected void onDestroy() {
super.onDestroy();
running = false;
}
private void initializeActionBar() {
assert getSupportActionBar() != null;
Recipient recipient = Recipient.from(this, getIntent().getParcelableExtra(ADDRESS_EXTRA), true);
recipient.addListener(this);
}
private void setActionBarColor(MaterialColor color) {
assert getSupportActionBar() != null;
getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this)));
}
@Override
public void onModified(final Recipient recipient) {
Util.runOnMain(() -> setActionBarColor(recipient.getColor()));
}
private void initializeResources() {
inflater = LayoutInflater.from(this);
View header = inflater.inflate(R.layout.message_details_header, recipientsList, false);
threadId = getIntent().getLongExtra(THREAD_ID_EXTRA, -1);
isPushGroup = getIntent().getBooleanExtra(IS_PUSH_GROUP_EXTRA, false);
glideRequests = GlideApp.with(this);
itemParent = header.findViewById(R.id.item_container);
recipientsList = findViewById(R.id.recipients_list);
metadataContainer = header.findViewById(R.id.metadata_container);
errorText = header.findViewById(R.id.error_text);
resendButton = header.findViewById(R.id.resend_button);
sentDate = header.findViewById(R.id.sent_time);
receivedContainer = header.findViewById(R.id.received_container);
receivedDate = header.findViewById(R.id.received_time);
transport = header.findViewById(R.id.transport);
toFrom = header.findViewById(R.id.tofrom);
separator = header.findViewById(R.id.separator);
expiresContainer = header.findViewById(R.id.expires_container);
expiresInText = header.findViewById(R.id.expires_in);
recipientsList.setHeaderDividersEnabled(false);
recipientsList.addHeaderView(header, null, false);
}
private void updateTransport(MessageRecord messageRecord) {
final String transportText;
if (messageRecord.isOutgoing() && messageRecord.isFailed()) {
transportText = "-";
} else if (messageRecord.isPending()) {
transportText = getString(R.string.ConversationFragment_pending);
} else if (messageRecord.isPush()) {
transportText = getString(R.string.ConversationFragment_push);
} else if (messageRecord.isMms()) {
transportText = getString(R.string.ConversationFragment_mms);
} else {
transportText = getString(R.string.ConversationFragment_sms);
}
transport.setText(transportText);
}
private void updateTime(MessageRecord messageRecord) {
sentDate.setOnLongClickListener(null);
receivedDate.setOnLongClickListener(null);
if (messageRecord.isPending() || messageRecord.isFailed()) {
sentDate.setText("-");
receivedContainer.setVisibility(View.GONE);
} else {
Locale dateLocale = Locale.getDefault();
SimpleDateFormat dateFormatter = DateUtils.getDetailedDateFormatter(this, dateLocale);
sentDate.setText(dateFormatter.format(new Date(messageRecord.getDateSent())));
sentDate.setOnLongClickListener(v -> {
copyToClipboard(String.valueOf(messageRecord.getDateSent()));
return true;
});
if (messageRecord.getDateReceived() != messageRecord.getDateSent() && !messageRecord.isOutgoing()) {
receivedDate.setText(dateFormatter.format(new Date(messageRecord.getDateReceived())));
receivedDate.setOnLongClickListener(v -> {
copyToClipboard(String.valueOf(messageRecord.getDateReceived()));
return true;
});
receivedContainer.setVisibility(View.VISIBLE);
} else {
receivedContainer.setVisibility(View.GONE);
}
}
}
private void updateExpirationTime(final MessageRecord messageRecord) {
if (messageRecord.getExpiresIn() <= 0 || messageRecord.getExpireStarted() <= 0) {
expiresContainer.setVisibility(View.GONE);
return;
}
expiresContainer.setVisibility(View.VISIBLE);
Util.runOnMain(new Runnable() {
@Override
public void run() {
long elapsed = System.currentTimeMillis() - messageRecord.getExpireStarted();
long remaining = messageRecord.getExpiresIn() - elapsed;
String duration = ExpirationUtil.getExpirationDisplayValue(MessageDetailsActivity.this, Math.max((int)(remaining / 1000), 1));
expiresInText.setText(duration);
if (running) {
Util.runOnMainDelayed(this, 500);
}
}
});
}
private void updateRecipients(MessageRecord messageRecord, Recipient recipient, List<RecipientDeliveryStatus> recipients) {
final int toFromRes;
if (messageRecord.isMms() && !messageRecord.isPush() && !messageRecord.isOutgoing()) {
toFromRes = R.string.message_details_header__with;
} else if (messageRecord.isOutgoing()) {
toFromRes = R.string.message_details_header__to;
} else {
toFromRes = R.string.message_details_header__from;
}
toFrom.setText(toFromRes);
long threadID = messageRecord.getThreadId();
OpenGroupV2 openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID);
if (openGroup != null && messageRecord.isOutgoing()) {
toFrom.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
}
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), recipient, null, false);
recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, glideRequests, messageRecord, recipients, isPushGroup));
}
private void inflateMessageViewIfAbsent(MessageRecord messageRecord) {
if (conversationItem == null) {
if (messageRecord.isGroupAction()) {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_update, itemParent, false);
} else if (messageRecord.isOutgoing()) {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent, itemParent, false);
} else {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received, itemParent, false);
}
itemParent.addView(conversationItem);
}
}
private @Nullable MessageRecord getMessageRecord(Context context, Cursor cursor, String type) {
switch (type) {
case MmsSmsDatabase.SMS_TRANSPORT:
SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
SmsDatabase.Reader reader = smsDatabase.readerFor(cursor);
return reader.getNext();
case MmsSmsDatabase.MMS_TRANSPORT:
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
MmsDatabase.Reader mmsReader = mmsDatabase.readerFor(cursor);
return mmsReader.getNext();
default:
throw new AssertionError("no valid message type specified");
}
}
private void copyToClipboard(@NonNull String text) {
((ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", text));
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new MessageDetailsLoader(this, getIntent().getStringExtra(TYPE_EXTRA),
getIntent().getLongExtra(MESSAGE_ID_EXTRA, -1));
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
MessageRecord messageRecord = getMessageRecord(this, cursor, getIntent().getStringExtra(TYPE_EXTRA));
if (messageRecord == null) {
finish();
} else {
new MessageRecipientAsyncTask(this, messageRecord).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
recipientsList.setAdapter(null);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home: finish(); return true;
}
return false;
}
@SuppressLint("StaticFieldLeak")
private class MessageRecipientAsyncTask extends AsyncTask<Void,Void,List<RecipientDeliveryStatus>> {
private final WeakReference<Context> weakContext;
private final MessageRecord messageRecord;
MessageRecipientAsyncTask(@NonNull Context context, @NonNull MessageRecord messageRecord) {
this.weakContext = new WeakReference<>(context);
this.messageRecord = messageRecord;
}
protected Context getContext() {
return weakContext.get();
}
@Override
public List<RecipientDeliveryStatus> doInBackground(Void... voids) {
Context context = getContext();
if (context == null) {
Log.w(TAG, "associated context is destroyed, finishing early");
return null;
}
List<RecipientDeliveryStatus> recipients = new LinkedList<>();
if (!messageRecord.getRecipient().isGroupRecipient()) {
recipients.add(new RecipientDeliveryStatus(messageRecord.getRecipient(), getStatusFor(messageRecord.getDeliveryReceiptCount(), messageRecord.getReadReceiptCount(), messageRecord.isPending()), messageRecord.isUnidentified(), -1));
} else {
List<GroupReceiptInfo> receiptInfoList = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageRecord.getId());
if (receiptInfoList.isEmpty()) {
List<Recipient> group = DatabaseFactory.getGroupDatabase(context).getGroupMembers(messageRecord.getRecipient().getAddress().toGroupString(), false);
for (Recipient recipient : group) {
recipients.add(new RecipientDeliveryStatus(recipient, RecipientDeliveryStatus.Status.UNKNOWN, false, -1));
}
} else {
for (GroupReceiptInfo info : receiptInfoList) {
recipients.add(new RecipientDeliveryStatus(Recipient.from(context, info.getAddress(), true),
getStatusFor(info.getStatus(), messageRecord.isPending(), messageRecord.isFailed()),
info.isUnidentified(),
info.getTimestamp()));
}
}
}
return recipients;
}
@Override
public void onPostExecute(List<RecipientDeliveryStatus> recipients) {
if (getContext() == null) {
Log.w(TAG, "AsyncTask finished with a destroyed context, leaving early.");
return;
}
inflateMessageViewIfAbsent(messageRecord);
updateRecipients(messageRecord, messageRecord.getRecipient(), recipients);
boolean isGroupNetworkFailure = messageRecord.isFailed() && !messageRecord.getNetworkFailures().isEmpty();
boolean isIndividualNetworkFailure = messageRecord.isFailed() && !isPushGroup && messageRecord.getIdentityKeyMismatches().isEmpty();
LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(getContext());
String errorMessage = lokiMessageDatabase.getErrorMessage(messageRecord.id);
if (errorMessage != null) {
errorText.setText(errorMessage);
}
if (isGroupNetworkFailure || isIndividualNetworkFailure) {
errorText.setVisibility(View.VISIBLE);
resendButton.setVisibility(View.VISIBLE);
resendButton.setOnClickListener(this::onResendClicked);
metadataContainer.setVisibility(View.GONE);
} else if (messageRecord.isFailed()) {
errorText.setVisibility(View.VISIBLE);
resendButton.setVisibility(View.GONE);
resendButton.setOnClickListener(null);
metadataContainer.setVisibility(View.GONE);
} else {
updateTransport(messageRecord);
updateTime(messageRecord);
updateExpirationTime(messageRecord);
errorText.setVisibility(View.GONE);
resendButton.setVisibility(View.GONE);
resendButton.setOnClickListener(null);
metadataContainer.setVisibility(View.VISIBLE);
}
}
private RecipientDeliveryStatus.Status getStatusFor(int deliveryReceiptCount, int readReceiptCount, boolean pending) {
if (readReceiptCount > 0) return RecipientDeliveryStatus.Status.READ;
else if (deliveryReceiptCount > 0) return RecipientDeliveryStatus.Status.DELIVERED;
else if (!pending) return RecipientDeliveryStatus.Status.SENT;
else return RecipientDeliveryStatus.Status.PENDING;
}
private RecipientDeliveryStatus.Status getStatusFor(int groupStatus, boolean pending, boolean failed) {
if (groupStatus == GroupReceiptDatabase.STATUS_READ) return RecipientDeliveryStatus.Status.READ;
else if (groupStatus == GroupReceiptDatabase.STATUS_DELIVERED) return RecipientDeliveryStatus.Status.DELIVERED;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && failed) return RecipientDeliveryStatus.Status.UNKNOWN;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && !pending) return RecipientDeliveryStatus.Status.SENT;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED) return RecipientDeliveryStatus.Status.PENDING;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNKNOWN) return RecipientDeliveryStatus.Status.UNKNOWN;
throw new AssertionError();
}
private void onResendClicked(View v) {
Recipient recipient = messageRecord.getRecipient();
VisibleMessage message = new VisibleMessage();
message.setId(messageRecord.getId());
if (messageRecord.isOpenGroupInvitation()) {
OpenGroupInvitation openGroupInvitation = new OpenGroupInvitation();
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(messageRecord.getBody());
if (updateMessageData.getKind() instanceof UpdateMessageData.Kind.OpenGroupInvitation) {
UpdateMessageData.Kind.OpenGroupInvitation data = (UpdateMessageData.Kind.OpenGroupInvitation)updateMessageData.getKind();
openGroupInvitation.setName(data.getGroupName());
openGroupInvitation.setUrl(data.getGroupUrl());
}
message.setOpenGroupInvitation(openGroupInvitation);
} else {
message.setText(messageRecord.getBody());
}
message.setSentTimestamp(messageRecord.getTimestamp());
if (recipient.isGroupRecipient()) {
message.setGroupPublicKey(recipient.getAddress().toGroupString());
} else {
message.setRecipient(messageRecord.getRecipient().getAddress().serialize());
}
message.setThreadID(messageRecord.getThreadId());
if (messageRecord.isMms()) {
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
if (!mmsMessageRecord.getLinkPreviews().isEmpty()) {
message.setLinkPreview(LinkPreview.Companion.from(mmsMessageRecord.getLinkPreviews().get(0)));
}
if (mmsMessageRecord.getQuote() != null) {
message.setQuote(Quote.Companion.from(mmsMessageRecord.getQuote().getQuoteModel()));
}
message.addSignalAttachments(mmsMessageRecord.getSlideDeck().asAttachments());
}
MessageSender.send(message, recipient.getAddress());
resendButton.setVisibility(View.GONE);
}
}
}

View File

@ -10,7 +10,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.loki.views.UserView;
import org.thoughtcrime.securesms.contacts.UserView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.Conversions;

View File

@ -39,7 +39,7 @@ import android.widget.ImageView;
import androidx.core.hardware.fingerprint.FingerprintManagerCompat;
import androidx.core.os.CancellationSignal;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.service.KeyCachingService;

View File

@ -12,8 +12,8 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.loki.activities.HomeActivity;
import org.thoughtcrime.securesms.loki.activities.LandingActivity;
import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.onboarding.LandingActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.session.libsession.utilities.TextSecurePreferences;

View File

@ -37,13 +37,13 @@ import androidx.appcompat.widget.Toolbar;
import org.session.libsession.utilities.DistributionTypes;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.loki.fragments.ContactSelectionListFragment;
import org.thoughtcrime.securesms.loki.fragments.ContactSelectionListLoader.DisplayMode;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.contacts.ContactSelectionListFragment;
import org.thoughtcrime.securesms.contacts.ContactSelectionListLoader.DisplayMode;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.session.libsession.utilities.recipients.Recipient;
@ -53,7 +53,6 @@ import org.session.libsession.utilities.ViewUtil;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import network.loki.messenger.R;
@ -215,10 +214,9 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
}
private void createConversation(long threadId, Address address, int distributionType) {
final Intent intent = getBaseShareIntent(ConversationActivity.class);
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, address);
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
final Intent intent = getBaseShareIntent(ConversationActivityV2.class);
intent.putExtra(ConversationActivityV2.ADDRESS, address);
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId);
isPassingAlongMedia = true;
startActivity(intent);
@ -226,11 +224,6 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
private Intent getBaseShareIntent(final @NonNull Class<?> target) {
final Intent intent = new Intent(this, target);
final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT);
final ArrayList<Media> mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA);
intent.putExtra(ConversationActivity.TEXT_EXTRA, textExtra);
intent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaExtra);
if (resolvedExtra != null) intent.setDataAndType(resolvedExtra, mimeType);

View File

@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatActivity;
import android.widget.Toast;
import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.loki.activities.HomeActivity;
import org.thoughtcrime.securesms.home.HomeActivity;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.util.CommunicationActions;

View File

@ -9,12 +9,13 @@ import org.session.libsession.messaging.sending_receiving.attachments.*
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.UploadResult
import org.session.libsession.utilities.Util
import org.session.libsignal.utilities.guava.Optional
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.messages.SignalServiceAttachment
import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceAttachmentStream
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.Database
import org.thoughtcrime.securesms.database.DatabaseFactory
@ -60,9 +61,9 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return databaseAttachment.toSignalAttachmentPointer()
}
override fun setAttachmentState(attachmentState: AttachmentState, attachmentId: Long, messageID: Long) {
override fun setAttachmentState(attachmentState: AttachmentState, attachmentId: AttachmentId, messageID: Long) {
val attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context)
attachmentDatabase.setTransferState(messageID, AttachmentId(attachmentId, 0), attachmentState.value)
attachmentDatabase.setTransferState(messageID, attachmentId, attachmentState.value)
}
override fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>? {
@ -92,11 +93,39 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return message.linkPreviews.firstOrNull()?.attachmentId?.rowId
}
override fun getIndividualRecipientForMms(mmsId: Long): Recipient? {
val mmsDb = DatabaseFactory.getMmsDatabase(context)
val message = mmsDb.getMessage(mmsId).use {
mmsDb.readerFor(it).next
}
return message?.individualRecipient
}
override fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream: InputStream) {
val attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context)
attachmentDatabase.insertAttachmentsForPlaceholder(messageId, attachmentId, stream)
}
override fun updateAudioAttachmentDuration(
attachmentId: AttachmentId,
durationMs: Long,
threadId: Long
) {
val attachmentDb = DatabaseFactory.getAttachmentDatabase(context)
attachmentDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
attachmentId = attachmentId,
visualSamples = byteArrayOf(),
durationMs = durationMs
), threadId)
}
override fun isMmsOutgoing(mmsMessageId: Long): Boolean {
val mmsDb = DatabaseFactory.getMmsDatabase(context)
return mmsDb.getMessage(mmsMessageId).use { cursor ->
mmsDb.readerFor(cursor).next
}.isOutgoing
}
override fun isOutgoingMessage(timestamp: Long): Boolean {
val smsDatabase = DatabaseFactory.getSmsDatabase(context)
val mmsDatabase = DatabaseFactory.getMmsDatabase(context)

View File

@ -23,6 +23,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.LoadControl;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.audio.AudioAttributes;
@ -103,9 +104,9 @@ public class AudioSlidePlayer implements SensorEventListener {
}
private void play(final double progress, boolean earpiece) throws IOException {
if (this.mediaPlayer != null) return;
if (this.mediaPlayer != null) { stop(); }
LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl();
LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl();
this.mediaPlayer = ExoPlayerFactory.newSimpleInstance(context, new DefaultRenderersFactory(context), new DefaultTrackSelector(), loadControl);
this.audioAttachmentServer = new AttachmentServer(context, slide.asAttachment());
this.startTime = System.currentTimeMillis();
@ -184,8 +185,6 @@ public class AudioSlidePlayer implements SensorEventListener {
public void onPlayerError(ExoPlaybackException error) {
Log.w(TAG, "MediaPlayer Error: " + error);
Toast.makeText(context, R.string.AudioSlidePlayer_error_playing_audio, Toast.LENGTH_SHORT).show();
synchronized (AudioSlidePlayer.this) {
mediaPlayer = null;
@ -267,8 +266,17 @@ public class AudioSlidePlayer implements SensorEventListener {
return slide;
}
public Long getDuration() {
if (mediaPlayer == null) { return 0L; }
return mediaPlayer.getDuration();
}
private Pair<Double, Integer> getProgress() {
public Double getProgress() {
if (mediaPlayer == null) { return 0.0; }
return (double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration();
}
private Pair<Double, Integer> getProgressTuple() {
if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) {
return new Pair<>(0D, 0);
} else {
@ -277,6 +285,16 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
public float getPlaybackSpeed() {
if (mediaPlayer == null) { return 1.0f; }
return mediaPlayer.getPlaybackParameters().speed;
}
public void setPlaybackSpeed(float speed) {
if (mediaPlayer == null) { return; }
mediaPlayer.setPlaybackParameters(new PlaybackParameters(speed));
}
private void notifyOnStart() {
Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this));
}
@ -383,7 +401,7 @@ public class AudioSlidePlayer implements SensorEventListener {
return;
}
Pair<Double, Integer> progress = player.getProgress();
Pair<Double, Integer> progress = player.getProgressTuple();
player.notifyOnProgress(progress.first, progress.second);
sendEmptyMessageDelayed(0, 50);
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.activities
package org.thoughtcrime.securesms.backup;
import android.app.Activity
import android.app.Application
@ -9,17 +9,13 @@ import android.os.Bundle
import android.provider.OpenableColumns
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.StyleSpan
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.widget.addTextChangedListener
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
@ -28,18 +24,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.FullBackupImporter.DatabaseDowngradeException
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.loki.utilities.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.loki.utilities.show
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.BackupUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.home.HomeActivity
class BackupRestoreActivity : BaseActionBarActivity() {
@ -188,7 +183,6 @@ class BackupRestoreViewModel(application: Application): AndroidViewModel(applica
TextSecurePreferences.setRestorationTime(context, System.currentTimeMillis())
TextSecurePreferences.setHasViewedSeed(context, true)
TextSecurePreferences.setHasSeenWelcomeScreen(context, true)
val application = ApplicationContext.getInstance(context)
BackupRestoreResult.SUCCESS
} catch (e: DatabaseDowngradeException) {

View File

@ -21,8 +21,7 @@ import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.database.*
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
import org.thoughtcrime.securesms.util.BackupUtil
import org.session.libsession.utilities.Util
import org.session.libsignal.crypto.kdf.HKDFv3

View File

@ -1,25 +1,27 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.ColorInt;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import androidx.annotation.ColorInt;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.utilities.Stub;
import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.session.libsession.utilities.Stub;
import java.util.List;
import network.loki.messenger.R;
public class AlbumThumbnailView extends FrameLayout {
private @Nullable SlideClickListener thumbnailClickListener;
@ -51,8 +53,8 @@ public class AlbumThumbnailView extends FrameLayout {
private void initialize() {
inflate(getContext(), R.layout.album_thumbnail_view, this);
albumCellContainer = findViewById(R.id.album_cell_container);
transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
albumCellContainer = findViewById(R.id.albumCellContainer);
transferControls = new Stub<>(findViewById(R.id.albumTransferControlsStub));
}
public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides, boolean showControls) {
@ -147,10 +149,5 @@ public class AlbumThumbnailView extends FrameLayout {
}
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
ThumbnailView cell = findViewById(id);
cell.setImageResource(glideRequests, slide, false, false);
cell.setLoadIndicatorVisibile(slide.isInProgress());
cell.setThumbnailClickListener(defaultThumbnailClickListener);
cell.setOnLongClickListener(defaultLongClickListener);
}
}

View File

@ -19,7 +19,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.loki.utilities.AvatarPlaceholderGenerator;
import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;

View File

@ -28,166 +28,166 @@ import org.session.libsession.utilities.TextSecurePreferences;
public class ComposeText extends EmojiEditText {
private CharSequence hint;
private SpannableString subHint;
private CharSequence hint;
private SpannableString subHint;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
public ComposeText(Context context) {
super(context);
initialize();
}
public ComposeText(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
public String getTextTrimmed(){
return getText().toString().trim();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (!TextUtils.isEmpty(hint)) {
if (!TextUtils.isEmpty(subHint)) {
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHint(ellipsizeToWidth(hint));
}
}
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
}
}
private CharSequence ellipsizeToWidth(CharSequence text) {
return TextUtils.ellipsize(text,
getPaint(),
getWidth() - getPaddingLeft() - getPaddingRight(),
TruncateAt.END);
}
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
this.hint = hint;
if (subHint != null) {
this.subHint = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
} else {
this.subHint = null;
public ComposeText(Context context) {
super(context);
initialize();
}
if (this.subHint != null) {
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
super.setHint(ellipsizeToWidth(this.hint));
}
}
public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) {
this.cursorPositionChangedListener = listener;
}
public void setTransport() {
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
setImeActionLabel(null, 0);
if (useSystemEmoji) {
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
public ComposeText(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
setInputType(inputType);
if (isIncognito) {
setImeOptions(imeOptions | 16777216);
} else {
setImeOptions(imeOptions);
}
}
@Override
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
InputConnection inputConnection = super.onCreateInputConnection(editorInfo);
if(TextSecurePreferences.isEnterSendsEnabled(getContext())) {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
if (Build.VERSION.SDK_INT < 21) return inputConnection;
if (mediaListener == null) return inputConnection;
if (inputConnection == null) return null;
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"});
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
}
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
}
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = CommitContentListener.class.getSimpleName();
private final InputPanel.MediaListener mediaListener;
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
public String getTextTrimmed(){
return getText().toString().trim();
}
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {
Log.w(TAG, e);
return false;
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (!TextUtils.isEmpty(hint)) {
if (!TextUtils.isEmpty(subHint)) {
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHint(ellipsizeToWidth(hint));
}
}
}
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
inputContentInfo.getDescription().getMimeType(0));
return true;
}
return false;
}
}
public interface CursorPositionChangedListener {
void onCursorPositionChanged(int start, int end);
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
}
}
private CharSequence ellipsizeToWidth(CharSequence text) {
return TextUtils.ellipsize(text,
getPaint(),
getWidth() - getPaddingLeft() - getPaddingRight(),
TruncateAt.END);
}
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
this.hint = hint;
if (subHint != null) {
this.subHint = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
} else {
this.subHint = null;
}
if (this.subHint != null) {
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
super.setHint(ellipsizeToWidth(this.hint));
}
}
public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) {
this.cursorPositionChangedListener = listener;
}
public void setTransport() {
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
setImeActionLabel(null, 0);
if (useSystemEmoji) {
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
}
setInputType(inputType);
if (isIncognito) {
setImeOptions(imeOptions | 16777216);
} else {
setImeOptions(imeOptions);
}
}
@Override
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
InputConnection inputConnection = super.onCreateInputConnection(editorInfo);
if(TextSecurePreferences.isEnterSendsEnabled(getContext())) {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (Build.VERSION.SDK_INT < 21) return inputConnection;
if (mediaListener == null) return inputConnection;
if (inputConnection == null) return null;
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"});
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
}
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
}
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = CommitContentListener.class.getSimpleName();
private final InputPanel.MediaListener mediaListener;
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {
Log.w(TAG, e);
return false;
}
}
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
inputContentInfo.getDescription().getMimeType(0));
return true;
}
return false;
}
}
public interface CursorPositionChangedListener {
void onCursorPositionChanged(int start, int end);
}
}

View File

@ -4,15 +4,17 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
@ -88,8 +90,6 @@ public class ConversationItemFooter extends LinearLayout {
if (messageRecord.isFailed()) {
dateView.setText(R.string.ConversationItem_error_not_delivered);
} else if (messageRecord.isPendingInsecureSmsFallback()) {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
@ -131,14 +131,14 @@ public class ConversationItemFooter extends LinearLayout {
}
private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
insecureIndicatorView.setVisibility(messageRecord.isSecure() ? View.GONE : View.VISIBLE);
insecureIndicatorView.setVisibility(View.GONE);
}
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
if (!messageRecord.isFailed() && !messageRecord.isPendingInsecureSmsFallback()) {
if (!messageRecord.isFailed()) {
if (!messageRecord.isOutgoing()) deliveryStatusView.setNone();
else if (messageRecord.isPending()) deliveryStatusView.setPending();
else if (messageRecord.isRemoteRead()) deliveryStatusView.setRead();
else if (messageRecord.isRead()) deliveryStatusView.setRead();
else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered();
else deliveryStatusView.setSent();
} else {

View File

@ -12,6 +12,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
@ -27,7 +28,7 @@ import network.loki.messenger.R;
public class ConversationItemThumbnail extends FrameLayout {
private ThumbnailView thumbnail;
private ThumbnailView thumbnail;
private AlbumThumbnailView album;
private ImageView shade;
private ConversationItemFooter footer;
@ -64,15 +65,10 @@ public class ConversationItemThumbnail extends FrameLayout {
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0));
typedArray.recycle();
}
}
@SuppressWarnings("SuspiciousNameCombination")
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);

View File

@ -1,88 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import network.loki.messenger.R;
/**
* Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity}
* when the user is searching within a conversation. Shows details about the results and allows the
* user to move between them.
*/
public class ConversationSearchBottomBar extends ConstraintLayout {
private View searchDown;
private View searchUp;
private TextView searchPositionText;
private View progressWheel;
private EventListener eventListener;
public ConversationSearchBottomBar(Context context) {
super(context);
}
public ConversationSearchBottomBar(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
this.searchUp = findViewById(R.id.conversation_search_up);
this.searchDown = findViewById(R.id.conversation_search_down);
this.searchPositionText = findViewById(R.id.conversation_search_position);
this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
}
public void setData(int position, int count) {
progressWheel.setVisibility(GONE);
searchUp.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveUpPressed();
}
});
searchDown.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveDownPressed();
}
});
if (count > 0) {
searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count));
} else {
searchPositionText.setText(R.string.ConversationActivity_no_results);
}
setViewEnabled(searchUp, position < (count - 1));
setViewEnabled(searchDown, position > 0);
}
public void showLoading() {
progressWheel.setVisibility(VISIBLE);
}
private void setViewEnabled(@NonNull View view, boolean enabled) {
view.setEnabled(enabled);
view.setAlpha(enabled ? 1f : 0.25f);
}
public void setEventListener(@Nullable EventListener eventListener) {
this.eventListener = eventListener;
}
public interface EventListener {
void onSearchMoveUpPressed();
void onSearchMoveDownPressed();
}
}

View File

@ -1,60 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.ThemeUtil;
import java.util.List;
import network.loki.messenger.R;
public class ConversationTypingView extends LinearLayout {
private AvatarImageView avatar;
private View bubble;
private TypingIndicatorView indicator;
public ConversationTypingView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
avatar = findViewById(R.id.typing_avatar);
bubble = findViewById(R.id.typing_bubble);
indicator = findViewById(R.id.typing_indicator);
}
public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List<Recipient> typists, boolean isGroupThread) {
if (typists.isEmpty()) {
indicator.stopAnimation();
return;
}
Recipient typist = typists.get(0);
bubble.getBackground().setColorFilter(
ThemeUtil.getThemedColor(getContext(), R.attr.message_received_background_color),
PorterDuff.Mode.MULTIPLY);
if (isGroupThread) {
avatar.setAvatar(glideRequests, typist, false);
avatar.setVisibility(VISIBLE);
} else {
avatar.setVisibility(GONE);
}
indicator.startAnimation();
}
}

View File

@ -4,443 +4,26 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.DimenRes;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.Interpolator;
import android.view.animation.TranslateAnimation;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.loki.utilities.MentionUtilities;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.SlideDeck;
public class InputPanel extends LinearLayout {
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.concurrent.AssertedSuccessListener;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.SettableFuture;
import org.session.libsignal.utilities.guava.Optional;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class InputPanel extends LinearLayout
implements MicrophoneRecorderView.Listener,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiKeyboardProvider.EmojiEventListener
{
private static final String TAG = InputPanel.class.getSimpleName();
private static final int FADE_TIME = 150;
private QuoteView quoteView;
private LinkPreviewView linkPreview;
private EmojiToggle mediaKeyboard;
public ComposeText composeText;
private View quickCameraToggle;
private View quickAudioToggle;
private View buttonToggle;
private View recordingContainer;
private View recordLockCancel;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private @Nullable Listener listener;
private boolean emojiVisible;
public InputPanel(Context context) {
super(context);
}
public InputPanel(Context context, AttributeSet attrs) {
super(context, attrs);
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss);
this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
this.quickAudioToggle = findViewById(R.id.quick_audio_toggle);
this.buttonToggle = findViewById(R.id.button_toggle);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
View slideToCancelView = findViewById(R.id.slide_to_cancel);
this.slideToCancel = new SlideToCancel(slideToCancelView);
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setListener(this);
this.recordTime = new RecordTime(findViewById(R.id.record_time),
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction());
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction());
if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
mediaKeyboard.setVisibility(View.GONE);
emojiVisible = false;
} else {
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
public InputPanel(Context context) {
super(context);
}
quoteDismiss.setOnClickListener(v -> clearQuote());
linkPreview.setCloseClickedListener(() -> {
if (listener != null) {
listener.onLinkPreviewCanceled();
}
});
}
public void setListener(final @NonNull Listener listener) {
this.listener = listener;
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
}
public void setMediaListener(@NonNull MediaListener listener) {
composeText.setMediaListener(listener);
}
public void setQuote(@NonNull GlideRequests glideRequests, long id, @NonNull Recipient author, @NonNull String body, @NonNull SlideDeck attachments, @NonNull Recipient conversationRecipient, long threadID) {
this.quoteView.setQuote(glideRequests, id, author, MentionUtilities.highlightMentions(body, threadID, getContext()), false, attachments, conversationRecipient);
this.quoteView.setVisibility(View.VISIBLE);
if (this.linkPreview.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
}
public void clearQuote() {
this.quoteView.dismiss();
if (this.linkPreview.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
}
public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getAddress(), quoteView.getBody(), false, quoteView.getAttachments()));
} else {
return Optional.absent();
}
}
public void setLinkPreviewLoading() {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLoading();
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {
if (preview.isPresent()) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLinkPreview(glideRequests, preview.get(), true);
} else {
this.linkPreview.setVisibility(View.GONE);
public InputPanel(Context context, AttributeSet attrs) {
super(context, attrs);
}
int largeCornerRadius = (int)(16 * getResources().getDisplayMetrics().density);
int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius) : largeCornerRadius;
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
}
@Override
public void onRecordPressed() {
if (listener != null) listener.onRecorderStarted();
recordTime.display();
slideToCancel.display();
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
}
@Override
public void onRecordReleased() {
long elapsedTime = onRecordHideEvent();
if (listener != null) {
Log.d(TAG, "Elapsed time: " + elapsedTime);
if (elapsedTime > 1000) {
listener.onRecorderFinished();
} else {
Toast.makeText(getContext(), R.string.InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send, Toast.LENGTH_LONG).show();
listener.onRecorderCanceled();
}
}
}
@Override
public void onRecordMoved(float offsetX, float absoluteX) {
slideToCancel.moveTo(offsetX);
int direction = ViewCompat.getLayoutDirection(this);
float position = absoluteX / recordingContainer.getWidth();
if (direction == ViewCompat.LAYOUT_DIRECTION_LTR && position <= 0.5 ||
direction == ViewCompat.LAYOUT_DIRECTION_RTL && position >= 0.6)
{
this.microphoneRecorderView.cancelAction();
}
}
@Override
public void onRecordCanceled() {
onRecordHideEvent();
if (listener != null) listener.onRecorderCanceled();
}
@Override
public void onRecordLocked() {
slideToCancel.hide();
recordLockCancel.setVisibility(View.VISIBLE);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
if (listener != null) listener.onRecorderLocked();
}
public void onPause() {
this.microphoneRecorderView.cancelAction();
}
public void setEnabled(boolean enabled) {
composeText.setEnabled(enabled);
mediaKeyboard.setEnabled(enabled);
quickAudioToggle.setEnabled(enabled);
quickCameraToggle.setEnabled(enabled);
}
public void setHint(@NonNull String hint) {
composeText.setHint(hint, null);
}
private long onRecordHideEvent() {
recordLockCancel.setVisibility(View.GONE);
ListenableFuture<Void> future = slideToCancel.hide();
long elapsedTime = recordTime.hide();
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
ViewUtil.fadeIn(composeText, FADE_TIME);
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
}
});
return elapsedTime;
}
@Override
public void onKeyboardShown() {
mediaKeyboard.setToMedia();
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
composeText.dispatchKeyEvent(keyEvent);
}
@Override
public void onEmojiSelected(String emoji) {
composeText.insertEmoji(emoji);
}
private int readDimen(@DimenRes int dimenRes) {
return getResources().getDimensionPixelSize(dimenRes);
}
public boolean isRecordingInLockedMode() {
return microphoneRecorderView.isRecordingLocked();
}
public void releaseRecordingLock() {
microphoneRecorderView.unlockAction();
}
public interface Listener {
void onRecorderStarted();
void onRecorderLocked();
void onRecorderFinished();
void onRecorderCanceled();
void onRecorderPermissionRequired();
void onEmojiToggle();
void onLinkPreviewCanceled();
}
private static class SlideToCancel {
private final View slideToCancelView;
SlideToCancel(View slideToCancelView) {
this.slideToCancelView = slideToCancelView;
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void display() {
ViewUtil.fadeIn(this.slideToCancelView, FADE_TIME);
public interface MediaListener {
void onMediaSelected(@NonNull Uri uri, String contentType);
}
public ListenableFuture<Void> hide() {
final SettableFuture<Void> future = new SettableFuture<>();
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, slideToCancelView.getTranslationX(),
Animation.ABSOLUTE, 0,
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, 0));
animation.addAnimation(new AlphaAnimation(1, 0));
animation.setDuration(MicrophoneRecorderView.ANIMATION_DURATION);
animation.setFillBefore(true);
animation.setFillAfter(false);
slideToCancelView.postDelayed(() -> future.set(null), MicrophoneRecorderView.ANIMATION_DURATION);
slideToCancelView.setVisibility(View.GONE);
slideToCancelView.startAnimation(animation);
return future;
}
void moveTo(float offset) {
Animation animation = new TranslateAnimation(Animation.ABSOLUTE, offset,
Animation.ABSOLUTE, offset,
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, 0);
animation.setDuration(0);
animation.setFillAfter(true);
animation.setFillBefore(true);
slideToCancelView.startAnimation(animation);
}
}
private static class RecordTime implements Runnable {
private final @NonNull TextView recordTimeView;
private final @NonNull View microphone;
private final @NonNull Runnable onLimitHit;
private final long limitSeconds;
private long startTime;
private RecordTime(@NonNull TextView recordTimeView, @NonNull View microphone, long limitSeconds, @NonNull Runnable onLimitHit) {
this.recordTimeView = recordTimeView;
this.microphone = microphone;
this.limitSeconds = limitSeconds;
this.onLimitHit = onLimitHit;
}
@MainThread
public void display() {
this.startTime = System.currentTimeMillis();
this.recordTimeView.setText(DateUtils.formatElapsedTime(0));
ViewUtil.fadeIn(this.recordTimeView, FADE_TIME);
Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1));
microphone.setVisibility(View.VISIBLE);
microphone.startAnimation(pulseAnimation());
}
@MainThread
public long hide() {
long elapsedTime = System.currentTimeMillis() - startTime;
this.startTime = 0;
ViewUtil.fadeOut(this.recordTimeView, FADE_TIME, View.INVISIBLE);
microphone.clearAnimation();
ViewUtil.fadeOut(this.microphone, FADE_TIME, View.INVISIBLE);
return elapsedTime;
}
@Override
@MainThread
public void run() {
long localStartTime = startTime;
if (localStartTime > 0) {
long elapsedTime = System.currentTimeMillis() - localStartTime;
long elapsedSeconds = TimeUnit.MILLISECONDS.toSeconds(elapsedTime);
if (elapsedSeconds >= limitSeconds) {
onLimitHit.run();
} else {
recordTimeView.setText(DateUtils.formatElapsedTime(elapsedSeconds));
Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1));
}
}
}
private static Animation pulseAnimation() {
AlphaAnimation animation = new AlphaAnimation(0, 1);
animation.setInterpolator(pulseInterpolator());
animation.setRepeatCount(Animation.INFINITE);
animation.setDuration(1000);
return animation;
}
private static Interpolator pulseInterpolator() {
return input -> {
input *= 5;
if (input > 1) {
input = 4 - input;
}
return Math.max(0, Math.min(1, input));
};
}
}
public interface MediaListener {
void onMediaSelected(@NonNull Uri uri, String contentType);
}
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.Canvas
@ -9,7 +9,7 @@ import android.view.LayoutInflater
import android.widget.RelativeLayout
import kotlinx.android.synthetic.main.view_separator.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.utilities.toPx
import org.thoughtcrime.securesms.util.toPx
import org.session.libsession.utilities.ThemeUtil
class LabeledSeparatorView : RelativeLayout {

View File

@ -1,272 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.content.Context;
import android.graphics.PorterDuff;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.AnticipateOvershootInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.session.libsession.utilities.ViewUtil;
import network.loki.messenger.R;
public final class MicrophoneRecorderView extends FrameLayout implements View.OnTouchListener {
enum State {
NOT_RUNNING,
RUNNING_HELD,
RUNNING_LOCKED
}
public static final int ANIMATION_DURATION = 200;
private FloatingRecordButton floatingRecordButton;
private LockDropTarget lockDropTarget;
private @Nullable Listener listener;
private @NonNull State state = State.NOT_RUNNING;
public MicrophoneRecorderView(Context context) {
super(context);
}
public MicrophoneRecorderView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
floatingRecordButton = new FloatingRecordButton(getContext(), findViewById(R.id.quick_audio_fab));
lockDropTarget = new LockDropTarget (getContext(), findViewById(R.id.lock_drop_target));
View recordButton = ViewUtil.findById(this, R.id.quick_audio_toggle);
recordButton.setOnTouchListener(this);
}
public void cancelAction() {
if (state != State.NOT_RUNNING) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordCanceled();
}
}
public boolean isRecordingLocked() {
return state == State.RUNNING_LOCKED;
}
private void lockAction() {
if (state == State.RUNNING_HELD) {
state = State.RUNNING_LOCKED;
hideUi();
if (listener != null) listener.onRecordLocked();
}
}
public void unlockAction() {
if (state == State.RUNNING_LOCKED) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordReleased();
}
}
private void hideUi() {
floatingRecordButton.hide();
lockDropTarget.hide();
}
@Override
public boolean onTouch(View v, final MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) {
if (listener != null) listener.onRecordPermissionRequired();
} else {
state = State.RUNNING_HELD;
floatingRecordButton.display(event.getX(), event.getY());
lockDropTarget.display();
if (listener != null) listener.onRecordPressed();
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (this.state == State.RUNNING_HELD) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordReleased();
}
break;
case MotionEvent.ACTION_MOVE:
if (this.state == State.RUNNING_HELD) {
this.floatingRecordButton.moveTo(event.getX(), event.getY());
if (listener != null) listener.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX());
int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target);
if (floatingRecordButton.lastOffsetY <= dimensionPixelSize) {
lockAction();
}
}
break;
}
return false;
}
public void setListener(@Nullable Listener listener) {
this.listener = listener;
}
public interface Listener {
void onRecordPressed();
void onRecordReleased();
void onRecordCanceled();
void onRecordLocked();
void onRecordMoved(float offsetX, float absoluteX);
void onRecordPermissionRequired();
}
private static class FloatingRecordButton {
private final ImageView recordButtonFab;
private float startPositionX;
private float startPositionY;
private float lastOffsetX;
private float lastOffsetY;
FloatingRecordButton(Context context, ImageView recordButtonFab) {
this.recordButtonFab = recordButtonFab;
this.recordButtonFab.getBackground().setColorFilter(context.getResources()
.getColor(R.color.destructive),
PorterDuff.Mode.SRC_IN);
}
void display(float x, float y) {
this.startPositionX = x;
this.startPositionY = y;
recordButtonFab.setVisibility(View.VISIBLE);
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0));
animation.addAnimation(new ScaleAnimation(.5f, 1f, .5f, 1f,
Animation.RELATIVE_TO_SELF, .5f,
Animation.RELATIVE_TO_SELF, .5f));
animation.setDuration(ANIMATION_DURATION);
animation.setInterpolator(new OvershootInterpolator());
recordButtonFab.startAnimation(animation);
}
void moveTo(float x, float y) {
lastOffsetX = getXOffset(x);
lastOffsetY = getYOffset(y);
if (Math.abs(lastOffsetX) > Math.abs(lastOffsetY)) {
lastOffsetY = 0;
} else {
lastOffsetX = 0;
}
recordButtonFab.setTranslationX(lastOffsetX);
recordButtonFab.setTranslationY(lastOffsetY);
}
void hide() {
recordButtonFab.setTranslationX(0);
recordButtonFab.setTranslationY(0);
if (recordButtonFab.getVisibility() != VISIBLE) return;
AnimationSet animation = new AnimationSet(false);
Animation scaleAnimation = new ScaleAnimation(1, 0.5f, 1, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
Animation translateAnimation = new TranslateAnimation(Animation.ABSOLUTE, lastOffsetX,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, lastOffsetY,
Animation.ABSOLUTE, 0);
scaleAnimation.setInterpolator(new AnticipateOvershootInterpolator(1.5f));
translateAnimation.setInterpolator(new DecelerateInterpolator());
animation.addAnimation(scaleAnimation);
animation.addAnimation(translateAnimation);
animation.setDuration(ANIMATION_DURATION);
animation.setInterpolator(new AnticipateOvershootInterpolator(1.5f));
recordButtonFab.setVisibility(View.GONE);
recordButtonFab.clearAnimation();
recordButtonFab.startAnimation(animation);
}
private float getXOffset(float x) {
return ViewCompat.getLayoutDirection(recordButtonFab) == ViewCompat.LAYOUT_DIRECTION_LTR ?
-Math.max(0, this.startPositionX - x) : Math.max(0, x - this.startPositionX);
}
private float getYOffset(float y) {
return Math.min(0, y - this.startPositionY);
}
}
private static class LockDropTarget {
private final View lockDropTarget;
private final int dropTargetPosition;
LockDropTarget(Context context, View lockDropTarget) {
this.lockDropTarget = lockDropTarget;
this.dropTargetPosition = context.getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target);
}
void display() {
lockDropTarget.setScaleX(1);
lockDropTarget.setScaleY(1);
lockDropTarget.setAlpha(0);
lockDropTarget.setTranslationY(0);
lockDropTarget.setVisibility(VISIBLE);
lockDropTarget.animate()
.setStartDelay(ANIMATION_DURATION * 2)
.setDuration(ANIMATION_DURATION)
.setInterpolator(new DecelerateInterpolator())
.translationY(dropTargetPosition)
.alpha(1)
.start();
}
void hide() {
lockDropTarget.animate()
.setStartDelay(0)
.setDuration(ANIMATION_DURATION)
.setInterpolator(new LinearInterpolator())
.scaleX(0).scaleY(0)
.start();
}
}
}

View File

@ -5,6 +5,7 @@ import android.graphics.Canvas;
import android.util.AttributeSet;
import org.session.libsession.utilities.ThemeUtil;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import network.loki.messenger.R;
@ -28,7 +29,6 @@ public class OutlinedThumbnailView extends ThumbnailView {
outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
setRadius(0);
setWillNotDraw(false);
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
@ -17,7 +17,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.AvatarPlaceholderGenerator
import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator
import org.thoughtcrime.securesms.mms.GlideRequests
class ProfilePictureView : RelativeLayout {
@ -31,23 +31,12 @@ class ProfilePictureView : RelativeLayout {
private val profilePicturesCache = mutableMapOf<String, String?>()
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
private fun initialize() {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_profile_picture, null)
addView(contentView)

View File

@ -22,8 +22,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.loki.database.SessionContactDatabase;
import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities;
import org.thoughtcrime.securesms.database.SessionContactDatabase;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;

View File

@ -16,7 +16,7 @@ import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
import network.loki.messenger.R;

View File

@ -7,6 +7,7 @@ import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;

View File

@ -8,7 +8,7 @@ import org.session.libsession.messaging.messages.control.TypingIndicator;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.loki.protocol.SessionMetaProtocol;
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.Util;
@ -79,8 +79,7 @@ public class TypingStatusSender {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Recipient recipient = threadDatabase.getRecipientForThreadId(threadId);
if (recipient == null) { return; }
// Loki - Check whether we want to send a typing indicator to this user
if (recipient != null && !SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; }
if (!SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; }
TypingIndicator typingIndicator;
if (typingStarted) {
typingIndicator = new TypingIndicator(TypingIndicator.Kind.STARTED);

View File

@ -11,7 +11,6 @@ import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
@ -19,9 +18,7 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.guava.Optional;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private static final char ELLIPSIS = '…';
@ -46,14 +43,9 @@ public class EmojiTextView extends AppCompatTextView {
public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
originalFontSize = a.getDimensionPixelSize(0, 0);
a.recycle();
scaleEmojis = true;
maxLength = 1000;
originalFontSize = getResources().getDimension(R.dimen.small_font_size);
}
@Override public void setText(@Nullable CharSequence text, BufferType type) {
@ -182,8 +174,11 @@ public class EmojiTextView extends AppCompatTextView {
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
if (drawable instanceof EmojiDrawable) {
invalidate();
} else {
super.invalidateDrawable(drawable);
}
}
@Override

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.fragments
package org.thoughtcrime.securesms.contacts
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
@ -7,7 +7,7 @@ import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.contact_selection_list_divider.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.views.UserView
import org.thoughtcrime.securesms.contacts.UserView
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.utilities.recipients.Recipient

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.fragments
package org.thoughtcrime.securesms.contacts
import android.os.Bundle
import androidx.fragment.app.Fragment
@ -11,10 +11,11 @@ import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.contact_selection_list_fragment.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.mms.GlideApp
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem
import org.thoughtcrime.securesms.contacts.ContactSelectionListLoader
class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<List<ContactSelectionListItem>>, ContactClickListener {
private var cursorFilter: String? = null
@ -98,7 +99,7 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L
update(listOf())
}
private fun update(items: List<ContactSelectionListItem>) {
private fun update(items: List<ContactSelectionListItem>) {
if (activity?.isDestroyed == true) {
Log.e(ContactSelectionListFragment::class.java.name,
"Received a loader callback after the fragment was detached from the activity.",

View File

@ -1,8 +1,8 @@
package org.thoughtcrime.securesms.loki.fragments
package org.thoughtcrime.securesms.contacts
import android.content.Context
import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.utilities.ContactUtilities
import org.thoughtcrime.securesms.util.ContactUtilities
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.util.AsyncLoader

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.activities
package org.thoughtcrime.securesms.contacts
import android.app.Activity
import android.content.Intent
@ -40,8 +40,8 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana
setContentView(R.layout.activity_select_contacts)
supportActionBar!!.title = resources.getString(R.string.activity_select_contacts_title)
usersToExclude = intent.getStringArrayExtra(Companion.usersToExcludeKey)?.toSet() ?: setOf()
val emptyStateText = intent.getStringExtra(Companion.emptyStateTextKey)
usersToExclude = intent.getStringArrayExtra(usersToExcludeKey)?.toSet() ?: setOf()
val emptyStateText = intent.getStringExtra(emptyStateTextKey)
if (emptyStateText != null) {
emptyStateMessageTextView.text = emptyStateText
}

View File

@ -1,10 +1,9 @@
package org.thoughtcrime.securesms.loki.activities
package org.thoughtcrime.securesms.contacts
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import org.session.libsession.utilities.Address
import org.thoughtcrime.securesms.loki.views.UserView
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.utilities.recipients.Recipient

View File

@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.loki.activities
package org.thoughtcrime.securesms.contacts
import android.content.Context
import org.thoughtcrime.securesms.loki.utilities.ContactUtilities
import org.thoughtcrime.securesms.util.ContactUtilities
import org.thoughtcrime.securesms.util.AsyncLoader
class SelectContactsLoader(context: Context, val usersToExclude: Set<String>) : AsyncLoader<List<String>>(context) {

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.contacts
import android.content.Context
import android.util.AttributeSet
@ -10,7 +10,7 @@ import kotlinx.android.synthetic.main.view_user.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.utilities.recipients.Recipient

View File

@ -1,532 +0,0 @@
/*
* Copyright (C) 2011 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.conversation;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.HeaderViewHolder;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.FastCursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.LRUCache;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
import org.session.libsession.utilities.Conversions;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.Util;
import java.lang.ref.SoftReference;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import network.loki.messenger.R;
/**
* A cursor adapter for a conversation thread. Ultimately
* used by ComposeMessageActivity to display a conversation
* thread in a ListActivity.
*
* @author Moxie Marlinspike
*
*/
public class ConversationAdapter <V extends View & BindableConversationItem>
extends FastCursorRecyclerViewAdapter<ConversationAdapter.ViewHolder, MessageRecord>
implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder>
{
private static final int MAX_CACHE_SIZE = 1000;
private static final String TAG = ConversationAdapter.class.getSimpleName();
private final Map<String,SoftReference<MessageRecord>> messageRecordCache =
Collections.synchronizedMap(new LRUCache<String, SoftReference<MessageRecord>>(MAX_CACHE_SIZE));
private final SparseArray<String> positionToCacheRef = new SparseArray<>();
private static final int MESSAGE_TYPE_OUTGOING = 0;
private static final int MESSAGE_TYPE_INCOMING = 1;
private static final int MESSAGE_TYPE_UPDATE = 2;
private static final int MESSAGE_TYPE_AUDIO_OUTGOING = 3;
private static final int MESSAGE_TYPE_AUDIO_INCOMING = 4;
private static final int MESSAGE_TYPE_THUMBNAIL_OUTGOING = 5;
private static final int MESSAGE_TYPE_THUMBNAIL_INCOMING = 6;
private static final int MESSAGE_TYPE_DOCUMENT_OUTGOING = 7;
private static final int MESSAGE_TYPE_DOCUMENT_INCOMING = 8;
private static final int MESSAGE_TYPE_INVITATION_OUTGOING = 9;
private static final int MESSAGE_TYPE_INVITATION_INCOMING = 10;
private final Set<MessageRecord> batchSelected = Collections.synchronizedSet(new HashSet<MessageRecord>());
private final @Nullable ItemClickListener clickListener;
private final @NonNull
GlideRequests glideRequests;
private final @NonNull Locale locale;
private final @NonNull Recipient recipient;
private final @NonNull MmsSmsDatabase db;
private final @NonNull LayoutInflater inflater;
private final @NonNull Calendar calendar;
private final @NonNull MessageDigest digest;
private MessageRecord recordToPulseHighlight;
private String searchQuery;
protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
super(itemView);
}
@SuppressWarnings("unchecked")
public <V extends View & BindableConversationItem> V getView() {
return (V)itemView;
}
}
static class HeaderViewHolder extends RecyclerView.ViewHolder {
TextView textView;
HeaderViewHolder(View itemView) {
super(itemView);
textView = ViewUtil.findById(itemView, R.id.text);
}
HeaderViewHolder(TextView textView) {
super(textView);
this.textView = textView;
}
public void setText(CharSequence text) {
textView.setText(text);
}
}
interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(MessageRecord item);
void onItemLongClick(MessageRecord item);
}
@SuppressWarnings("ConstantConditions")
@VisibleForTesting
ConversationAdapter(Context context, Cursor cursor) {
super(context, cursor);
try {
this.glideRequests = null;
this.locale = null;
this.clickListener = null;
this.recipient = null;
this.inflater = null;
this.db = null;
this.calendar = null;
this.digest = MessageDigest.getInstance("SHA1");
} catch (NoSuchAlgorithmException nsae) {
throw new AssertionError("SHA1 isn't supported!");
}
}
public ConversationAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@Nullable Cursor cursor,
@NonNull Recipient recipient)
{
super(context, cursor);
try {
this.glideRequests = glideRequests;
this.locale = locale;
this.clickListener = clickListener;
this.recipient = recipient;
this.inflater = LayoutInflater.from(context);
this.db = DatabaseFactory.getMmsSmsDatabase(context);
this.calendar = Calendar.getInstance();
this.digest = MessageDigest.getInstance("SHA1");
setHasStableIds(true);
} catch (NoSuchAlgorithmException nsae) {
throw new AssertionError("SHA1 isn't supported!");
}
}
@Override
public void changeCursor(Cursor cursor) {
messageRecordCache.clear();
positionToCacheRef.clear();
super.cleanFastRecords();
super.changeCursor(cursor);
}
@Override
protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull MessageRecord messageRecord) {
int adapterPosition = viewHolder.getAdapterPosition();
String prevCachedId = positionToCacheRef.get(adapterPosition + 1,null);
String nextCachedId = positionToCacheRef.get(adapterPosition - 1, null);
MessageRecord previousRecord = null;
if (adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1)) {
if (prevCachedId != null && messageRecordCache.containsKey(prevCachedId)) {
SoftReference<MessageRecord> prevSoftRecord = messageRecordCache.get(prevCachedId);
MessageRecord prevCachedRecord = prevSoftRecord.get();
if (prevCachedRecord != null) {
previousRecord = prevCachedRecord;
} else {
previousRecord = getRecordForPositionOrThrow(adapterPosition + 1);
}
} else {
previousRecord = getRecordForPositionOrThrow(adapterPosition + 1);
}
}
MessageRecord nextRecord = null;
if (adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1)) {
if (nextCachedId != null && messageRecordCache.containsKey(nextCachedId)) {
SoftReference<MessageRecord> nextSoftRecord = messageRecordCache.get(nextCachedId);
MessageRecord nextCachedRecord = nextSoftRecord.get();
if (nextCachedRecord != null) {
nextRecord = nextCachedRecord;
} else {
nextRecord = getRecordForPositionOrThrow(adapterPosition - 1);
}
} else {
nextRecord = getRecordForPositionOrThrow(adapterPosition - 1);
}
}
viewHolder.getView().bind(messageRecord,
Optional.fromNullable(previousRecord),
Optional.fromNullable(nextRecord),
glideRequests,
locale,
batchSelected,
recipient,
searchQuery,
messageRecord == recordToPulseHighlight);
if (messageRecord == recordToPulseHighlight) {
recordToPulseHighlight = null;
}
}
@Override
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
long start = System.currentTimeMillis();
final V itemView = ViewUtil.inflate(inflater, parent, getLayoutForViewType(viewType));
itemView.setOnClickListener(view -> {
if (clickListener != null) {
clickListener.onItemClick(itemView.getMessageRecord());
}
});
itemView.setOnLongClickListener(view -> {
if (clickListener != null) {
clickListener.onItemLongClick(itemView.getMessageRecord());
}
return true;
});
itemView.setEventListener(clickListener);
Log.d(TAG, "Inflate time: " + (System.currentTimeMillis() - start));
return new ViewHolder(itemView);
}
@Override
public void onItemViewRecycled(ViewHolder holder) {
holder.getView().unbind();
}
private @LayoutRes int getLayoutForViewType(int viewType) {
switch (viewType) {
case MESSAGE_TYPE_AUDIO_OUTGOING:
case MESSAGE_TYPE_THUMBNAIL_OUTGOING:
case MESSAGE_TYPE_DOCUMENT_OUTGOING:
case MESSAGE_TYPE_INVITATION_OUTGOING:
case MESSAGE_TYPE_OUTGOING: return R.layout.conversation_item_sent;
case MESSAGE_TYPE_AUDIO_INCOMING:
case MESSAGE_TYPE_THUMBNAIL_INCOMING:
case MESSAGE_TYPE_DOCUMENT_INCOMING:
case MESSAGE_TYPE_INVITATION_INCOMING:
case MESSAGE_TYPE_INCOMING: return R.layout.conversation_item_received;
case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
default: throw new IllegalArgumentException("unsupported item view type given to ConversationAdapter");
}
}
@Override
public int getItemViewType(@NonNull MessageRecord messageRecord) {
if (messageRecord.isUpdate()) {
return MESSAGE_TYPE_UPDATE;
} else if (messageRecord.isOpenGroupInvitation()) {
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_INVITATION_OUTGOING;
else return MESSAGE_TYPE_INVITATION_INCOMING;
} else if (hasAudio(messageRecord)) {
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_AUDIO_OUTGOING;
else return MESSAGE_TYPE_AUDIO_INCOMING;
} else if (hasDocument(messageRecord)) {
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_DOCUMENT_OUTGOING;
else return MESSAGE_TYPE_DOCUMENT_INCOMING;
} else if (hasThumbnail(messageRecord)) {
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_THUMBNAIL_OUTGOING;
else return MESSAGE_TYPE_THUMBNAIL_INCOMING;
} else if (messageRecord.isOutgoing()) {
return MESSAGE_TYPE_OUTGOING;
} else {
return MESSAGE_TYPE_INCOMING;
}
}
@Override
protected boolean isRecordForId(@NonNull MessageRecord record, long id) {
return record.getId() == id;
}
@Override
public long getItemId(@NonNull Cursor cursor) {
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(cursor);
List<DatabaseAttachment> messageAttachments = Stream.of(attachments).filterNot(DatabaseAttachment::isQuote).toList();
if (messageAttachments.size() > 0 && messageAttachments.get(0).getFastPreflightId() != null) {
return Long.valueOf(messageAttachments.get(0).getFastPreflightId());
}
final String unique = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.UNIQUE_ROW_ID));
final byte[] bytes = digest.digest(unique.getBytes());
return Conversions.byteArrayToLong(bytes);
}
@Override
protected long getItemId(@NonNull MessageRecord record) {
if (record.isOutgoing() && record.isMms()) {
MmsMessageRecord mmsRecord = (MmsMessageRecord) record;
SlideDeck slideDeck = mmsRecord.getSlideDeck();
if (slideDeck.getThumbnailSlide() != null && slideDeck.getThumbnailSlide().getFastPreflightId() != null) {
return Long.valueOf(slideDeck.getThumbnailSlide().getFastPreflightId());
}
}
return record.getId();
}
@Override
protected MessageRecord getRecordFromCursor(@NonNull Cursor cursor) {
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID));
String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT));
final SoftReference<MessageRecord> reference = messageRecordCache.get(type + messageId);
if (reference != null) {
final MessageRecord record = reference.get();
if (record != null) return record;
}
final MessageRecord messageRecord = db.readerFor(cursor).getCurrent();
messageRecordCache.put(type + messageId, new SoftReference<>(messageRecord));
return messageRecord;
}
public void close() {
getCursor().close();
}
public int findLastSeenPosition(long lastSeen) {
if (lastSeen <= 0) return -1;
if (!isActiveCursor()) return -1;
int count = getItemCount() - (hasFooterView() ? 1 : 0);
for (int i=(hasHeaderView() ? 1 : 0);i<count;i++) {
MessageRecord messageRecord = getRecordForPositionOrThrow(i);
if (messageRecord.isOutgoing() || messageRecord.getDateReceived() <= lastSeen) {
return i;
}
}
return -1;
}
public void toggleSelection(MessageRecord messageRecord) {
if (!batchSelected.remove(messageRecord)) {
batchSelected.add(messageRecord);
}
}
public void clearSelection() {
batchSelected.clear();
}
public Set<MessageRecord> getSelectedItems() {
return Collections.unmodifiableSet(new HashSet<>(batchSelected));
}
public void pulseHighlightItem(int position) {
if (position < getItemCount()) {
recordToPulseHighlight = getRecordForPositionOrThrow(position);
notifyItemChanged(position);
}
}
public void onSearchQueryUpdated(@Nullable String query) {
this.searchQuery = query;
notifyDataSetChanged();
}
private boolean hasAudio(MessageRecord messageRecord) {
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
}
private boolean hasDocument(MessageRecord messageRecord) {
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide() != null;
}
private boolean hasThumbnail(MessageRecord messageRecord) {
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null;
}
@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;
MessageRecord record = getRecordForPositionOrThrow(position);
if (record.getRecipient().getAddress().isOpenGroup()) {
calendar.setTime(new Date(record.getDateReceived()));
} else {
calendar.setTime(new Date(record.getDateSent()));
}
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
}
public long getReceivedTimestamp(int position) {
if (!isActiveCursor()) return 0;
if (isHeaderPosition(position)) return 0;
if (isFooterPosition(position)) return 0;
if (position >= getItemCount()) return 0;
if (position < 0) return 0;
MessageRecord messageRecord = getRecordForPositionOrThrow(position);
if (messageRecord.isOutgoing()) return 0;
else return messageRecord.getDateReceived();
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) {
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_header, parent, false));
}
public HeaderViewHolder onCreateLastSeenViewHolder(ViewGroup parent) {
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_last_seen, parent, false));
}
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
MessageRecord messageRecord = getRecordForPositionOrThrow(position);
long timestamp = messageRecord.getDateReceived();
if (recipient.getAddress().isOpenGroup()) { timestamp = messageRecord.getTimestamp(); }
viewHolder.setText(DateUtils.getRelativeDate(getContext(), locale, timestamp));
}
public void onBindLastSeenViewHolder(HeaderViewHolder viewHolder, int position) {
viewHolder.setText(getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
}
static class LastSeenHeader extends StickyHeaderDecoration {
private final ConversationAdapter adapter;
private final long lastSeenTimestamp;
LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) {
super(adapter, false, false);
this.adapter = adapter;
this.lastSeenTimestamp = lastSeenTimestamp;
}
@Override
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
if (!adapter.isActiveCursor()) {
return false;
}
if (lastSeenTimestamp <= 0) {
return false;
}
long currentRecordTimestamp = adapter.getReceivedTimestamp(position);
long previousRecordTimestamp = adapter.getReceivedTimestamp(position + 1);
return currentRecordTimestamp > lastSeenTimestamp && previousRecordTimestamp < lastSeenTimestamp;
}
@Override
protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
return parent.getLayoutManager().getDecoratedTop(child);
}
@Override
protected HeaderViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
HeaderViewHolder viewHolder = adapter.onCreateLastSeenViewHolder(parent);
adapter.onBindLastSeenViewHolder(viewHolder, position);
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), viewHolder.itemView.getLayoutParams().width);
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), viewHolder.itemView.getLayoutParams().height);
viewHolder.itemView.measure(childWidth, childHeight);
viewHolder.itemView.layout(0, 0, viewHolder.itemView.getMeasuredWidth(), viewHolder.itemView.getMeasuredHeight());
return viewHolder;
}
}
}

View File

@ -1,119 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Intent;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import androidx.core.app.ActivityOptionsCompat;
import android.view.Display;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.WindowManager;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ListenableFuture;
import java.util.concurrent.ExecutionException;
import network.loki.messenger.R;
public class ConversationPopupActivity extends ConversationActivity {
private static final String TAG = ConversationPopupActivity.class.getSimpleName();
@Override
protected void onPreCreate() {
super.onPreCreate();
overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top);
}
@Override
protected void onCreate(Bundle bundle, boolean ready) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND,
WindowManager.LayoutParams.FLAG_DIM_BEHIND);
WindowManager.LayoutParams params = getWindow().getAttributes();
params.alpha = 1.0f;
params.dimAmount = 0.1f;
params.gravity = Gravity.TOP;
getWindow().setAttributes(params);
Display display = getWindowManager().getDefaultDisplay();
int width = display.getWidth();
int height = display.getHeight();
if (height > width) getWindow().setLayout((int) (width * .85), (int) (height * .5));
else getWindow().setLayout((int) (width * .7), (int) (height * .75));
super.onCreate(bundle, ready);
}
@Override
protected void onResume() {
super.onResume();
composeText.requestFocus();
quickAttachmentToggle.disable();
}
@Override
protected void onPause() {
super.onPause();
if (isFinishing()) overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
MenuInflater inflater = this.getMenuInflater();
menu.clear();
inflater.inflate(R.menu.conversation_popup, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_expand:
saveDraft().addListener(new ListenableFuture.Listener<Long>() {
@Override
public void onSuccess(Long result) {
ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height);
Intent intent = new Intent(ConversationPopupActivity.this, ConversationActivity.class);
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, getRecipient().getAddress());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, result);
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
startActivity(intent, transition.toBundle());
} else {
startActivity(intent);
overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right);
}
finish();
}
@Override
public void onFailure(ExecutionException e) {
Log.w(TAG, e);
}
});
return true;
}
return false;
}
@Override
protected void initializeActionBar() {
super.initializeActionBar();
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
}
@Override
protected void sendComplete(long threadId) {
super.sendComplete(threadId);
finish();
}
}

View File

@ -1,146 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.app.Application;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.util.CloseableLiveData;
import org.session.libsession.utilities.Debouncer;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.concurrent.SignalExecutors;
import java.io.Closeable;
import java.util.List;
public class ConversationSearchViewModel extends AndroidViewModel {
private final SearchRepository searchRepository;
private final CloseableLiveData<SearchResult> result;
private final Debouncer debouncer;
private boolean firstSearch;
private boolean searchOpen;
private String activeQuery;
private long activeThreadId;
public ConversationSearchViewModel(@NonNull Application application) {
super(application);
Context context = application.getApplicationContext();
result = new CloseableLiveData<>();
debouncer = new Debouncer(500);
searchRepository = new SearchRepository(context,
DatabaseFactory.getSearchDatabase(context),
DatabaseFactory.getThreadDatabase(context),
ContactAccessor.getInstance(),
SignalExecutors.SERIAL);
}
LiveData<SearchResult> getSearchResults() {
return result;
}
void onQueryUpdated(@NonNull String query, long threadId) {
if (firstSearch && query.length() < 2) {
result.postValue(new SearchResult(CursorList.emptyList(), 0));
return;
}
if (query.equals(activeQuery)) {
return;
}
updateQuery(query, threadId);
}
void onMissingResult() {
if (activeQuery != null) {
updateQuery(activeQuery, activeThreadId);
}
}
void onMoveUp() {
debouncer.clear();
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
int position = Math.min(result.getValue().getPosition() + 1, messages.size() - 1);
result.setValue(new SearchResult(messages, position), false);
}
void onMoveDown() {
debouncer.clear();
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
int position = Math.max(result.getValue().getPosition() - 1, 0);
result.setValue(new SearchResult(messages, position), false);
}
void onSearchOpened() {
searchOpen = true;
firstSearch = true;
}
void onSearchClosed() {
searchOpen = false;
debouncer.clear();
result.close();
}
@Override
protected void onCleared() {
super.onCleared();
result.close();
}
private void updateQuery(@NonNull String query, long threadId) {
activeQuery = query;
activeThreadId = threadId;
debouncer.publish(() -> {
firstSearch = false;
searchRepository.query(query, threadId, messages -> {
Util.runOnMain(() -> {
if (searchOpen && query.equals(activeQuery)) {
result.setValue(new SearchResult(messages, 0));
} else {
messages.close();
}
});
});
});
}
static class SearchResult implements Closeable {
private final CursorList<MessageResult> results;
private final int position;
SearchResult(CursorList<MessageResult> results, int position) {
this.results = results;
this.position = position;
}
public List<MessageResult> getResults() {
return results;
}
public int getPosition() {
return position;
}
@Override
public void close() {
results.close();
}
}
}

View File

@ -1,289 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.loki.utilities.GeneralUtilitiesKt;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.DateUtils;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.recipients.RecipientModifiedListener;
import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.Util;
import java.util.Locale;
import java.util.Set;
import network.loki.messenger.R;
//TODO Remove this class.
public class ConversationUpdateItem extends LinearLayout
implements RecipientModifiedListener, BindableConversationItem
{
private static final String TAG = ConversationUpdateItem.class.getSimpleName();
private Set<MessageRecord> batchSelected;
private ImageView icon;
private TextView title;
private TextView body;
private TextView date;
private Recipient sender;
private MessageRecord messageRecord;
private Locale locale;
public ConversationUpdateItem(Context context) {
super(context);
}
public ConversationUpdateItem(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
this.icon = findViewById(R.id.conversation_update_icon);
this.title = findViewById(R.id.conversation_update_title);
this.body = findViewById(R.id.conversation_update_body);
this.date = findViewById(R.id.conversation_update_date);
this.setOnClickListener(new InternalClickListener(null));
}
@Override
public void bind(@NonNull MessageRecord messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseUpdate)
{
this.batchSelected = batchSelected;
bind(messageRecord, locale);
}
@Override
public void setEventListener(@Nullable EventListener listener) {
// No events to report yet
}
@Override
public MessageRecord getMessageRecord() {
return messageRecord;
}
private void bind(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
this.messageRecord = messageRecord;
this.sender = messageRecord.getIndividualRecipient();
this.locale = locale;
this.sender.addListener(this);
if (messageRecord.isGroupAction()) setGroupRecord(messageRecord);
else if (messageRecord.isCallLog()) setCallRecord(messageRecord);
else if (messageRecord.isJoined()) setJoinedRecord(messageRecord);
else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord);
else if (messageRecord.isScreenshotExtraction()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT);
else if (messageRecord.isMediaSavedExtraction()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED);
else if (messageRecord.isEndSession()) setEndSessionRecord(messageRecord);
else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord);
else if (messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord);
else if (messageRecord.isLokiSessionRestoreSent()) setTextMessageRecord(messageRecord);
else if (messageRecord.isLokiSessionRestoreDone()) setTextMessageRecord(messageRecord);
else throw new AssertionError("Neither group nor log nor joined.");
if (batchSelected.contains(messageRecord)) setSelected(true);
else setSelected(false);
}
private void setCallRecord(MessageRecord messageRecord) {
if (messageRecord.isIncomingCall()) icon.setImageResource(R.drawable.ic_call_received_grey600_24dp);
else if (messageRecord.isOutgoingCall()) icon.setImageResource(R.drawable.ic_call_made_grey600_24dp);
else icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp);
body.setText(messageRecord.getDisplayBody(getContext()));
date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(View.VISIBLE);
}
private void setTimerRecord(final MessageRecord messageRecord) {
@ColorInt int color = GeneralUtilitiesKt.getColorWithID(getResources(), R.color.text, getContext().getTheme());
if (messageRecord.getExpiresIn() > 0) {
icon.setImageResource(R.drawable.ic_timer);
icon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY));
} else {
icon.setImageResource(R.drawable.ic_timer_disabled);
icon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY));
}
title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000)));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(VISIBLE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setDataExtractionRecord(final MessageRecord messageRecord, DataExtractionNotificationInfoMessage.Kind kind) {
@ColorInt int color = GeneralUtilitiesKt.getColorWithID(getResources(), R.color.text, getContext().getTheme());
if (kind == DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) {
icon.setImageResource(R.drawable.quick_camera_dark);
} else if (kind == DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) {
icon.setImageResource(R.drawable.ic_file_download_white_36dp);
}
icon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(VISIBLE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setIdentityRecord(final MessageRecord messageRecord) {
icon.setImageResource(R.drawable.ic_security_white_24dp);
icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setIdentityVerifyUpdate(final MessageRecord messageRecord) {
if (messageRecord.isIdentityVerified()) icon.setImageResource(R.drawable.ic_check_white_24dp);
else icon.setImageResource(R.drawable.ic_info_outline_white_24dp);
icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setGroupRecord(MessageRecord messageRecord) {
icon.setImageResource(R.drawable.ic_group_grey600_24dp);
icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setJoinedRecord(MessageRecord messageRecord) {
icon.setImageResource(R.drawable.ic_favorite_grey600_24dp);
icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setEndSessionRecord(MessageRecord messageRecord) {
icon.setImageResource(R.drawable.ic_refresh_white_24dp);
icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setTextMessageRecord(MessageRecord messageRecord) {
body.setText(messageRecord.getDisplayBody(getContext()));
icon.setVisibility(GONE);
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
@Override
public void onModified(Recipient recipient) {
Util.runOnMain(() -> bind(messageRecord, locale));
}
@Override
public void setOnClickListener(View.OnClickListener l) {
super.setOnClickListener(new InternalClickListener(l));
}
@Override
public void unbind() {
if (sender != null) {
sender.removeListener(this);
}
}
private class InternalClickListener implements View.OnClickListener {
@Nullable private final View.OnClickListener parent;
InternalClickListener(@Nullable View.OnClickListener parent) {
this.parent = parent;
}
@Override
public void onClick(View v) {
if ((!messageRecord.isIdentityUpdate() &&
!messageRecord.isIdentityDefault() &&
!messageRecord.isIdentityVerified()) ||
!batchSelected.isEmpty())
{
if (parent != null) parent.onClick(v);
return;
}
final Recipient sender = ConversationUpdateItem.this.sender;
// IdentityUtil.getRemoteIdentityKey(getContext(), sender).addListener(new ListenableFuture.Listener<Optional<IdentityRecord>>() {
// @Override
// public void onSuccess(Optional<IdentityRecord> result) {
// if (result.isPresent()) {
// Intent intent = new Intent(getContext(), VerifyIdentityActivity.class);
// intent.putExtra(VerifyIdentityActivity.ADDRESS_EXTRA, sender.getAddress());
// intent.putExtra(VerifyIdentityActivity.IDENTITY_EXTRA, new IdentityKeyParcelable(result.get().getIdentityKey()));
// intent.putExtra(VerifyIdentityActivity.VERIFIED_EXTRA, result.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED);
//
// getContext().startActivity(intent);
// }
// }
//
// @Override
// public void onFailure(ExecutionException e) {
// Log.w(TAG, e);
// }
// });
}
}
}

View File

@ -0,0 +1,143 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.database.Cursor
import android.graphics.Rect
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import kotlinx.android.synthetic.main.view_visible_message.view.*
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit,
private val glide: GlideRequests)
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
private val messageDB = DatabaseFactory.getMmsSmsDatabase(context)
var selectedItems = mutableSetOf<MessageRecord>()
private var searchQuery: String? = null
var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null
sealed class ViewType(val rawValue: Int) {
object Visible : ViewType(0)
object Control : ViewType(1)
companion object {
val allValues: Map<Int, ViewType> get() = mapOf(
Visible.rawValue to Visible,
Control.rawValue to Control
)
}
}
class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view)
class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view)
override fun getItemViewType(cursor: Cursor): Int {
val message = getMessage(cursor)!!
if (message.isControlMessage) { return ViewType.Control.rawValue }
return ViewType.Visible.rawValue
}
override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@Suppress("NAME_SHADOWING")
val viewType = ViewType.allValues[viewType]
when (viewType) {
ViewType.Visible -> {
val view = VisibleMessageView(context)
return VisibleMessageViewHolder(view)
}
ViewType.Control -> {
val view = ControlMessageView(context)
return ControlMessageViewHolder(view)
}
else -> throw IllegalStateException("Unexpected view type: $viewType.")
}
}
override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) {
val message = getMessage(cursor)!!
when (viewHolder) {
is VisibleMessageViewHolder -> {
val view = viewHolder.view
val isSelected = selectedItems.contains(message)
view.snIsSelected = isSelected
view.messageTimestampTextView.isVisible = isSelected
val position = viewHolder.adapterPosition
view.viewHolderIndex = position
view.bind(message, getMessageBefore(position, cursor), getMessageAfter(position, cursor), glide, searchQuery)
view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) }
view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) }
view.contentViewDelegate = visibleMessageContentViewDelegate
}
is ControlMessageViewHolder -> viewHolder.view.bind(message)
}
}
override fun onItemViewRecycled(viewHolder: ViewHolder?) {
when (viewHolder) {
is VisibleMessageViewHolder -> viewHolder.view.recycle()
is ControlMessageViewHolder -> viewHolder.view.recycle()
}
super.onItemViewRecycled(viewHolder)
}
private fun getMessage(cursor: Cursor): MessageRecord? {
return messageDB.readerFor(cursor).current
}
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually before the current one is actually after the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position + 1)) { return null }
return messageDB.readerFor(cursor).current
}
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually after the current one is actually before the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position - 1)) { return null }
return messageDB.readerFor(cursor).current
}
fun toggleSelection(message: MessageRecord, position: Int) {
if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message)
notifyItemChanged(position)
}
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
val cursor = this.cursor
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i }
}
return null
}
fun getItemPositionForTimestamp(timestamp: Long): Int? {
val cursor = this.cursor
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.dateSent == timestamp) { return i }
}
return null
}
fun onSearchQueryUpdated(query: String?) {
this.searchQuery = query
notifyDataSetChanged()
}
}

View File

@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.util.AbstractCursorLoader
class ConversationLoader(private val threadID: Long, context: Context) : AbstractCursorLoader(context) {
override fun getCursor(): Cursor {
return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadID)
}
}

View File

@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.VelocityTracker
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.activity_conversation_v2.*
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.toPx
import kotlin.math.abs
import kotlin.math.max
class ConversationRecyclerView : RecyclerView {
private val maxLongPressVelocityY = toPx(10, resources)
private val minSwipeVelocityX = toPx(10, resources)
private var velocityTracker: VelocityTracker? = null
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
disableClipping()
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
val velocityTracker = velocityTracker ?: return super.onInterceptTouchEvent(e)
velocityTracker.computeCurrentVelocity(1000) // Specifying 1000 gives pixels per second
val vx = velocityTracker.xVelocity
val vy = velocityTracker.yVelocity
// Only allow swipes to the left; allowing swipes to the right interferes with some back gestures
if (vx > 0) { return super.onInterceptTouchEvent(e) }
// Distinguish between scrolling gestures and long presses
if (abs(vy) > maxLongPressVelocityY && abs(vx) < minSwipeVelocityX) { return super.onInterceptTouchEvent(e) }
// Return false if abs(v.x) > abs(v.y) so that only swipes that are more horizontal than vertical
// get passed on to the message view
if (abs(vx) > abs(vy)) {
return false
} else {
return super.onInterceptTouchEvent(e)
}
}
override fun dispatchTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
MotionEvent.ACTION_DOWN -> velocityTracker = VelocityTracker.obtain()
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> velocityTracker = null
}
velocityTracker?.addMovement(e)
return super.dispatchTouchEvent(e)
}
}

View File

@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.children
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.album_thumbnail_view.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MediaPreviewActivity
import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.longmessage.LongMessageActivity
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.video.exo.AttachmentDataSource
import kotlin.math.roundToInt
class AlbumThumbnailView : FrameLayout {
companion object {
const val MAX_ALBUM_DISPLAY_SIZE = 5
}
// region Lifecycle
constructor(context: Context) : super(context) {
initialize()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initialize()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initialize()
}
private val cornerMask by lazy { CornerMask(this) }
private var slides: List<Slide> = listOf()
private var slideSize: Int = 0
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.album_thumbnail_view, this)
}
override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas)
cornerMask.mask(canvas)
}
// endregion
// region Interaction
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient) {
val rawXInt = event.rawX.toInt()
val rawYInt = event.rawY.toInt()
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
// Z-check in specific order
val testRect = Rect()
// test "Read More"
albumCellBodyTextReadMore.getGlobalVisibleRect(testRect)
if (testRect.contains(eventRect)) {
// dispatch to activity view
ActivityDispatcher.get(context)?.dispatchIntent { context ->
LongMessageActivity.getIntent(context, mms.recipient.address, mms.getId(), true)
}
return
}
// test each album child
albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child ->
child.getGlobalVisibleRect(testRect)
if (testRect.contains(eventRect)) {
// hit intersects with this particular child
val slide = slides.getOrNull(index) ?: return
// only open to downloaded images
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// restart download here
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
val attachmentId = attachment.attachmentId.rowId
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mms.getId()))
}
}
if (slide.isInProgress) return
ActivityDispatcher.get(context)?.dispatchIntent { context ->
MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient)
}
}
}
}
fun bind(glideRequests: GlideRequests, message: MmsMessageRecord,
isStart: Boolean, isEnd: Boolean) {
slides = message.slideDeck.thumbnailSlides
if (slides.isEmpty()) {
// this should never be encountered because it's checked by parent
return
}
calculateRadius(isStart, isEnd, message.isOutgoing)
// recreate cell views if different size to what we have already (for recycling)
if (slides.size != this.slideSize) {
albumCellContainer.removeAllViews()
LayoutInflater.from(context).inflate(layoutRes(slides.size), albumCellContainer)
val overflowed = slides.size > MAX_ALBUM_DISPLAY_SIZE
albumCellContainer.findViewById<TextView>(R.id.album_cell_overflow_text)?.let { overflowText ->
// overflowText will be null if !overflowed
overflowText.isVisible = overflowed // more than max album size
overflowText.text = context.getString(R.string.AlbumThumbnailView_plus, slides.size - MAX_ALBUM_DISPLAY_SIZE)
}
this.slideSize = slides.size
}
// iterate binding
slides.take(5).forEachIndexed { position, slide ->
val thumbnailView = getThumbnailView(position)
thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message)
}
albumCellBodyParent.isVisible = message.body.isNotEmpty()
albumCellBodyText.text = message.body
post {
// post to await layout of text
albumCellBodyText.layout?.let { layout ->
val maxEllipsis = (0 until layout.lineCount).maxByOrNull { lineNum -> layout.getEllipsisCount(lineNum) }
?: 0
// show read more text if at least one line is ellipsized
ViewUtil.setPaddingTop(albumCellBodyTextParent, if (maxEllipsis > 0) resources.getDimension(R.dimen.small_spacing).roundToInt() else resources.getDimension(R.dimen.medium_spacing).roundToInt())
albumCellBodyTextReadMore.isVisible = maxEllipsis > 0
}
}
}
// endregion
fun layoutRes(slideCount: Int) = when (slideCount) {
1 -> R.layout.album_thumbnail_1 // single
2 -> R.layout.album_thumbnail_2// two sidebyside
3 -> R.layout.album_thumbnail_3// three stacked
4 -> R.layout.album_thumbnail_4// four square
5 -> R.layout.album_thumbnail_5//
else -> R.layout.album_thumbnail_many// five or more
}
fun getThumbnailView(position: Int): KThumbnailView = when (position) {
0 -> albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1)
1 -> albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2)
2 -> albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3)
3 -> albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_4)
4 -> albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_5)
else -> throw Exception("Can't get thumbnail view for non-existent thumbnail at position: $position")
}
fun calculateRadius(isStart: Boolean, isEnd: Boolean, outgoing: Boolean) {
val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).toInt()
val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).toInt()
val (startTop, endTop, startBottom, endBottom) = when {
// single message, consistent dimen
isStart && isEnd -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen)
// start of message cluster, collapsed BL
isStart -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen)
// end of message cluster, collapsed TL
isEnd -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen)
// else in the middle, no rounding left side
else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen)
}
// TL, TR, BR, BL (CW direction)
cornerMask.setRadii(
if (!outgoing) startTop else endTop, // TL
if (!outgoing) endTop else startTop, // TR
if (!outgoing) endBottom else startBottom, // BR
if (!outgoing) startBottom else endBottom // BL
)
}
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.components;
package org.thoughtcrime.securesms.conversation.v2.components;
import android.content.Context;
import androidx.annotation.NonNull;
@ -118,5 +118,4 @@ public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImag
Util.runOnMainDelayed(this, timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn));
}
}
}

View File

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_link_preview_draft.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.ImageSlide
class LinkPreviewDraftView : LinearLayout {
var delegate: LinkPreviewDraftViewDelegate? = null
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
// Start out with the loader showing and the content view hidden
LayoutInflater.from(context).inflate(R.layout.view_link_preview_draft, this)
linkPreviewDraftContainer.isVisible = false
thumbnailImageView.clipToOutline = true
linkPreviewDraftCancelButton.setOnClickListener { cancel() }
}
fun update(glide: GlideRequests, linkPreview: LinkPreview) {
// Hide the loader and show the content view
linkPreviewDraftContainer.isVisible = true
linkPreviewDraftLoader.isVisible = false
thumbnailImageView.radius = toPx(4, resources)
if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail
thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false)
}
linkPreviewDraftTitleTextView.text = linkPreview.title
}
private fun cancel() {
delegate?.cancelLinkPreviewDraft()
}
}
interface LinkPreviewDraftViewDelegate {
fun cancelLinkPreviewDraft()
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.util.AttributeSet
@ -8,7 +8,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.toPx
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.messaging.mentions.Mention

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.util.AttributeSet
@ -30,7 +30,7 @@ class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr:
}
private fun update() {
btnGroupNameDisplay.text = mentionCandidate.displayName
mentionCandidateNameTextView.text = mentionCandidate.displayName
profilePictureView.publicKey = mentionCandidate.publicKey
profilePictureView.displayName = mentionCandidate.displayName
profilePictureView.additionalPublicKey = null

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.views
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.content.Intent
@ -7,30 +7,22 @@ import android.view.LayoutInflater
import android.widget.FrameLayout
import kotlinx.android.synthetic.main.view_open_group_guidelines.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.loki.activities.OpenGroupGuidelinesActivity
import org.thoughtcrime.securesms.loki.utilities.push
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.groups.OpenGroupGuidelinesActivity
import org.thoughtcrime.securesms.util.push
class OpenGroupGuidelinesView : FrameLayout {
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
private fun initialize() {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_open_group_guidelines, null)
addView(contentView)
readButton.setOnClickListener {
val activity = context as ConversationActivity
val activity = context as ConversationActivityV2
val intent = Intent(activity, OpenGroupGuidelinesActivity::class.java)
activity.push(intent)
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.components;
package org.thoughtcrime.securesms.conversation.v2.components;
import android.content.Context;
import android.content.res.TypedArray;
@ -13,18 +13,14 @@ import android.widget.LinearLayout;
import network.loki.messenger.R;
public class TypingIndicatorView extends LinearLayout {
private boolean isActive;
private long startTime;
private static final long DURATION = 300;
private static final long PRE_DELAY = 500;
private static final long POST_DELAY = 500;
private static final long CYCLE_DURATION = 1500;
private static final long DOT_DURATION = 600;
private static final float MIN_ALPHA = 0.4f;
private static final float MIN_SCALE = 0.75f;
private boolean isActive;
private long startTime;
private View dot1;
private View dot2;
private View dot3;
@ -40,7 +36,7 @@ public class TypingIndicatorView extends LinearLayout {
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.typing_indicator_view, this);
inflate(getContext(), R.layout.view_typing_indicator, this);
setWillNotDraw(false);

View File

@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.view_conversation_typing_container.view.*
import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient
class TypingIndicatorViewContainer : LinearLayout {
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_conversation_typing_container, this)
}
fun setTypists(typists: List<Recipient>) {
if (typists.isEmpty()) { typingIndicator.stopAnimation(); return }
typingIndicator.startAnimation()
}
}

View File

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.dialog_blocked.view.*
import kotlinx.android.synthetic.main.dialog_blocked.view.cancelButton
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.database.DatabaseFactory
/** Shown upon sending a message to a user that's blocked. */
class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
override fun setContentView(builder: AlertDialog.Builder) {
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_blocked, null)
val contactDB = DatabaseFactory.getSessionContactDatabase(requireContext())
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_blocked_title, name)
contentView.blockedTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
contentView.blockedExplanationTextView.text = spannable
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.unblockButton.setOnClickListener { unblock() }
builder.setView(contentView)
}
private fun unblock() {
DatabaseFactory.getRecipientDatabase(requireContext()).setBlocked(recipient, false)
dismiss()
}
}

View File

@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.dialog_download.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.database.DatabaseFactory
/** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */
class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
override fun setContentView(builder: AlertDialog.Builder) {
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_download, null)
val contactDB = DatabaseFactory.getSessionContactDatabase(requireContext())
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_download_title, name)
contentView.downloadTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_download_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
contentView.downloadExplanationTextView.text = spannable
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.downloadButton.setOnClickListener { trust() }
builder.setView(contentView)
}
private fun trust() {
val contactDB = DatabaseFactory.getSessionContactDatabase(requireContext())
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) ?: return
val threadID = DatabaseFactory.getThreadDatabase(requireContext()).getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID)
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss()
}
}

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.dialog_join_open_group.view.*
import network.loki.messenger.R
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon tapping an open group invitation. */
class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() {
override fun setContentView(builder: AlertDialog.Builder) {
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_join_open_group, null)
val title = resources.getString(R.string.dialog_join_open_group_title, name)
contentView.joinOpenGroupTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
contentView.joinOpenGroupExplanationTextView.text = spannable
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.joinButton.setOnClickListener { join() }
builder.setView(contentView)
}
private fun join() {
val openGroup = OpenGroupUrlParser.parseUrl(url)
val activity = requireContext() as AppCompatActivity
ThreadUtils.queue {
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
}
dismiss()
}
}

View File

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.dialog_link_preview.view.*
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
/** Shown the first time the user inputs a URL that could generate a link preview, to
* let them know that Session offers the ability to send and receive link previews. */
class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
override fun setContentView(builder: AlertDialog.Builder) {
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_link_preview, null)
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.enableLinkPreviewsButton.setOnClickListener { enable() }
builder.setView(contentView)
}
private fun enable() {
TextSecurePreferences.setLinkPreviewsEnabled(requireContext(), true)
dismiss()
onEnabled()
}
}

View File

@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.dialog_open_url.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
/** Shown upon tapping a URL. */
class OpenURLDialog(private val url: String) : BaseDialog() {
override fun setContentView(builder: AlertDialog.Builder) {
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_open_url, null)
val explanation = resources.getString(R.string.dialog_open_url_explanation, url)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(url)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + url.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
contentView.openURLExplanationTextView.text = spannable
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.openURLButton.setOnClickListener { open() }
builder.setView(contentView)
}
private fun open() {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
requireContext().startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, R.string.invalid_url, Toast.LENGTH_SHORT).show()
}
dismiss()
}
}

View File

@ -0,0 +1,196 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.content.Context
import android.content.res.Resources
import android.text.InputType
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.widget.RelativeLayout
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_input_bar.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftView
import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.QuoteView
import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.mms.GlideRequests
import kotlin.math.max
import kotlin.math.roundToInt
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate {
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val vMargin by lazy { toDp(4, resources) }
private val minHeight by lazy { toPx(56, resources) }
private var linkPreviewDraftView: LinkPreviewDraftView? = null
var delegate: InputBarDelegate? = null
var additionalContentHeight = 0
var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null
var showInput: Boolean = true
set(value) { field = value; showOrHideInputIfNeeded() }
var text: String
get() { return inputBarEditText.text?.toString() ?: "" }
set(value) { inputBarEditText.setText(value) }
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24) }
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone) }
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true) }
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_input_bar, this)
// Attachments button
attachmentsButtonContainer.addView(attachmentsButton)
attachmentsButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
attachmentsButton.onPress = { toggleAttachmentOptions() }
// Microphone button
microphoneOrSendButtonContainer.addView(microphoneButton)
microphoneButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
microphoneButton.onLongPress = { startRecordingVoiceMessage() }
microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) }
microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) }
microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) }
// Send button
microphoneOrSendButtonContainer.addView(sendButton)
sendButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
sendButton.isVisible = false
sendButton.onUp = { delegate?.sendMessage() }
// Edit text
inputBarEditText.imeOptions = inputBarEditText.imeOptions or 16777216 // Always use incognito keyboard
inputBarEditText.inputType = inputBarEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
inputBarEditText.delegate = this
}
// endregion
// region General
private fun setHeight(newHeight: Int) {
val layoutParams = inputBarLinearLayout.layoutParams as LayoutParams
layoutParams.height = newHeight
inputBarLinearLayout.layoutParams = layoutParams
delegate?.inputBarHeightChanged(newHeight)
}
// endregion
// region Updating
override fun inputBarEditTextContentChanged(text: CharSequence) {
sendButton.isVisible = text.isNotEmpty()
microphoneButton.isVisible = text.isEmpty()
delegate?.inputBarEditTextContentChanged(text)
}
override fun inputBarEditTextHeightChanged(newValue: Int) {
val newHeight = max(newValue + 2 * vMargin, minHeight) + inputBarAdditionalContentContainer.height
setHeight(newHeight)
}
private fun toggleAttachmentOptions() {
delegate?.toggleAttachmentOptions()
}
private fun startRecordingVoiceMessage() {
delegate?.startRecordingVoiceMessage()
}
// Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft
// a quote and a link preview at the same time.
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
quote = message
linkPreview = null
linkPreviewDraftView = null
inputBarAdditionalContentContainer.removeAllViews()
val quoteView = QuoteView(context, QuoteView.Mode.Draft)
quoteView.delegate = this
inputBarAdditionalContentContainer.addView(quoteView)
val attachments = (message as? MmsMessageRecord)?.slideDeck
// The max content width is the screen width - 2 times the horizontal input bar padding - the
// quote view content area's start and end margins. This unfortunately has to be calculated manually
// here to get the layout right.
val maxContentWidth = (screenWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources) - toPx(30, resources)).roundToInt()
val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize()
quoteView.bind(sender, message.body, attachments,
thread, true, maxContentWidth, message.isOpenGroupInvitation, message.threadId, false, glide)
// The 6 DP below is the padding the quote view applies to itself, which isn't included in the
// intrinsic height calculation.
val quoteViewIntrinsicHeight = quoteView.getIntrinsicHeight(maxContentWidth) + toPx(6, resources)
val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + quoteViewIntrinsicHeight
additionalContentHeight = quoteViewIntrinsicHeight
setHeight(newHeight)
}
override fun cancelQuoteDraft() {
quote = null
inputBarAdditionalContentContainer.removeAllViews()
val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight)
additionalContentHeight = 0
setHeight(newHeight)
}
fun draftLinkPreview() {
quote = null
val linkPreviewDraftHeight = toPx(88, resources)
inputBarAdditionalContentContainer.removeAllViews()
val linkPreviewDraftView = LinkPreviewDraftView(context)
linkPreviewDraftView.delegate = this
this.linkPreviewDraftView = linkPreviewDraftView
inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + linkPreviewDraftHeight
additionalContentHeight = linkPreviewDraftHeight
setHeight(newHeight)
}
fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) {
this.linkPreview = linkPreview
val linkPreviewDraftView = this.linkPreviewDraftView ?: return
linkPreviewDraftView.update(glide, linkPreview)
}
override fun cancelLinkPreviewDraft() {
if (quote != null) { return }
linkPreview = null
inputBarAdditionalContentContainer.removeAllViews()
val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight)
additionalContentHeight = 0
setHeight(newHeight)
}
private fun showOrHideInputIfNeeded() {
if (showInput) {
setOf( inputBarEditText, attachmentsButton ).forEach { it.isVisible = true }
microphoneButton.isVisible = text.isEmpty()
sendButton.isVisible = text.isNotEmpty()
} else {
cancelQuoteDraft()
cancelLinkPreviewDraft()
val views = setOf( inputBarEditText, attachmentsButton, microphoneButton, sendButton )
views.forEach { it.isVisible = false }
}
}
// endregion
}
interface InputBarDelegate {
fun inputBarHeightChanged(newValue: Int)
fun inputBarEditTextContentChanged(newContent: CharSequence)
fun toggleAttachmentOptions()
fun showVoiceMessageUI()
fun startRecordingVoiceMessage()
fun onMicrophoneButtonMove(event: MotionEvent)
fun onMicrophoneButtonCancel(event: MotionEvent)
fun onMicrophoneButtonUp(event: MotionEvent)
fun sendMessage()
}

View File

@ -0,0 +1,174 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.animation.PointFEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.PointF
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.annotation.DrawableRes
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.*
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.InputBarButtonImageViewContainer
import java.util.*
import kotlin.math.abs
class InputBarButton : RelativeLayout {
private val gestureHandler = Handler(Looper.getMainLooper())
private var isSendButton = false
private var hasOpaqueBackground = false
private var isGIFButton = false
@DrawableRes private var iconID = 0
private var longPressCallback: Runnable? = null
private var onDownTimestamp = 0L
var snIsEnabled = true
var onPress: (() -> Unit)? = null
var onMove: ((MotionEvent) -> Unit)? = null
var onCancel: ((MotionEvent) -> Unit)? = null
var onUp: ((MotionEvent) -> Unit)? = null
var onLongPress: (() -> Unit)? = null
companion object {
const val animationDuration = 250.toLong()
const val longPressDurationThreshold = 250L // ms
}
private val expandedImageViewPosition by lazy { PointF(0.0f, 0.0f) }
private val collapsedImageViewPosition by lazy { PointF((expandedSize - collapsedSize) / 2, (expandedSize - collapsedSize) / 2) }
private val colorID by lazy {
if (hasOpaqueBackground) {
R.color.input_bar_button_background_opaque
} else if (isSendButton) {
R.color.accent
} else {
R.color.input_bar_button_background
}
}
val expandedSize by lazy { resources.getDimension(R.dimen.input_bar_button_expanded_size) }
val collapsedSize by lazy { resources.getDimension(R.dimen.input_bar_button_collapsed_size) }
private val imageViewContainer by lazy {
val result = InputBarButtonImageViewContainer(context)
val size = collapsedSize.toInt()
result.layoutParams = LayoutParams(size, size)
result.setBackgroundResource(R.drawable.input_bar_button_background)
result.mainColor = resources.getColorWithID(colorID, context.theme)
if (hasOpaqueBackground) {
result.strokeColor = resources.getColorWithID(R.color.input_bar_button_background_opaque_border, context.theme)
}
result
}
private val imageView by lazy {
val result = ImageView(context)
val size = if (isGIFButton) toPx(24, resources) else toPx(16, resources)
result.layoutParams = LayoutParams(size, size)
result.scaleType = ImageView.ScaleType.CENTER_INSIDE
result.setImageResource(iconID)
val colorID = if (isSendButton) R.color.black else R.color.text
result.imageTintList = ColorStateList.valueOf(resources.getColorWithID(colorID, context.theme))
result
}
constructor(context: Context) : super(context) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") }
constructor(context: Context, @DrawableRes iconID: Int, isSendButton: Boolean = false,
hasOpaqueBackground: Boolean = false, isGIFButton: Boolean = false) : super(context) {
this.isSendButton = isSendButton
this.iconID = iconID
this.hasOpaqueBackground = hasOpaqueBackground
this.isGIFButton = isGIFButton
val size = resources.getDimension(R.dimen.input_bar_button_expanded_size).toInt()
val layoutParams = LayoutParams(size, size)
this.layoutParams = layoutParams
addView(imageViewContainer)
imageViewContainer.x = collapsedImageViewPosition.x
imageViewContainer.y = collapsedImageViewPosition.y
imageViewContainer.addView(imageView)
val imageViewLayoutParams = imageView.layoutParams as LayoutParams
imageViewLayoutParams.addRule(RelativeLayout.CENTER_IN_PARENT)
imageView.layoutParams = imageViewLayoutParams
gravity = Gravity.TOP or Gravity.LEFT // Intentionally not Gravity.START
isHapticFeedbackEnabled = true
}
fun expand() {
GlowViewUtilities.animateColorChange(context, imageViewContainer, colorID, R.color.accent)
imageViewContainer.animateSizeChange(R.dimen.input_bar_button_collapsed_size, R.dimen.input_bar_button_expanded_size, animationDuration)
animateImageViewContainerPositionChange(collapsedImageViewPosition, expandedImageViewPosition)
}
fun collapse() {
GlowViewUtilities.animateColorChange(context, imageViewContainer, R.color.accent, colorID)
imageViewContainer.animateSizeChange(R.dimen.input_bar_button_expanded_size, R.dimen.input_bar_button_collapsed_size, animationDuration)
animateImageViewContainerPositionChange(expandedImageViewPosition, collapsedImageViewPosition)
}
private fun animateImageViewContainerPositionChange(startPosition: PointF, endPosition: PointF) {
val animation = ValueAnimator.ofObject(PointFEvaluator(), startPosition, endPosition)
animation.duration = animationDuration
animation.addUpdateListener { animator ->
val point = animator.animatedValue as PointF
imageViewContainer.x = point.x
imageViewContainer.y = point.y
}
animation.start()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!snIsEnabled) { return false }
when (event.action) {
MotionEvent.ACTION_DOWN -> onDown(event)
MotionEvent.ACTION_MOVE -> onMove(event)
MotionEvent.ACTION_UP -> onUp(event)
MotionEvent.ACTION_CANCEL -> onCancel(event)
}
return true
}
private fun onDown(event: MotionEvent) {
expand()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
} else {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
val newLongPressCallback = Runnable { onLongPress?.invoke() }
this.longPressCallback = newLongPressCallback
gestureHandler.postDelayed(newLongPressCallback, InputBarButton.longPressDurationThreshold)
onDownTimestamp = Date().time
}
private fun onMove(event: MotionEvent) {
onMove?.invoke(event)
}
private fun onCancel(event: MotionEvent) {
onCancel?.invoke(event)
collapse()
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
}
private fun onUp(event: MotionEvent) {
onUp?.invoke(event)
collapse()
if ((Date().time - onDownTimestamp) < InputBarButton.longPressDurationThreshold) {
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
onPress?.invoke()
}
}
}

View File

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.content.Context
import android.content.res.Resources
import android.text.Layout
import android.text.StaticLayout
import android.util.AttributeSet
import android.util.Log
import android.widget.RelativeLayout
import androidx.appcompat.widget.AppCompatEditText
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities
import org.thoughtcrime.securesms.util.toPx
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class InputBarEditText : AppCompatEditText {
private val screenWidth get() = Resources.getSystem().displayMetrics.widthPixels
var delegate: InputBarEditTextDelegate? = null
private val snMinHeight = toPx(40.0f, resources)
private val snMaxHeight = toPx(80.0f, resources)
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onTextChanged(text: CharSequence, start: Int, lengthBefore: Int, lengthAfter: Int) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
delegate?.inputBarEditTextContentChanged(text)
// Calculate the width manually to get it right even before layout has happened (i.e.
// when restoring a draft). The 64 DP is the horizontal margin around the input bar
// edit text.
val width = (screenWidth - 2 * toPx(64.0f, resources)).roundToInt()
if (width < 0) { return } // screenWidth initially evaluates to 0
val height = TextUtilities.getIntrinsicHeight(text, paint, width).toFloat()
val constrainedHeight = min(max(height, snMinHeight), snMaxHeight)
if (constrainedHeight.roundToInt() == this.height) { return }
val layoutParams = this.layoutParams as? RelativeLayout.LayoutParams ?: return
layoutParams.height = constrainedHeight.roundToInt()
this.layoutParams = layoutParams
delegate?.inputBarEditTextHeightChanged(constrainedHeight.roundToInt())
}
}
interface InputBarEditTextDelegate {
fun inputBarEditTextContentChanged(text: CharSequence)
fun inputBarEditTextHeightChanged(newValue: Int)
}

View File

@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.animation.FloatEvaluator
import android.animation.IntEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_input_bar_recording.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.animateSizeChange
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.util.DateUtils
import java.util.*
class InputBarRecordingView : RelativeLayout {
private var startTimestamp = 0L
private val snHandler = Handler(Looper.getMainLooper())
private var dotViewAnimation: ValueAnimator? = null
private var pulseAnimation: ValueAnimator? = null
var delegate: InputBarRecordingViewDelegate? = null
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_input_bar_recording, this)
inputBarMiddleContentContainer.disableClipping()
inputBarCancelButton.setOnClickListener { hide() }
}
fun show() {
startTimestamp = Date().time
recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme))
inputBarCancelButton.alpha = 0.0f
inputBarMiddleContentContainer.alpha = 1.0f
lockView.alpha = 1.0f
isVisible = true
alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
alpha = animator.animatedValue as Float
}
animation.start()
animateDotView()
pulse()
animateLockViewUp()
updateTimer()
}
fun hide() {
alpha = 1.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f) {
isVisible = false
dotViewAnimation?.repeatCount = 0
pulseAnimation?.removeAllUpdateListeners()
}
}
animation.start()
delegate?.handleVoiceMessageUIHidden()
}
private fun animateDotView() {
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
dotViewAnimation = animation
animation.duration = 500L
animation.addUpdateListener { animator ->
dotView.alpha = animator.animatedValue as Float
}
animation.repeatCount = ValueAnimator.INFINITE
animation.repeatMode = ValueAnimator.REVERSE
animation.start()
}
private fun pulse() {
val collapsedSize = toPx(80.0f, resources)
val expandedSize = toPx(104.0f, resources)
pulseView.animateSizeChange(collapsedSize, expandedSize, 1000)
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f)
pulseAnimation = animation
animation.duration = 1000L
animation.addUpdateListener { animator ->
pulseView.alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f && isVisible) { pulse() }
}
animation.start()
}
private fun animateLockViewUp() {
val startMarginBottom = toPx(32, resources)
val endMarginBottom = toPx(72, resources)
val layoutParams = lockView.layoutParams as LayoutParams
layoutParams.bottomMargin = startMarginBottom
lockView.layoutParams = layoutParams
val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom)
animation.duration = 250L
animation.addUpdateListener { animator ->
layoutParams.bottomMargin = animator.animatedValue as Int
lockView.layoutParams = layoutParams
}
animation.start()
}
private fun updateTimer() {
val duration = (Date().time - startTimestamp) / 1000L
recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
snHandler.postDelayed({ updateTimer() }, 500)
}
fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L
fadeOutAnimation.addUpdateListener { animator ->
inputBarMiddleContentContainer.alpha = animator.animatedValue as Float
lockView.alpha = animator.animatedValue as Float
}
fadeOutAnimation.start()
val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
fadeInAnimation.duration = 250L
fadeInAnimation.addUpdateListener { animator ->
inputBarCancelButton.alpha = animator.animatedValue as Float
}
fadeInAnimation.start()
recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme))
recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() }
}
}
interface InputBarRecordingViewDelegate {
fun handleVoiceMessageUIHidden()
fun sendVoiceMessage()
fun cancelVoiceMessage()
}

View File

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.RelativeLayout
import kotlinx.android.synthetic.main.view_mention_candidate.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : RelativeLayout(context, attrs, defStyleAttr) {
var candidate = Mention("", "")
set(newValue) { field = newValue; update() }
var glide: GlideRequests? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context) : this(context, null)
companion object {
fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateView {
return layoutInflater.inflate(R.layout.view_mention_candidate_v2, parent, false) as MentionCandidateView
}
}
private fun update() {
mentionCandidateNameTextView.text = candidate.displayName
profilePictureView.publicKey = candidate.publicKey
profilePictureView.displayName = candidate.displayName
profilePictureView.additionalPublicKey = null
profilePictureView.glide = glide!!
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE
}
}
}

View File

@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
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.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.messaging.mentions.Mention
class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
private var candidates = listOf<Mention>()
set(newValue) { field = newValue; snAdapter.candidates = newValue }
var glide: GlideRequests? = null
set(newValue) { field = newValue; snAdapter.glide = newValue }
var openGroupServer: String? = null
set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer }
var openGroupRoom: String? = null
set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom }
var onCandidateSelected: ((Mention) -> Unit)? = null
private val snAdapter by lazy { Adapter(context) }
private class Adapter(private val context: Context) : BaseAdapter() {
var candidates = listOf<Mention>()
set(newValue) { field = newValue; notifyDataSetChanged() }
var glide: GlideRequests? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
override fun getCount(): Int { return candidates.count() }
override fun getItemId(position: Int): Long { return position.toLong() }
override fun getItem(position: Int): Mention { return candidates[position] }
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView.inflate(LayoutInflater.from(context), parent)
val mentionCandidate = getItem(position)
cell.glide = glide
cell.candidate = 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 = snAdapter
snAdapter.candidates = candidates
setOnItemClickListener { _, _, position, _ ->
onCandidateSelected?.invoke(candidates[position])
}
}
fun show(candidates: List<Mention>, threadID: Long) {
val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID)
if (openGroup != null) {
openGroupServer = openGroup.server
openGroupRoom = openGroup.room
}
setMentionCandidates(candidates)
}
fun setMentionCandidates(candidates: List<Mention>) {
this.candidates = candidates
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources)
this.layoutParams = layoutParams
}
fun hide() {
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = 0
this.layoutParams = layoutParams
}
}

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.conversation.v2.menus
import android.content.Context
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import network.loki.messenger.R
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long,
private val context: Context) : ActionMode.Callback {
var delegate: ConversationActionModeCallbackDelegate? = null
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val inflater = mode.menuInflater
inflater.inflate(R.menu.menu_conversation_item_action, menu)
updateActionModeMenu(menu)
return true
}
fun updateActionModeMenu(menu: Menu) {
// Prepare
val selectedItems = adapter.selectedItems
val containsControlMessage = selectedItems.any { it.isUpdate }
val hasText = selectedItems.any { it.body.isNotEmpty() }
if (selectedItems.isEmpty()) { return }
val firstMessage = selectedItems.iterator().next()
val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID)
val thread = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadID)!!
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
fun userCanDeleteSelectedItems(): Boolean {
if (openGroup == null) { return true }
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
if (allSentByCurrentUser) { return true }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
}
fun userCanBanSelectedUsers(): Boolean {
if (openGroup == null) { return false }
val anySentByCurrentUser = selectedItems.any { it.isOutgoing }
if (anySentByCurrentUser) { return false } // Users can't ban themselves
val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet()
if (selectedUsers.size > 1) { return false }
return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server)
}
// Delete message
menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems()
// Ban user
menu.findItem(R.id.menu_context_ban_user).isVisible = userCanBanSelectedUsers()
// Copy message text
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && selectedItems.size == 1 && firstMessage.recipient.address.toString() != userPublicKey)
// Resend
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
// Save media
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
// Reply
menu.findItem(R.id.menu_context_reply).isVisible =
(selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed)
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val selectedItems = adapter.selectedItems
when (item.itemId) {
R.id.menu_context_delete_message -> delegate?.deleteMessages(selectedItems)
R.id.menu_context_ban_user -> delegate?.banUser(selectedItems)
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
R.id.menu_context_reply -> delegate?.reply(selectedItems)
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
adapter.selectedItems.clear()
adapter.notifyDataSetChanged()
}
}
interface ConversationActionModeCallbackDelegate {
fun deleteMessages(messages: Set<MessageRecord>)
fun banUser(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>)
fun reply(messages: Set<MessageRecord>)
}

View File

@ -0,0 +1,328 @@
package org.thoughtcrime.securesms.conversation.v2.menus
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.AsyncTask
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import kotlinx.android.synthetic.main.activity_conversation_v2.*
import network.loki.messenger.R
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
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.*
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException
object ConversationMenuHelper {
fun onPrepareOptionsMenu(menu: Menu, inflater: MenuInflater, thread: Recipient, threadId: Long, context: Context, onOptionsItemSelected: (MenuItem) -> Unit) {
// Prepare
menu.clear()
val isOpenGroup = thread.isOpenGroupRecipient
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages
if (!isOpenGroup) {
if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages)
val actionView = item.actionView
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.resources.getColorWithID(R.color.text, context.theme)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) }
} else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
}
}
// One-on-one chat menu (options that should only be present for one-on-one chats)
if (thread.isContactRecipient) {
if (thread.isBlocked) {
inflater.inflate(R.menu.menu_conversation_unblock, menu)
} else {
inflater.inflate(R.menu.menu_conversation_block, menu)
}
inflater.inflate(R.menu.menu_conversation_copy_session_id, menu)
}
// Closed group menu (options that should only be present in closed groups)
if (thread.isClosedGroupRecipient) {
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
}
// Open group menu
if (isOpenGroup) {
inflater.inflate(R.menu.menu_conversation_open_group, menu)
}
// Muting
if (thread.isMuted) {
inflater.inflate(R.menu.menu_conversation_muted, menu)
} else {
inflater.inflate(R.menu.menu_conversation_unmuted, menu)
}
// Search
val searchViewItem = menu.findItem(R.id.menu_search)
(context as ConversationActivityV2).searchViewItem = searchViewItem
val searchView = searchViewItem.actionView as SearchView
val searchViewModel = context.searchViewModel!!
val queryListener = object : OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return true
}
override fun onQueryTextChange(query: String): Boolean {
searchViewModel.onQueryUpdated(query, threadId)
context.searchBottomBar.showLoading()
context.onSearchQueryUpdated(query)
return true
}
}
searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(queryListener)
searchViewModel.onSearchOpened()
context.searchBottomBar.visibility = View.VISIBLE
context.searchBottomBar.setData(0, 0)
context.inputBar.visibility = View.GONE
for (i in 0 until menu.size()) {
if (menu.getItem(i) != searchViewItem) {
menu.getItem(i).isVisible = false
}
}
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(null)
searchViewModel.onSearchClosed()
context.searchBottomBar.visibility = View.GONE
context.inputBar.visibility = View.VISIBLE
context.onSearchQueryUpdated(null)
context.invalidateOptionsMenu()
return true
}
})
}
fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean {
when (item.itemId) {
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
R.id.menu_search -> { search(context) }
R.id.menu_add_shortcut -> { addShortcut(context, thread) }
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread) }
R.id.menu_copy_session_id -> { copySessionID(context, thread) }
R.id.menu_edit_group -> { editClosedGroup(context, thread) }
R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) }
R.id.menu_unmute_notifications -> { unmute(context, thread) }
R.id.menu_mute_notifications -> { mute(context, thread) }
}
return true
}
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)
}
private fun search(context: Context) {
val searchViewModel = (context as ConversationActivityV2).searchViewModel!!
searchViewModel.onSearchOpened()
}
@SuppressLint("StaticFieldLeak")
private fun addShortcut(context: Context, thread: Recipient) {
object : AsyncTask<Void?, Void?, IconCompat?>() {
override fun doInBackground(vararg params: Void?): IconCompat? {
var icon: IconCompat? = null
val contactPhoto = thread.contactPhoto
if (contactPhoto != null) {
try {
var bitmap = BitmapFactory.decodeStream(contactPhoto.openInputStream(context))
bitmap = BitmapUtil.createScaledBitmap(bitmap, 300, 300)
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
} catch (e: IOException) {
// Do nothing
}
}
if (icon == null) {
icon = IconCompat.createWithResource(context, if (thread.isGroupRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut)
}
return icon
}
override fun onPostExecute(icon: IconCompat?) {
val name = Optional.fromNullable<String>(thread.name)
.or(Optional.fromNullable<String>(thread.profileName))
.or(thread.toShortString())
val shortcutInfo = ShortcutInfoCompat.Builder(context, thread.address.serialize() + '-' + System.currentTimeMillis())
.setShortLabel(name)
.setIcon(icon)
.setIntent(ShortcutLauncherActivity.createIntent(context, thread.address))
.build()
if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) {
Toast.makeText(context, context.resources.getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show()
}
}
}.execute()
}
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) {
if (thread.isClosedGroupRecipient) {
val group = DatabaseFactory.getGroupDatabase(context).getGroup(thread.address.toGroupString()).orNull()
if (group?.isActive == false) { return }
}
ExpirationDialog.show(context, thread.expireMessages) { expirationTime: Int ->
DatabaseFactory.getRecipientDatabase(context).setExpireMessages(thread, expirationTime)
val message = ExpirationTimerUpdate(expirationTime)
message.recipient = thread.address.serialize()
message.sentTimestamp = System.currentTimeMillis()
val expiringMessageManager = ApplicationContext.getInstance(context).expiringMessageManager
expiringMessageManager.setExpirationTimer(message)
MessageSender.send(message, thread.address)
val activity = context as AppCompatActivity
activity.invalidateOptionsMenu()
}
}
private fun unblock(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return }
val title = R.string.ConversationActivity_unblock_this_contact_question
val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact
AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.ConversationActivity_unblock) { _, _ ->
DatabaseFactory.getRecipientDatabase(context)
.setBlocked(thread, false)
}.show()
}
private fun block(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return }
val title = R.string.RecipientPreferenceActivity_block_this_contact_question
val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact
AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ ->
DatabaseFactory.getRecipientDatabase(context)
.setBlocked(thread, true)
}.show()
}
private fun copySessionID(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return }
val sessionID = thread.address.toString()
val clip = ClipData.newPlainText("Session ID", sessionID)
val activity = context as AppCompatActivity
val manager = activity.getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
private fun editClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return }
val intent = Intent(context, EditClosedGroupActivity::class.java)
val groupID: String = thread.address.toGroupString()
intent.putExtra(groupIDKey, groupID)
context.startActivity(intent)
}
private fun leaveClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return }
val builder = AlertDialog.Builder(context)
builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group))
builder.setCancelable(true)
val group = DatabaseFactory.getGroupDatabase(context).getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == sessionID }
val message = if (isCurrentUserAdmin) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."
} else {
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
}
builder.setMessage(message)
builder.setPositiveButton(R.string.yes) { _, _ ->
var groupPublicKey: String?
var isClosedGroup: Boolean
try {
groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
isClosedGroup = DatabaseFactory.getLokiAPIDatabase(context).isClosedGroup(groupPublicKey)
} catch (e: IOException) {
groupPublicKey = null
isClosedGroup = false
}
try {
if (isClosedGroup) {
MessageSender.leave(groupPublicKey!!, true)
} else {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
}
}
builder.setNegativeButton(R.string.no, null)
builder.show()
}
private fun inviteContacts(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return }
val intent = Intent(context, SelectContactsActivity::class.java)
val activity = context as AppCompatActivity
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
}
private fun unmute(context: Context, thread: Recipient) {
DatabaseFactory.getRecipientDatabase(context).setMuted(thread, 0)
}
private fun mute(context: Context, thread: Recipient) {
MuteDialog.show(context) { until: Long ->
DatabaseFactory.getRecipientDatabase(context).setMuted(thread, until)
}
}
}

View File

@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.content.res.Resources
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.view_control_message.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.MessageRecord
class ControlMessageView : LinearLayout {
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_control_message, this)
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
// endregion
// region Updating
fun bind(message: MessageRecord) {
iconImageView.visibility = View.GONE
if (message.isExpirationTimerUpdate) {
iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme))
iconImageView.visibility = View.VISIBLE
} else if (message.isMediaSavedNotification) {
iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme))
iconImageView.visibility = View.VISIBLE
}
textView.text = message.getDisplayBody(context)
}
fun recycle() {
}
// endregion
}

View File

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import kotlinx.android.synthetic.main.view_document.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
class DocumentView : LinearLayout {
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_document, this)
}
// endregion
// region Updating
fun bind(message: MmsMessageRecord, @ColorInt textColor: Int) {
val document = message.slideDeck.documentSlide!!
documentTitleTextView.text = document.fileName.or("Untitled File")
documentTitleTextView.setTextColor(textColor)
documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor)
}
// endregion
}

View File

@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_link_preview.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.conversation.v2.dialogs.OpenURLDialog
import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.ImageSlide
class LinkPreviewView : LinearLayout {
private val cornerMask by lazy { CornerMask(this) }
private var url: String? = null
lateinit var bodyTextView: TextView
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_link_preview, this)
}
// endregion
// region Updating
fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, searchQuery: String?) {
val linkPreview = message.linkPreviews.first()
url = linkPreview.url
// Thumbnail
if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail
thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
thumbnailImageView.loadIndicator.isVisible = false
}
// Title
titleTextView.text = linkPreview.title
val textColorID = if (message.isOutgoing && UiModeUtilities.isDayUiMode(context)) {
R.color.white
} else {
if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.white
}
titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme))
// Body
bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
mainLinkPreviewContainer.addView(bodyTextView)
// Corner radii
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
cornerMask.setTopLeftRadius(cornerRadii[0])
cornerMask.setTopRightRadius(cornerRadii[1])
cornerMask.setBottomRightRadius(cornerRadii[2])
cornerMask.setBottomLeftRadius(cornerRadii[3])
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
cornerMask.mask(canvas)
}
// endregion
// region Interaction
fun calculateHit(event: MotionEvent) {
val rawXInt = event.rawX.toInt()
val rawYInt = event.rawY.toInt()
val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
val previewRect = Rect()
mainLinkPreviewParent.getGlobalVisibleRect(previewRect)
if (previewRect.contains(hitRect)) {
openURL()
return
}
// intersectedModalSpans should only be a list of one item
val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect)
hitSpans.forEach { span ->
span.onClick(bodyTextView)
}
}
fun openURL() {
val url = this.url ?: return
val activity = context as AppCompatActivity
OpenURLDialog(url).show(activity.supportFragmentManager, "Open URL Dialog")
}
// endregion
}

View File

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.view_open_group_invitation.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.open_groups.OpenGroupV2
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.OpenGroupUrlParser
import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog
import org.thoughtcrime.securesms.database.model.MessageRecord
class OpenGroupInvitationView : LinearLayout {
private var data: UpdateMessageData.Kind.OpenGroupInvitation? = null
constructor(context: Context): super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_open_group_invitation, this)
}
fun bind(message: MessageRecord, @ColorInt textColor: Int) {
// FIXME: This is a really weird approach...
val umd = UpdateMessageData.fromJSON(message.body)!!
val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation
this.data = data
val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus
openGroupInvitationIconImageView.setImageResource(iconID)
openGroupTitleTextView.text = data.groupName
openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl)
openGroupTitleTextView.setTextColor(textColor)
openGroupJoinMessageTextView.setTextColor(textColor)
openGroupURLTextView.setTextColor(textColor)
}
fun joinOpenGroup() {
val data = data ?: return
val activity = context as AppCompatActivity
JoinOpenGroupDialog(data.groupName, data.groupUrl).show(activity.supportFragmentManager, "Join Open Group Dialog")
}
}

View File

@ -0,0 +1,197 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.annotation.ColorInt
import androidx.core.content.res.ResourcesCompat
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_link_preview.view.*
import kotlinx.android.synthetic.main.view_quote.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.util.*
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.UiModeUtilities
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
// There's quite some calculation going on here. It's a bit complex so don't make changes
// if you don't need to. If you do then test:
// • Quoted text in both private chats and group chats
// • Quoted images and videos in both private chats and group chats
// • Quoted voice messages and documents in both private chats and group chats
// • All of the above in both dark mode and light mode
class QuoteView : LinearLayout {
private lateinit var mode: Mode
private val vPadding by lazy { toPx(6, resources) }
var delegate: QuoteViewDelegate? = null
enum class Mode { Regular, Draft }
// region Lifecycle
constructor(context: Context) : super(context) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") }
constructor(context: Context, mode: Mode) : super(context) {
this.mode = mode
LayoutInflater.from(context).inflate(R.layout.view_quote, this)
// Add padding here (not on mainQuoteViewContainer) to get a bit of a top inset while avoiding
// the clipping issue described in getIntrinsicHeight(maxContentWidth:).
setPadding(0, toPx(6, resources), 0, 0)
when (mode) {
Mode.Draft -> quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() }
Mode.Regular -> {
quoteViewCancelButton.isVisible = false
mainQuoteViewContainer.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.transparent, context.theme))
val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams
// Since we're not showing the cancel button we can shorten the end margin
quoteViewMainContentContainerLayoutParams.marginEnd = resources.getDimension(R.dimen.medium_spacing).roundToInt()
quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams
}
}
}
// endregion
// region General
fun getIntrinsicContentHeight(maxContentWidth: Int): Int {
// If we're showing an attachment thumbnail, just constrain to the height of that
if (quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) }
var result = 0
var authorTextViewIntrinsicHeight = 0
if (quoteViewAuthorTextView.isVisible) {
val author = quoteViewAuthorTextView.text
authorTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(author, quoteViewAuthorTextView.paint, maxContentWidth)
result += authorTextViewIntrinsicHeight
}
val body = quoteViewBodyTextView.text
val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, quoteViewBodyTextView.paint, maxContentWidth)
result += bodyTextViewIntrinsicHeight
if (!quoteViewAuthorTextView.isVisible) {
// We want to at least be as high as the cancel button, and no higher than 56 DP (that's
// approximately the height of 3 lines.
return min(max(result, toPx(32, resources)), toPx(56, resources))
} else {
// Because we're showing the author text view, we should have a height of at least 32 DP
// anyway, so there's no need to constrain to that. We constrain to a max height of 56 DP
// because that's approximately the height of the author text view + 2 lines of the body
// text view.
return min(result, toPx(56, resources))
}
}
fun getIntrinsicHeight(maxContentWidth: Int): Int {
// The way all this works is that we just calculate the total height the quote view should be
// and then center everything inside vertically. This effectively means we're applying padding.
// Applying padding the regular way results in a clipping issue though due to a bug in
// RelativeLayout.
return getIntrinsicContentHeight(maxContentWidth) + 2 * vPadding
}
// endregion
// region Updating
fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient,
isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean, threadID: Long,
isOriginalMissing: Boolean, glide: GlideRequests) {
val contactDB = DatabaseFactory.getSessionContactDatabase(context)
// Reduce the max body text view line count to 2 if this is a group thread because
// we'll be showing the author text view and we don't want the overall quote view height
// to get too big.
quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3
// Author
if (thread.isGroupRecipient) {
val author = contactDB.getContactWithSessionID(authorPublicKey)
val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey
quoteViewAuthorTextView.text = authorDisplayName
quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
}
quoteViewAuthorTextView.isVisible = thread.isGroupRecipient
// Body
quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context);
quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))
// Accent line / attachment preview
val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing
quoteViewAccentLine.isVisible = !hasAttachments
quoteViewAttachmentPreviewContainer.isVisible = hasAttachments
if (!hasAttachments) {
val accentLineLayoutParams = quoteViewAccentLine.layoutParams as RelativeLayout.LayoutParams
accentLineLayoutParams.height = getIntrinsicContentHeight(maxContentWidth) // Match the intrinsic * content * height
quoteViewAccentLine.layoutParams = accentLineLayoutParams
quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage))
} else if (attachments != null) {
quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme))
val backgroundColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.accent
val backgroundColor = ResourcesCompat.getColor(resources, backgroundColorID, context.theme)
quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor)
quoteViewAttachmentPreviewImageView.isVisible = false
quoteViewAttachmentThumbnailImageView.isVisible = false
if (attachments.audioSlide != null) {
quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone)
quoteViewAttachmentPreviewImageView.isVisible = true
quoteViewBodyTextView.text = resources.getString(R.string.Slide_audio)
} else if (attachments.documentSlide != null) {
quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light)
quoteViewAttachmentPreviewImageView.isVisible = true
quoteViewBodyTextView.text = resources.getString(R.string.document)
} else if (attachments.thumbnailSlide != null) {
val slide = attachments.thumbnailSlide!!
// This internally fetches the thumbnail
quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources)
quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false)
quoteViewAttachmentThumbnailImageView.isVisible = true
quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
}
}
mainQuoteViewContainer.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, getIntrinsicHeight(maxContentWidth))
val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams
// The start margin is different if we just show the accent line vs if we show an attachment thumbnail
quoteViewMainContentContainerLayoutParams.marginStart = if (!hasAttachments) toPx(16, resources) else toPx(48, resources)
quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams
}
// endregion
// region Convenience
@ColorInt private fun getLineColor(isOutgoingMessage: Boolean): Int {
val isLightMode = UiModeUtilities.isDayUiMode(context)
if ((mode == Mode.Regular && isLightMode) || (mode == Mode.Draft && isLightMode)) {
return ResourcesCompat.getColor(resources, R.color.black, context.theme)
} else if (mode == Mode.Regular && !isLightMode) {
if (isOutgoingMessage) {
return ResourcesCompat.getColor(resources, R.color.black, context.theme)
} else {
return ResourcesCompat.getColor(resources, R.color.accent, context.theme)
}
} else { // Draft & dark mode
return ResourcesCompat.getColor(resources, R.color.accent, context.theme)
}
}
@ColorInt private fun getTextColor(isOutgoingMessage: Boolean): Int {
if (mode == Mode.Draft) { return ResourcesCompat.getColor(resources, R.color.text, context.theme) }
val isLightMode = UiModeUtilities.isDayUiMode(context)
if ((isOutgoingMessage && !isLightMode) || (!isOutgoingMessage && isLightMode)) {
return ResourcesCompat.getColor(resources, R.color.black, context.theme)
} else {
return ResourcesCompat.getColor(resources, R.color.white, context.theme)
}
}
// endregion
}
interface QuoteViewDelegate {
fun cancelQuoteDraft()
}

View File

@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.view_untrusted_attachment.view.*
import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
import java.util.*
class UntrustedAttachmentView: LinearLayout {
enum class AttachmentType {
AUDIO,
DOCUMENT,
MEDIA
}
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_untrusted_attachment, this)
}
// endregion
// region Updating
fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int) {
val (iconRes, stringRes) = when (attachmentType) {
AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.Slide_audio
AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.document
AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media
}
val iconDrawable = ContextCompat.getDrawable(context,iconRes)!!
iconDrawable.mutate().setTint(textColor)
val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).toLowerCase(Locale.ROOT))
untrustedAttachmentIcon.setImageDrawable(iconDrawable)
untrustedAttachmentTitle.text = text
}
// endregion
// region Interaction
fun showTrustDialog(recipient: Recipient) {
ActivityDispatcher.get(context)?.showDialog(DownloadDialog(recipient))
}
}

View File

@ -0,0 +1,252 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.text.util.Linkify
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import kotlinx.android.synthetic.main.view_link_preview.view.*
import kotlinx.android.synthetic.main.view_visible_message_content.view.*
import network.loki.messenger.R
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.dialogs.OpenURLDialog
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.*
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory
import org.thoughtcrime.securesms.util.UiModeUtilities
import java.util.*
import kotlin.math.roundToInt
class VisibleMessageContentView : LinearLayout {
var onContentClick: ((event: MotionEvent) -> Unit)? = null
var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageContentViewDelegate? = null
var viewHolderIndex: Int = -1
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_visible_message_content, this)
}
// endregion
// region Updating
fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean,
glide: GlideRequests, maxWidth: Int, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) {
// Background
val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster)
val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color
val color = ThemeUtil.getThemedColor(context, colorID)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN)
background.colorFilter = filter
setBackground(background)
// Body
mainContainer.removeAllViews()
onContentClick = null
onContentDoubleTap = null
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) {
val linkPreviewView = LinkPreviewView(context)
linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster, searchQuery)
mainContainer.addView(linkPreviewView)
onContentClick = { event -> linkPreviewView.calculateHit(event) }
// Body text view is inside the link preview for layout convenience
} else if (message is MmsMessageRecord && message.quote != null) {
val quote = message.quote!!
val quoteView = QuoteView(context, QuoteView.Mode.Regular)
// The max content width is the max message bubble size - 2 times the horizontal padding - 2
// times the horizontal margin. This unfortunately has to be calculated manually
// here to get the layout right.
val maxContentWidth = (maxWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - 2 * toPx(16, resources)).roundToInt()
val quoteText = if (quote.isOriginalMissing) {
context.getString(R.string.QuoteView_original_missing)
} else {
quote.text
}
quoteView.bind(quote.author.toString(), quoteText, quote.attachment, thread,
message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId,
quote.isOriginalMissing, glide)
mainContainer.addView(quoteView)
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
ViewUtil.setPaddingTop(bodyTextView, 0)
mainContainer.addView(bodyTextView)
onContentClick = { event ->
val r = Rect()
quoteView.getGlobalVisibleRect(r)
if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) {
delegate?.scrollToMessageIfPossible(quote.id)
}
}
} else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) {
// Audio attachment
if (contactIsTrusted || message.isOutgoing) {
val voiceMessageView = VoiceMessageView(context)
voiceMessageView.index = viewHolderIndex
voiceMessageView.delegate = context as? ConversationActivityV2
voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
mainContainer.addView(voiceMessageView)
// We have to use onContentClick (rather than a click listener directly on the voice
// message view) so as to not interfere with all the other gestures.
onContentClick = { voiceMessageView.togglePlayback() }
onContentDoubleTap = { voiceMessageView.handleDoubleTap() }
} else {
val untrustedView = UntrustedAttachmentView(context)
untrustedView.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
mainContainer.addView(untrustedView)
onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) }
}
} else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) {
// Document attachment
if (contactIsTrusted || message.isOutgoing) {
val documentView = DocumentView(context)
documentView.bind(message, VisibleMessageContentView.getTextColor(context, message))
mainContainer.addView(documentView)
} else {
val untrustedView = UntrustedAttachmentView(context)
untrustedView.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
mainContainer.addView(untrustedView)
onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) }
}
} else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) {
// Images/Video attachment
if (contactIsTrusted || message.isOutgoing) {
val albumThumbnailView = AlbumThumbnailView(context)
mainContainer.addView(albumThumbnailView)
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind
albumThumbnailView.bind(
glideRequests = glide,
message = message,
isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster
)
onContentClick = { event ->
albumThumbnailView.calculateHitObject(event, message, thread)
}
} else {
val untrustedView = UntrustedAttachmentView(context)
untrustedView.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
mainContainer.addView(untrustedView)
onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) }
}
} else if (message.isOpenGroupInvitation) {
val openGroupInvitationView = OpenGroupInvitationView(context)
openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message))
mainContainer.addView(openGroupInvitationView)
onContentClick = { openGroupInvitationView.joinOpenGroup() }
} else {
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
mainContainer.addView(bodyTextView)
onContentClick = { event ->
// intersectedModalSpans should only be a list of one item
bodyTextView.getIntersectedModalSpans(event).forEach { span ->
span.onClick(bodyTextView)
}
}
}
}
private fun getBackground(isOutgoing: Boolean, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean): Drawable {
val isSingleMessage = (isStartOfMessageCluster && isEndOfMessageCluster)
@DrawableRes val backgroundID: Int
if (isSingleMessage) {
backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
} else if (isStartOfMessageCluster) {
backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_start else R.drawable.message_bubble_background_received_start
} else if (isEndOfMessageCluster) {
backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_end else R.drawable.message_bubble_background_received_end
} else {
backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_middle else R.drawable.message_bubble_background_received_middle
}
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() {
mainContainer.removeAllViews()
}
// endregion
// region Convenience
companion object {
fun getBodyTextView(context: Context, message: MessageRecord, searchQuery: String?): TextView {
val result = EmojiTextView(context)
val vPadding = context.resources.getDimension(R.dimen.small_spacing).toInt()
val hPadding = toPx(12, context.resources)
result.setPadding(hPadding, vPadding, hPadding, vPadding)
result.setTextSize(TypedValue.COMPLEX_UNIT_PX, context.resources.getDimension(R.dimen.small_font_size))
val color = getTextColor(context, message)
result.setTextColor(color)
result.setLinkTextColor(color)
var body = message.body.toSpannable()
Linkify.addLinks(body, Linkify.WEB_URLS)
// replace URLSpans with ModalURLSpans
body.getSpans<URLSpan>(0, body.length).toList().forEach { urlSpan ->
val replacementSpan = ModalURLSpan(urlSpan.url) { url ->
val activity = context as AppCompatActivity
OpenURLDialog(url).show(activity.supportFragmentManager, "Open URL Dialog")
}
val start = body.getSpanStart(urlSpan)
val end = body.getSpanEnd(urlSpan)
val flags = body.getSpanFlags(urlSpan)
body.removeSpan(urlSpan)
body.setSpan(replacementSpan, start, end, flags)
}
body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context)
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { ForegroundColorSpan(Color.BLACK) }, body, searchQuery)
result.text = body
return result
}
@ColorInt
fun getTextColor(context: Context, message: MessageRecord): Int {
val isDayUiMode = UiModeUtilities.isDayUiMode(context)
val colorID = if (message.isOutgoing) {
if (isDayUiMode) R.color.white else R.color.black
} else {
if (isDayUiMode) R.color.black else R.color.white
}
return context.resources.getColorWithID(colorID, context.theme)
}
}
// endregion
}
interface VisibleMessageContentViewDelegate {
fun scrollToMessageIfPossible(timestamp: Long)
}

View File

@ -0,0 +1,366 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.*
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_visible_message.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.utilities.ViewUtil
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.*
import java.util.*
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
class VisibleMessageView : LinearLayout {
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect()
private var dx = 0.0f
private var previousTranslationX = 0.0f
private val gestureHandler = Handler(Looper.getMainLooper())
private var pressCallback: Runnable? = null
private var longPressCallback: Runnable? = null
private var onDownTimestamp = 0L
private var onDoubleTap: (() -> Unit)? = null
var viewHolderIndex: Int = -1
var snIsSelected = false
set(value) { field = value; handleIsSelectedChanged()}
var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null
var contentViewDelegate: VisibleMessageContentViewDelegate? = null
companion object {
const val swipeToReplyThreshold = 80.0f // dp
const val longPressMovementTreshold = 10.0f // dp
const val longPressDurationThreshold = 250L // ms
const val maxDoubleTapInterval = 200L
}
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_visible_message, this)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
isHapticFeedbackEnabled = true
setWillNotDraw(false)
expirationTimerViewContainer.disableClipping()
messageContentContainer.disableClipping()
}
// endregion
// region Updating
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, glide: GlideRequests, searchQuery: String?) {
val sender = message.individualRecipient
val senderSessionID = sender.address.serialize()
val threadID = message.threadId
val threadDB = DatabaseFactory.getThreadDatabase(context)
val thread = threadDB.getRecipientForThreadId(threadID) ?: return
val contactDB = DatabaseFactory.getSessionContactDatabase(context)
val contact = contactDB.getContactWithSessionID(senderSessionID)
val isGroupThread = thread.isGroupRecipient
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
// Show profile picture and sender name if this is a group thread AND
// the message is incoming
if (isGroupThread && !message.isOutgoing) {
profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE
profilePictureView.publicKey = senderSessionID
profilePictureView.glide = glide
profilePictureView.update()
if (thread.isOpenGroupRecipient) {
val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID) ?: return
val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server)
moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE
} else {
moderatorIconImageView.visibility = View.INVISIBLE
}
senderNameTextView.isVisible = isStartOfMessageCluster
val context = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
senderNameTextView.text = contact?.displayName(context) ?: senderSessionID
} else {
profilePictureContainer.visibility = View.GONE
senderNameTextView.visibility = View.GONE
}
// Date break
val showDateBreak = (previous == null || !DateUtils.isSameHour(message.timestamp, previous.timestamp))
dateBreakTextView.isVisible = showDateBreak
dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else ""
// Timestamp
messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp)
// Margins
val startPadding: Int
if (isGroupThread) {
startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt() else 0
} else {
startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt()
else resources.getDimension(R.dimen.medium_spacing).toInt()
}
val endPadding = if (message.isOutgoing) resources.getDimension(R.dimen.medium_spacing).toInt()
else resources.getDimension(R.dimen.very_large_spacing).toInt()
messageContentContainer.setPaddingRelative(startPadding, 0, endPadding, 0)
// Set inter-message spacing
setMessageSpacing(isStartOfMessageCluster, isEndOfMessageCluster)
// Gravity
val gravity = if (message.isOutgoing) Gravity.END else Gravity.START
mainContainer.gravity = gravity or Gravity.BOTTOM
// Message status indicator
val (iconID, iconColor) = getMessageStatusImage(message)
if (iconID != null) {
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
if (iconColor != null) {
drawable?.setTint(iconColor)
}
messageStatusImageView.setImageDrawable(drawable)
}
if (message.isOutgoing) {
val lastMessageID = DatabaseFactory.getMmsSmsDatabase(context).getLastMessageID(message.threadId)
messageStatusImageView.isVisible = !message.isSent || message.id == lastMessageID
} else {
messageStatusImageView.isVisible = false
}
// Expiration timer
updateExpirationTimer(message)
// Calculate max message bubble width
var maxWidth = screenWidth - startPadding - endPadding
if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width }
// Populate content view
messageContentView.viewHolderIndex = viewHolderIndex
messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery, isGroupThread || (contact?.isTrusted ?: false))
messageContentView.delegate = contentViewDelegate
onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() }
}
private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) {
val topPadding = if (isStartOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse
ViewUtil.setPaddingTop(this, resources.getDimension(topPadding).roundToInt())
val bottomPadding = if (isEndOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse
ViewUtil.setPaddingBottom(this, resources.getDimension(bottomPadding).roundToInt())
}
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean {
return if (isGroupThread) {
previous == null || previous.isUpdate || !DateUtils.isSameDay(current.timestamp, previous.timestamp)
|| current.recipient.address != previous.recipient.address
} else {
previous == null || previous.isUpdate || !DateUtils.isSameDay(current.timestamp, previous.timestamp)
|| current.isOutgoing != previous.isOutgoing
}
}
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean {
return if (isGroupThread) {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|| current.recipient.address != next.recipient.address
} else {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|| current.isOutgoing != next.isOutgoing
}
}
private fun getMessageStatusImage(message: MessageRecord): Pair<Int?,Int?> {
return when {
!message.isOutgoing -> null to null
message.isFailed -> R.drawable.ic_error to resources.getColor(R.color.destructive, context.theme)
message.isPending -> R.drawable.ic_circle_dot_dot_dot to null
message.isRead -> R.drawable.ic_filled_circle_check to null
else -> R.drawable.ic_circle_check to null
}
}
private fun updateExpirationTimer(message: MessageRecord) {
val expirationTimerViewLayoutParams = expirationTimerView.layoutParams as RelativeLayout.LayoutParams
val ruleToAdd = if (message.isOutgoing) RelativeLayout.ALIGN_START else RelativeLayout.ALIGN_END
val ruleToRemove = if (message.isOutgoing) RelativeLayout.ALIGN_END else RelativeLayout.ALIGN_START
expirationTimerViewLayoutParams.removeRule(ruleToRemove)
expirationTimerViewLayoutParams.addRule(ruleToAdd, R.id.messageContentView)
val expirationTimerViewSize = toPx(12, resources)
val smallSpacing = resources.getDimension(R.dimen.small_spacing).roundToInt()
expirationTimerViewLayoutParams.marginStart = if (message.isOutgoing) -(smallSpacing + expirationTimerViewSize) else 0
expirationTimerViewLayoutParams.marginEnd = if (message.isOutgoing) 0 else -(smallSpacing + expirationTimerViewSize)
expirationTimerView.layoutParams = expirationTimerViewLayoutParams
if (message.expiresIn > 0 && !message.isPending) {
expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme))
expirationTimerView.isVisible = true
expirationTimerView.setPercentComplete(0.0f)
if (message.expireStarted > 0) {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
expirationTimerView.startAnimation()
if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) {
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
}
} else if (!message.isOutgoing && !message.isMediaPending) {
ThreadUtils.queue {
val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager
val id = message.getId()
val mms = message.isMms
if (mms) DatabaseFactory.getMmsDatabase(context).markExpireStarted(id) else DatabaseFactory.getSmsDatabase(context).markExpireStarted(id)
expirationManager.scheduleDeletion(id, mms, message.expiresIn)
}
}
} else {
expirationTimerView.isVisible = false
}
}
private fun handleIsSelectedChanged() {
background = if (snIsSelected) {
ColorDrawable(context.resources.getColorWithID(R.color.message_selected, context.theme))
} else {
null
}
}
override fun onDraw(canvas: Canvas) {
if (translationX < 0 && !expirationTimerView.isVisible) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val threshold = VisibleMessageView.swipeToReplyThreshold
val iconSize = toPx(24, context.resources)
val bottomVOffset = paddingBottom + messageStatusImageView.height + (messageContentView.height - iconSize) / 2
swipeToReplyIconRect.left = messageContentContainer.right - messageContentContainer.paddingEnd + spacing
swipeToReplyIconRect.top = height - bottomVOffset - iconSize
swipeToReplyIconRect.right = messageContentContainer.right - messageContentContainer.paddingEnd + iconSize + spacing
swipeToReplyIconRect.bottom = height - bottomVOffset
swipeToReplyIcon.bounds = swipeToReplyIconRect
swipeToReplyIcon.alpha = (255.0f * (min(abs(translationX), threshold) / threshold)).roundToInt()
} else {
swipeToReplyIcon.alpha = 0
}
swipeToReplyIcon.draw(canvas)
super.onDraw(canvas)
}
fun recycle() {
profilePictureView.recycle()
messageContentView.recycle()
}
// endregion
// region Interaction
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> onDown(event)
MotionEvent.ACTION_MOVE -> onMove(event)
MotionEvent.ACTION_CANCEL -> onCancel(event)
MotionEvent.ACTION_UP -> onUp(event)
}
return true
}
private fun onDown(event: MotionEvent) {
dx = x - event.rawX
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
val newLongPressCallback = Runnable { onLongPress() }
this.longPressCallback = newLongPressCallback
gestureHandler.postDelayed(newLongPressCallback, VisibleMessageView.longPressDurationThreshold)
onDownTimestamp = Date().time
}
private fun onMove(event: MotionEvent) {
val translationX = toDp(event.rawX + dx, context.resources)
if (abs(translationX) < VisibleMessageView.longPressMovementTreshold || snIsSelected) {
return
} else {
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
}
if (translationX > 0) { return } // Only allow swipes to the left
// The idea here is to asymptotically approach a maximum drag distance
val damping = 50.0f
val sign = -1.0f
val x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
this.translationX = x
this.dateBreakTextView.translationX = -x // Bit of a hack to keep the date break text view from moving
postInvalidate() // Ensure onDraw(canvas:) is called
if (abs(x) > VisibleMessageView.swipeToReplyThreshold && abs(previousTranslationX) < VisibleMessageView.swipeToReplyThreshold) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
} else {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
previousTranslationX = x
}
private fun onCancel(event: MotionEvent) {
if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) {
onSwipeToReply?.invoke()
}
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
resetPosition()
}
private fun onUp(event: MotionEvent) {
if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) {
onSwipeToReply?.invoke()
} else if ((Date().time - onDownTimestamp) < VisibleMessageView.longPressDurationThreshold) {
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
val pressCallback = this.pressCallback
if (pressCallback != null) {
// If we're here and pressCallback isn't null, it means that we tapped again within
// maxDoubleTapInterval ms and we should count this as a double tap
gestureHandler.removeCallbacks(pressCallback)
this.pressCallback = null
onDoubleTap?.invoke()
} else {
val newPressCallback = Runnable { onPress(event) }
this.pressCallback = newPressCallback
gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval)
}
}
resetPosition()
}
private fun resetPosition() {
animate()
.translationX(0.0f)
.setDuration(150)
.setUpdateListener {
postInvalidate() // Ensure onDraw(canvas:) is called
}
.start()
// Bit of a hack to keep the date break text view from moving
dateBreakTextView.animate()
.translationX(0.0f)
.setDuration(150)
.start()
}
private fun onLongPress() {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
onLongPress?.invoke()
}
fun onContentClick(event: MotionEvent) {
messageContentView.onContentClick?.invoke(event)
}
private fun onPress(event: MotionEvent) {
onPress?.invoke(event)
pressCallback = null
}
// endregion
}

View File

@ -0,0 +1,142 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.view_voice_message.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener {
private val cornerMask by lazy { CornerMask(this) }
private var isPlaying = false
set(value) {
field = value
renderIcon()
}
private var progress = 0.0
private var duration = 0L
private var player: AudioSlidePlayer? = null
var delegate: VoiceMessageViewDelegate? = null
var index = -1
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_voice_message, this)
voiceMessageViewDurationTextView.text = String.format("%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(0),
TimeUnit.MILLISECONDS.toSeconds(0))
}
// endregion
// region Updating
fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) {
val audio = message.slideDeck.audioSlide!!
voiceMessageViewLoader.isVisible = audio.isInProgress
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
cornerMask.setTopLeftRadius(cornerRadii[0])
cornerMask.setTopRightRadius(cornerRadii[1])
cornerMask.setBottomRightRadius(cornerRadii[2])
cornerMask.setBottomLeftRadius(cornerRadii[3])
// only process audio if downloaded
if (audio.isPendingDownload || audio.isInProgress) {
this.player = null
return
}
val player = AudioSlidePlayer.createFor(context, audio, this)
this.player = player
(audio.asAttachment() as? DatabaseAttachment)?.let { attachment ->
DatabaseFactory.getAttachmentDatabase(context).getAttachmentAudioExtras(attachment.attachmentId)?.let { audioExtras ->
if (audioExtras.durationMs > 0) {
duration = audioExtras.durationMs
voiceMessageViewDurationTextView.visibility = View.VISIBLE
voiceMessageViewDurationTextView.text = String.format("%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
}
}
}
}
override fun onPlayerStart(player: AudioSlidePlayer) {}
override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, unused: Long) {
if (progress == 1.0) {
togglePlayback()
handleProgressChanged(0.0)
delegate?.playNextAudioIfPossible(index)
} else {
handleProgressChanged(progress)
}
}
private fun handleProgressChanged(progress: Double) {
this.progress = progress
voiceMessageViewDurationTextView.text = String.format("%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()),
TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong()))
val layoutParams = progressView.layoutParams as RelativeLayout.LayoutParams
layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt()
progressView.layoutParams = layoutParams
}
override fun onPlayerStop(player: AudioSlidePlayer) {
Log.d("Loki", "Player stopped")
isPlaying = false
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
cornerMask.mask(canvas)
}
private fun renderIcon() {
val iconID = if (isPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play
voiceMessagePlaybackImageView.setImageResource(iconID)
}
// endregion
// region Interaction
fun togglePlayback() {
val player = this.player ?: return
isPlaying = !isPlaying
if (isPlaying) {
player.play(progress)
} else {
player.stop()
}
}
fun handleDoubleTap() {
val player = this.player ?: return
player.playbackSpeed = if (player.playbackSpeed == 1.0f) 1.5f else 1.0f
}
// endregion
}
interface VoiceMessageViewDelegate {
fun playNextAudioIfPossible(current: Int)
}

View File

@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.conversation.v2.search
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.view_search_bottom_bar.view.*
import network.loki.messenger.R
class SearchBottomBar : LinearLayout {
private var eventListener: EventListener? = null
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
fun initialize() {
LayoutInflater.from(context).inflate(R.layout.view_search_bottom_bar, this)
}
fun setData(position: Int, count: Int) {
searchProgressWheel.visibility = GONE
searchUp.setOnClickListener { v: View? ->
if (eventListener != null) {
eventListener!!.onSearchMoveUpPressed()
}
}
searchDown.setOnClickListener { v: View? ->
if (eventListener != null) {
eventListener!!.onSearchMoveDownPressed()
}
}
if (count > 0) {
searchPosition.text = resources.getString(R.string.ConversationActivity_search_position, position + 1, count)
} else {
searchPosition.text = ""
}
setViewEnabled(searchUp, position < count - 1)
setViewEnabled(searchDown, position > 0)
}
fun showLoading() {
searchProgressWheel.visibility = VISIBLE
}
private fun setViewEnabled(view: View, enabled: Boolean) {
view.isEnabled = enabled
view.alpha = if (enabled) 1f else 0.25f
}
fun setEventListener(eventListener: EventListener?) {
this.eventListener = eventListener
}
interface EventListener {
fun onSearchMoveUpPressed()
fun onSearchMoveDownPressed()
}
}

View File

@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.conversation.v2.search
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import org.session.libsession.utilities.Debouncer
import org.session.libsession.utilities.Util.runOnMain
import org.session.libsession.utilities.concurrent.SignalExecutors
import org.thoughtcrime.securesms.contacts.ContactAccessor
import org.thoughtcrime.securesms.database.CursorList
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.util.CloseableLiveData
import java.io.Closeable
class SearchViewModel(application: Application) : AndroidViewModel(application) {
private val searchRepository: SearchRepository
private val result: CloseableLiveData<SearchResult>
private val debouncer: Debouncer
private var firstSearch = false
private var searchOpen = false
private var activeQuery: String? = null
private var activeThreadId: Long = 0
val searchResults: LiveData<SearchResult>
get() = result
fun onQueryUpdated(query: String, threadId: Long) {
if (query == activeQuery) {
return
}
updateQuery(query, threadId)
}
fun onMissingResult() {
if (activeQuery != null) {
updateQuery(activeQuery!!, activeThreadId)
}
}
fun onMoveUp() {
debouncer.clear()
val messages = result.value!!.getResults() as CursorList<MessageResult?>
val position = Math.min(result.value!!.position + 1, messages.size - 1)
result.setValue(SearchResult(messages, position), false)
}
fun onMoveDown() {
debouncer.clear()
val messages = result.value!!.getResults() as CursorList<MessageResult?>
val position = Math.max(result.value!!.position - 1, 0)
result.setValue(SearchResult(messages, position), false)
}
fun onSearchOpened() {
searchOpen = true
firstSearch = true
}
fun onSearchClosed() {
searchOpen = false
activeQuery = null
debouncer.clear()
result.close()
}
override fun onCleared() {
super.onCleared()
result.close()
}
private fun updateQuery(query: String, threadId: Long) {
activeQuery = query
activeThreadId = threadId
debouncer.publish {
firstSearch = false
searchRepository.query(query, threadId) { messages: CursorList<MessageResult?> ->
runOnMain {
if (searchOpen && query == activeQuery) {
result.setValue(SearchResult(messages, 0))
} else {
messages.close()
}
}
}
}
}
class SearchResult(private val results: CursorList<MessageResult?>, val position: Int) : Closeable {
fun getResults(): List<MessageResult?> {
return results
}
override fun close() {
results.close()
}
}
init {
val context = application.applicationContext
result = CloseableLiveData()
debouncer = Debouncer(500)
searchRepository = SearchRepository(context,
DatabaseFactory.getSearchDatabase(context),
DatabaseFactory.getThreadDatabase(context),
ContactAccessor.getInstance(),
SignalExecutors.SERIAL)
}
}

View File

@ -14,7 +14,7 @@
* 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.mms;
package org.thoughtcrime.securesms.conversation.v2.utilities;
import android.Manifest;
import android.annotation.SuppressLint;
@ -23,29 +23,31 @@ import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.PorterDuff;
import android.net.Uri;
import android.os.AsyncTask;
import android.provider.ContactsContract;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.text.TextUtils;
import android.util.Pair;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.loki.views.MessageAudioView;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.RemovableEditableMediaView;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.session.libsignal.utilities.NoExternalStorageException;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.DocumentSlide;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.session.libsignal.utilities.ExternalStorageUtil;
@ -53,13 +55,8 @@ import org.thoughtcrime.securesms.util.FileProviderUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.ThemeUtil;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsession.utilities.Stub;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.ListenableFuture.Listener;
import org.session.libsignal.utilities.SettableFuture;
import java.io.File;
@ -67,26 +64,18 @@ import java.io.IOException;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import network.loki.messenger.R;
import static android.provider.MediaStore.EXTRA_OUTPUT;
public class AttachmentManager {
private final static String TAG = AttachmentManager.class.getSimpleName();
private final @NonNull Context context;
private final @NonNull Stub<View> attachmentViewStub;
private final @NonNull AttachmentListener attachmentListener;
private RemovableEditableMediaView removableMediaView;
private ThumbnailView thumbnail;
private MessageAudioView audioView;
private DocumentView documentView;
private @NonNull List<Uri> garbage = new LinkedList<>();
private @NonNull Optional<Slide> slide = Optional.absent();
private @Nullable Uri captureUri;
@ -94,51 +83,12 @@ public class AttachmentManager {
public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) {
this.context = activity;
this.attachmentListener = listener;
this.attachmentViewStub = ViewUtil.findStubById(activity, R.id.attachment_editor_stub);
}
private void inflateStub() {
if (!attachmentViewStub.resolved()) {
View root = attachmentViewStub.get();
this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail);
this.audioView = ViewUtil.findById(root, R.id.attachment_audio);
this.documentView = ViewUtil.findById(root, R.id.attachment_document);
this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view);
removableMediaView.setRemoveClickListener(new RemoveButtonListener());
thumbnail.setOnClickListener(new ThumbnailClickListener());
documentView.getBackground().setColorFilter(ThemeUtil.getThemedColor(context, R.attr.conversation_item_bubble_background), PorterDuff.Mode.MULTIPLY);
}
}
public void clear(@NonNull GlideRequests glideRequests, boolean animate) {
if (attachmentViewStub.resolved()) {
if (animate) {
ViewUtil.fadeOut(attachmentViewStub.get(), 200).addListener(new Listener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
thumbnail.clear(glideRequests);
attachmentViewStub.get().setVisibility(View.GONE);
attachmentListener.onAttachmentChanged();
}
@Override
public void onFailure(ExecutionException e) {
}
});
} else {
thumbnail.clear(glideRequests);
attachmentViewStub.get().setVisibility(View.GONE);
attachmentListener.onAttachmentChanged();
}
markGarbage(getSlideUri());
slide = Optional.absent();
audioView.cleanup();
}
public void clear() {
markGarbage(getSlideUri());
slide = Optional.absent();
attachmentListener.onAttachmentChanged();
}
public void cleanup() {
@ -190,16 +140,12 @@ public class AttachmentManager {
final int width,
final int height)
{
inflateStub();
final SettableFuture<Boolean> result = new SettableFuture<>();
new AsyncTask<Void, Void, Slide>() {
@Override
protected void onPreExecute() {
thumbnail.clear(glideRequests);
thumbnail.showProgressSpinner();
attachmentViewStub.get().setVisibility(View.VISIBLE);
}
@Override
@ -222,35 +168,12 @@ public class AttachmentManager {
@Override
protected void onPostExecute(@Nullable final Slide slide) {
if (slide == null) {
attachmentViewStub.get().setVisibility(View.GONE);
Toast.makeText(context,
R.string.ConversationActivity_sorry_there_was_an_error_setting_your_attachment,
Toast.LENGTH_SHORT).show();
result.set(false);
} else if (!areConstraintsSatisfied(context, slide, constraints)) {
attachmentViewStub.get().setVisibility(View.GONE);
Toast.makeText(context,
R.string.ConversationActivity_attachment_exceeds_size_limits,
Toast.LENGTH_SHORT).show();
result.set(false);
} else {
setSlide(slide);
attachmentViewStub.get().setVisibility(View.VISIBLE);
if (slide.hasAudio()) {
audioView.setAudio((AudioSlide) slide, false);
removableMediaView.display(audioView, false);
result.set(true);
} else if (slide.hasDocument()) {
documentView.setDocument((DocumentSlide) slide, false);
removableMediaView.display(documentView, false);
result.set(true);
} else {
Attachment attachment = slide.asAttachment();
result.deferTo(thumbnail.setImageResource(glideRequests, slide, false, true, attachment.getWidth(), attachment.getHeight()));
removableMediaView.display(thumbnail, mediaType == MediaType.IMAGE);
}
result.set(true);
attachmentListener.onAttachmentChanged();
}
}
@ -317,11 +240,8 @@ public class AttachmentManager {
return result;
}
public boolean isAttachmentPresent() {
return attachmentViewStub.resolved() && attachmentViewStub.get().getVisibility() == View.VISIBLE;
}
public @NonNull SlideDeck buildSlideDeck() {
public @NonNull
SlideDeck buildSlideDeck() {
SlideDeck deck = new SlideDeck();
if (slide.isPresent()) deck.addSlide(slide.get());
return deck;
@ -333,43 +253,16 @@ public class AttachmentManager {
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
Permissions.with(activity)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
}
public static void selectAudio(Activity activity, int requestCode) {
selectMediaType(activity, "audio/*", null, requestCode);
}
public static void selectContactInfo(Activity activity, int requestCode) {
Permissions.with(activity)
.request(Manifest.permission.WRITE_CONTACTS)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information))
.onAllGranted(() -> {
Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI);
activity.startActivityForResult(intent, requestCode);
})
.execute();
}
public static void selectLocation(Activity activity, int requestCode) {
/* Loki - Enable again once we have location sharing
Permissions.with(activity)
.request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location))
.onAllGranted(() -> {
try {
activity.startActivityForResult(new PlacePicker.IntentBuilder().build(activity), requestCode);
} catch (GooglePlayServicesRepairableException | GooglePlayServicesNotAvailableException e) {
Log.w(TAG, e);
}
})
.execute();
*/
}
public static void selectGif(Activity activity, int requestCode) {
Intent intent = new Intent(activity, GiphyActivity.class);
intent.putExtra(GiphyActivity.EXTRA_IS_MMS, false);
@ -386,28 +279,25 @@ public class AttachmentManager {
public void capturePhoto(Activity activity, int requestCode) {
Permissions.with(activity)
.request(Manifest.permission.CAMERA)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied))
.onAllGranted(() -> {
try {
File captureFile = File.createTempFile(
"conversation-capture",
".jpg",
ExternalStorageUtil.getImageDir(activity));
Uri captureUri = FileProviderUtil.getUriFor(context, captureFile);
Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
captureIntent.putExtra(EXTRA_OUTPUT, captureUri);
captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (captureIntent.resolveActivity(activity.getPackageManager()) != null) {
Log.d(TAG, "captureUri path is " + captureUri.getPath());
this.captureUri = captureUri;
activity.startActivityForResult(captureIntent, requestCode);
}
} catch (IOException | NoExternalStorageException e) {
throw new RuntimeException("Error creating image capture intent.", e);
}
})
.execute();
.request(Manifest.permission.CAMERA)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied))
.onAllGranted(() -> {
try {
File captureFile = File.createTempFile("conversation-capture", ".jpg", ExternalStorageUtil.getImageDir(activity));
Uri captureUri = FileProviderUtil.getUriFor(context, captureFile);
Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
captureIntent.putExtra(EXTRA_OUTPUT, captureUri);
captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (captureIntent.resolveActivity(activity.getPackageManager()) != null) {
Log.d(TAG, "captureUri path is " + captureUri.getPath());
this.captureUri = captureUri;
activity.startActivityForResult(captureIntent, requestCode);
}
} catch (IOException | NoExternalStorageException e) {
throw new RuntimeException("Error creating image capture intent.", e);
}
})
.execute();
}
private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) {
@ -445,34 +335,6 @@ public class AttachmentManager {
constraints.canResize(slide.asAttachment());
}
private void previewImageDraft(final @NonNull Slide slide) {
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
Intent intent = new Intent(context, MediaPreviewActivity.class);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, slide.asAttachment().getSize());
intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, slide.getCaption().orNull());
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, true);
intent.setDataAndType(slide.getUri(), slide.getContentType());
context.startActivity(intent);
}
}
private class ThumbnailClickListener implements View.OnClickListener {
@Override
public void onClick(View v) {
if (slide.isPresent()) previewImageDraft(slide.get());
}
}
private class RemoveButtonListener implements View.OnClickListener {
@Override
public void onClick(View v) {
cleanup();
clear(GlideApp.with(context.getApplicationContext()), true);
}
}
public interface AttachmentListener {
void onAttachmentChanged();
}
@ -513,6 +375,5 @@ public class AttachmentManager {
return DOCUMENT;
}
}
}

View File

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.util.UiModeUtilities
open class BaseDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(requireContext())
setContentView(builder)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
result.window?.setDimAmount(if (isLightMode) 0.1f else 0.75f)
return result
}
open fun setContentView(builder: AlertDialog.Builder) {
// To be overridden by subclasses
}
}

View File

@ -0,0 +1,196 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestOptions
import kotlinx.android.synthetic.main.thumbnail_view.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.utilities.Util.equals
import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.*
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
open class KThumbnailView: FrameLayout {
companion object {
private const val WIDTH = 0
private const val HEIGHT = 1
}
// region Lifecycle
constructor(context: Context) : super(context) { initialize(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
private val image by lazy { thumbnail_image }
private val playOverlay by lazy { play_overlay }
val loadIndicator: View by lazy { thumbnail_load_indicator }
val downloadIndicator: View by lazy { thumbnail_download_icon }
private val dimensDelegate = ThumbnailDimensDelegate()
private var slide: Slide? = null
private var radius: Int = 0
private fun initialize(attrs: AttributeSet?) {
inflate(context, R.layout.thumbnail_view, this)
if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
typedArray.recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val adjustedDimens = dimensDelegate.resourceSize()
if (adjustedDimens[WIDTH] == 0 && adjustedDimens[HEIGHT] == 0) {
return super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
val finalWidth: Int = adjustedDimens[WIDTH] + paddingLeft + paddingRight
val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom
super.onMeasure(
MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)
)
}
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
// endregion
// region Interaction
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture<Boolean> {
return setImageResource(glide, slide, isPreview, 0, 0, mms)
}
fun setImageResource(glide: GlideRequests, slide: Slide,
isPreview: Boolean, naturalWidth: Int,
naturalHeight: Int, mms: MmsMessageRecord): ListenableFuture<Boolean> {
val currentSlide = this.slide
playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
if (equals(currentSlide, slide)) {
// don't re-load slide
return SettableFuture(false)
}
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) {
// not reloading slide for fast preflight
this.slide = slide
}
this.slide = slide
loadIndicator.isVisible = slide.isInProgress
downloadIndicator.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
dimensDelegate.setDimens(naturalWidth, naturalHeight)
invalidate()
val result = SettableFuture<Boolean>()
when {
slide.thumbnailUri != null -> {
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result))
}
slide.hasPlaceholder() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result))
}
else -> {
glide.clear(image)
result.set(false)
}
}
return result
}
fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Drawable> {
val dimens = dimensDelegate.resourceSize()
val request = glide.load(DecryptableUri(slide.thumbnailUri!!))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.let { request ->
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.transition(DrawableTransitionOptions.withCrossFade())
.centerCrop()
return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))
}
fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Bitmap> {
val dimens = dimensDelegate.resourceSize()
return glide.asBitmap()
.load(slide.getPlaceholderRes(context.theme))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.let { request ->
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.fitCenter()
}
open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(image)
slide = null
}
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> {
val future = SettableFuture<Boolean>()
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
request = if (radius > 0) {
request.transforms(CenterCrop(), RoundedCorners(radius))
} else {
request.transforms(CenterCrop())
}
request.into(GlideDrawableListeningTarget(image, future))
return future
}
// endregion
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.utilities
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import org.session.libsession.utilities.TextSecurePreferences

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.utilities
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import android.graphics.Typeface
@ -7,11 +7,13 @@ import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Range
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.util.UiModeUtilities
import java.util.regex.Pattern
object MentionUtilities {
@ -23,7 +25,7 @@ object MentionUtilities {
@JvmStatic
fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString {
var text = text
@Suppress("NAME_SHADOWING") var text = text
val threadDB = DatabaseFactory.getThreadDatabase(context)
val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false
val pattern = Pattern.compile("@[0-9a-fA-F]*")
@ -38,7 +40,7 @@ object MentionUtilities {
TextSecurePreferences.getProfileName(context)
} else {
val contact = DatabaseFactory.getSessionContactDatabase(context).getContactWithSessionID(publicKey)
val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR
@Suppress("NAME_SHADOWING") val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR
contact?.displayName(context)
}
if (userDisplayName != null) {
@ -54,10 +56,15 @@ object MentionUtilities {
}
}
val result = SpannableString(text)
val isLightMode = UiModeUtilities.isDayUiMode(context)
for (mention in mentions) {
val isLightMode = UiModeUtilities.isDayUiMode(context)
val colorID = if (isLightMode && isOutgoingMessage) R.color.black else R.color.accent
result.setSpan(ForegroundColorSpan(context.resources.getColorWithID(colorID, context.theme)), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val colorID = if (isOutgoingMessage) {
if (isLightMode) R.color.white else R.color.black
} else {
R.color.accent
}
val color = ResourcesCompat.getColor(context.resources, colorID, context.theme)
result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return result

View File

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import network.loki.messenger.R
import kotlin.math.roundToInt
object MessageBubbleUtilities {
fun calculateRadii(context: Context, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, isOutgoing: Boolean): IntArray {
val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).roundToInt()
val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).roundToInt()
val (tl, tr, bl, br) = when {
// Single message
isStartOfMessageCluster && isEndOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen)
// Start of message cluster; collapsed BL
isStartOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen)
// End of message cluster; collapsed TL
isEndOfMessageCluster -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen)
// In the middle; no rounding on the left
else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen)
}
// TL, TR, BR, BL (CW direction)
// Flip if the message is outgoing
return intArrayOf(
if (!isOutgoing) tl else tr, // TL
if (!isOutgoing) tr else tl, // TR
if (!isOutgoing) br else bl, // BR
if (!isOutgoing) bl else br // BL
)
}
}

View File

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.text.style.URLSpan
import android.view.View
class ModalURLSpan(url: String, private val openModalCallback: (String)->Unit): URLSpan(url) {
override fun onClick(widget: View) {
openModalCallback(url)
}
}

View File

@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.graphics.Rect
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.view.MotionEvent
import android.widget.TextView
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
object TextUtilities {
fun getIntrinsicHeight(text: CharSequence, paint: TextPaint, width: Int): Int {
val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0.0f, 1.0f)
.setIncludePad(false)
val layout = builder.build()
return layout.height
}
fun TextView.getIntersectedModalSpans(event: MotionEvent): List<ModalURLSpan> {
val xInt = event.rawX.toInt()
val yInt = event.rawY.toInt()
val hitRect = Rect(xInt, yInt, xInt, yInt)
return getIntersectedModalSpans(hitRect)
}
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
val textLayout = layout ?: return emptyList()
val lineRect = Rect()
val bodyTextRect = Rect()
getGlobalVisibleRect(bodyTextRect)
val textSpannable = text.toSpannable()
return (0 until textLayout.lineCount).flatMap { line ->
textLayout.getLineBounds(line, lineRect)
lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop)
if ((Rect(lineRect)).contains(hitRect)) {
// calculate the url span intersected with (if any)
val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same
textSpannable.getSpans<ModalURLSpan>(off, off).toList()
} else {
emptyList()
}
}
}
}

View File

@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
class ThumbnailDimensDelegate {
companion object {
// dimens array constants
private const val WIDTH = 0
private const val HEIGHT = 1
private const val DIMENS_ARRAY_SIZE = 2
// bounds array constants
private const val MIN_WIDTH = 0
private const val MIN_HEIGHT = 1
private const val MAX_WIDTH = 2
private const val MAX_HEIGHT = 3
private const val BOUNDS_ARRAY_SIZE = 4
// const zero int array
private val EMPTY_DIMENS = intArrayOf(0,0)
}
private val measured: IntArray = IntArray(DIMENS_ARRAY_SIZE)
private val dimens: IntArray = IntArray(DIMENS_ARRAY_SIZE)
private val bounds: IntArray = IntArray(BOUNDS_ARRAY_SIZE)
fun resourceSize(): IntArray {
if (dimens.all { it == 0 }) {
// dimens are (0, 0), don't go any further
return EMPTY_DIMENS
}
val naturalWidth = dimens[WIDTH].toDouble()
val naturalHeight = dimens[HEIGHT].toDouble()
val minWidth = dimens[MIN_WIDTH]
val maxWidth = dimens[MAX_WIDTH]
val minHeight = dimens[MIN_HEIGHT]
val maxHeight = dimens[MAX_HEIGHT]
// calculate actual measured
var measuredWidth: Double = naturalWidth
var measuredHeight: Double = naturalHeight
val widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth
val heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight
if (!widthInBounds || !heightInBounds) {
val minWidthRatio: Double = naturalWidth / minWidth
val maxWidthRatio: Double = naturalWidth / maxWidth
val minHeightRatio: Double = naturalHeight / minHeight
val maxHeightRatio: Double = naturalHeight / maxHeight
if (maxWidthRatio > 1 || maxHeightRatio > 1) {
if (maxWidthRatio >= maxHeightRatio) {
measuredWidth /= maxWidthRatio
measuredHeight /= maxWidthRatio
} else {
measuredWidth /= maxHeightRatio
measuredHeight /= maxHeightRatio
}
measuredWidth = Math.max(measuredWidth, minWidth.toDouble())
measuredHeight = Math.max(measuredHeight, minHeight.toDouble())
} else if (minWidthRatio < 1 || minHeightRatio < 1) {
if (minWidthRatio <= minHeightRatio) {
measuredWidth /= minWidthRatio
measuredHeight /= minWidthRatio
} else {
measuredWidth /= minHeightRatio
measuredHeight /= minHeightRatio
}
measuredWidth = Math.min(measuredWidth, maxWidth.toDouble())
measuredHeight = Math.min(measuredHeight, maxHeight.toDouble())
}
}
measured[WIDTH] = measuredWidth.toInt()
measured[HEIGHT] = measuredHeight.toInt()
return measured
}
fun setBounds(minWidth: Int, minHeight: Int, maxWidth: Int, maxHeight: Int) {
bounds[MIN_WIDTH] = minWidth
bounds[MIN_HEIGHT] = minHeight
bounds[MAX_WIDTH] = maxWidth
bounds[MAX_HEIGHT] = maxHeight
}
fun setDimens(width: Int, height: Int) {
dimens[WIDTH] = width
dimens[HEIGHT] = height
}
}

View File

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Interpolator
import android.graphics.Paint
import android.graphics.Rect
import android.os.SystemClock
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.Animation
import android.view.animation.AnimationSet
import android.view.animation.AnimationUtils
import androidx.core.content.res.ResourcesCompat
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import network.loki.messenger.R
import kotlin.math.sin
class ThumbnailProgressBar: View {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
private val firstX: Double
get() = sin(SystemClock.elapsedRealtime() / 300.0) * 1.5
private val secondX: Double
get() = sin(SystemClock.elapsedRealtime() / 300.0 + (Math.PI/4)) * 1.5
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = ResourcesCompat.getColor(resources, R.color.accent, null)
}
private val objectRect = Rect()
private val drawingRect = Rect()
override fun dispatchDraw(canvas: Canvas?) {
if (canvas == null) return
getDrawingRect(objectRect)
drawingRect.set(objectRect)
val coercedFX = firstX
val coercedSX = secondX
val firstMeasuredX = objectRect.left + (objectRect.width() * coercedFX)
val secondMeasuredX = objectRect.left + (objectRect.width() * coercedSX)
drawingRect.set(
(if (firstMeasuredX < secondMeasuredX) firstMeasuredX else secondMeasuredX).toInt(),
objectRect.top,
(if (firstMeasuredX < secondMeasuredX) secondMeasuredX else firstMeasuredX).toInt(),
objectRect.bottom
)
canvas.drawRect(drawingRect, paint)
invalidate()
}
}

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.components;
package org.thoughtcrime.securesms.conversation.v2.utilities;
import android.content.Context;
import android.content.res.TypedArray;
@ -24,6 +24,9 @@ import com.bumptech.glide.request.RequestOptions;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget;
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget;
import org.thoughtcrime.securesms.components.TransferControlView;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
@ -54,7 +57,6 @@ public class ThumbnailView extends FrameLayout {
private ImageView image;
private View playOverlay;
private View captionIcon;
private View loadIndicator;
private OnClickListener parentClickListener;
@ -67,7 +69,7 @@ public class ThumbnailView extends FrameLayout {
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private int radius;
public int radius;
public ThumbnailView(Context context) {
this(context, null);
@ -84,7 +86,6 @@ public class ThumbnailView extends FrameLayout {
this.image = findViewById(R.id.thumbnail_image);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
this.loadIndicator = findViewById(R.id.thumbnail_load_indicator);
super.setOnClickListener(new ThumbnailClickDispatcher());
@ -94,10 +95,10 @@ public class ThumbnailView extends FrameLayout {
bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0);
typedArray.recycle();
} else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
radius = 0;
}
}
@ -275,8 +276,6 @@ public class ThumbnailView extends FrameLayout {
this.slide = slide;
this.captionIcon.setVisibility(slide.getCaption().isPresent() ? VISIBLE : GONE);
dimens[WIDTH] = naturalWidth;
dimens[HEIGHT] = naturalHeight;
invalidate();
@ -398,6 +397,7 @@ public class ThumbnailView extends FrameLayout {
}
private class ThumbnailClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
if (thumbnailClickListener != null &&
@ -413,9 +413,9 @@ public class ThumbnailView extends FrameLayout {
}
private class DownloadClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
Log.i(TAG, "onClick() for download button");
if (downloadClickListener != null && slide != null) {
downloadClickListener.onClick(view, Collections.singletonList(slide));
} else {

View File

@ -15,21 +15,22 @@
* 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.session.libsession.utilities;
package org.thoughtcrime.securesms.crypto;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.os.Build;
import androidx.annotation.NonNull;
import org.session.libsignal.crypto.ecc.ECPublicKey;
import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.crypto.IdentityKeyPair;
import org.session.libsignal.exceptions.InvalidKeyException;
import org.session.libsignal.crypto.ecc.Curve;
import org.session.libsignal.crypto.ecc.ECKeyPair;
import org.session.libsignal.crypto.ecc.ECPrivateKey;
import org.session.libsignal.crypto.ecc.ECPublicKey;
import org.session.libsignal.exceptions.InvalidKeyException;
import org.session.libsignal.utilities.Base64;
import java.io.IOException;
@ -45,19 +46,41 @@ public class IdentityKeyUtil {
@SuppressWarnings("unused")
private static final String TAG = IdentityKeyUtil.class.getSimpleName();
private static final String ENCRYPTED_SUFFIX = "_encrypted";
public static final String IDENTITY_PUBLIC_KEY_PREF = "pref_identity_public_v3";
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
private static SharedPreferences getSharedPreferences(Context context) {
return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
}
public static boolean hasIdentityKey(Context context) {
SharedPreferences preferences = context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
SharedPreferences preferences = getSharedPreferences(context);
return
preferences.contains(IDENTITY_PUBLIC_KEY_PREF) &&
preferences.contains(IDENTITY_PRIVATE_KEY_PREF);
return (preferences.contains(IDENTITY_PUBLIC_KEY_PREF) &&
preferences.contains(IDENTITY_PRIVATE_KEY_PREF))
|| (preferences.contains(IDENTITY_PUBLIC_KEY_PREF+ENCRYPTED_SUFFIX) &&
preferences.contains(IDENTITY_PRIVATE_KEY_PREF+ENCRYPTED_SUFFIX));
}
public static void checkUpdate(Context context) {
SharedPreferences preferences = getSharedPreferences(context);
// check if any keys are not migrated
if (hasIdentityKey(context) && !preferences.getBoolean(HAS_MIGRATED_KEY, false)) {
// this will retrieve and force upgrade if possible
// retrieve will force upgrade if available
retrieve(context,IDENTITY_PUBLIC_KEY_PREF);
retrieve(context,IDENTITY_PRIVATE_KEY_PREF);
retrieve(context,ED25519_PUBLIC_KEY);
retrieve(context,ED25519_SECRET_KEY);
retrieve(context,LOKI_SEED);
preferences.edit().putBoolean(HAS_MIGRATED_KEY, true).apply();
}
}
public static @NonNull IdentityKey getIdentityKey(@NonNull Context context) {
@ -94,14 +117,56 @@ public class IdentityKeyUtil {
public static String retrieve(Context context, String key) {
SharedPreferences preferences = context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
return preferences.getString(key, null);
String unencryptedSecret = preferences.getString(key, null);
String encryptedSecret = preferences.getString(key+ENCRYPTED_SUFFIX, null);
if (unencryptedSecret != null) return getUnencryptedSecret(key, unencryptedSecret, context);
else if (encryptedSecret != null) return getEncryptedSecret(encryptedSecret);
return null;
}
private static String getUnencryptedSecret(String key, String unencryptedSecret, Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return unencryptedSecret;
} else {
KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(unencryptedSecret.getBytes());
// save the encrypted suffix secret "key_encrypted"
save(context,key+ENCRYPTED_SUFFIX,encryptedSecret.serialize());
// delete the regular secret "key"
delete(context,key);
return unencryptedSecret;
}
}
private static String getEncryptedSecret(String encryptedSecret) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
throw new AssertionError("OS downgrade not supported. KeyStore sealed data exists on platform < M!");
} else {
KeyStoreHelper.SealedData sealedData = KeyStoreHelper.SealedData.fromString(encryptedSecret);
return new String(KeyStoreHelper.unseal(sealedData));
}
}
public static void save(Context context, String key, String value) {
SharedPreferences preferences = context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
Editor preferencesEditor = preferences.edit();
preferencesEditor.putString(key, value);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
boolean isEncryptedSuffix = key.endsWith(ENCRYPTED_SUFFIX);
if (isEncryptedSuffix) {
preferencesEditor.putString(key, value);
} else {
KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(value.getBytes());
preferencesEditor.putString(key+ENCRYPTED_SUFFIX, encryptedSecret.serialize());
}
} else {
preferencesEditor.putString(key, value);
}
if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences");
}

View File

@ -1,4 +1,4 @@
package org.session.libsession.utilities
package org.thoughtcrime.securesms.crypto
import android.content.Context
import com.goterl.lazysodium.LazySodiumAndroid

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.utilities
package org.thoughtcrime.securesms.crypto
import android.content.Context

View File

@ -44,8 +44,8 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachmentAudioExtras;
import org.session.libsession.utilities.MediaTypes;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.ExternalStorageUtil;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
@ -820,7 +820,7 @@ public class AttachmentDatabase extends Database {
* @return true if the update operation was successful.
*/
@Synchronized
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras, long threadId) {
ContentValues values = new ContentValues();
values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());
values.put(AUDIO_DURATION, extras.getDurationMs());
@ -830,9 +830,22 @@ public class AttachmentDatabase extends Database {
PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE,
extras.getAttachmentId().toStrings());
if (threadId >= 0) {
notifyConversationListeners(threadId);
}
return alteredRows > 0;
}
/**
* Updates audio extra columns for the "audio/*" mime type attachments only.
* @return true if the update operation was successful.
*/
@Synchronized
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
return setAttachmentAudioExtras(extras, -1); // -1 for no update
}
@VisibleForTesting
class ThumbnailFetchCallable implements Callable<InputStream> {

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.database
package org.thoughtcrime.securesms.database
import android.net.Uri
import java.util.*

View File

@ -25,13 +25,8 @@ import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase;
import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase;
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
import org.thoughtcrime.securesms.loki.database.SessionJobDatabase;
import org.thoughtcrime.securesms.loki.database.SessionContactDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.SessionJobDatabase;
public class DatabaseFactory {

View File

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki.utilities
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import androidx.core.database.getStringOrNull

Some files were not shown because too many files have changed in this diff Show More