mirror of
https://github.com/oxen-io/session-android.git
synced 2025-03-13 21:30:56 +00:00
Merge remote-tracking branch 'upstream/dev' into disappearing-messages
# Conflicts: # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt # app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt # app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java # app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java # app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt # app/src/main/res/layout/activity_conversation_v2_action_bar.xml # app/src/main/res/layout/expiration_dialog.xml # app/src/main/res/menu/menu_conversation_expiration.xml # app/src/main/res/menu/menu_conversation_expiration_on.xml # app/src/main/res/values/strings.xml # libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt # libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt # libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java # libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java # libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt # libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt # libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt # libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt # libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java
This commit is contained in:
commit
325abe020a
@ -4,7 +4,7 @@
|
||||
|
||||
Add the [F-Droid repo](https://fdroid.getsession.org/)
|
||||
|
||||
[Download the APK from here](https://github.com/loki-project/session-android/releases/latest)
|
||||
[Download the APK from here](https://github.com/oxen-io/session-android/releases/latest)
|
||||
|
||||
## Summary
|
||||
|
||||
|
@ -43,6 +43,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
||||
implementation 'androidx.activity:activity-ktx:1.5.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
||||
@ -95,7 +96,8 @@ dependencies {
|
||||
implementation 'com.takisoft.fix:colorpicker:1.0.1'
|
||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||
implementation 'org.signal:android-database-sqlcipher:3.5.9-S3'
|
||||
implementation 'androidx.sqlite:sqlite-ktx:2.2.0'
|
||||
implementation 'net.zetetic:sqlcipher-android:4.5.3@aar'
|
||||
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
|
||||
exclude group: 'com.fasterxml.jackson.core'
|
||||
exclude group: 'org.freemarker'
|
||||
@ -158,8 +160,8 @@ dependencies {
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 321
|
||||
def canonicalVersionName = "1.16.3"
|
||||
def canonicalVersionCode = 338
|
||||
def canonicalVersionName = "1.16.9"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['armeabi-v7a' : 1,
|
||||
|
@ -410,12 +410,6 @@
|
||||
<action android:name="network.loki.securesms.RESTART" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@ -449,17 +443,9 @@
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService"
|
||||
android:enabled="@bool/enable_job_service"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
tools:targetApi="26" />
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService"
|
||||
android:enabled="@bool/enable_alarm_manager" />
|
||||
<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" />
|
||||
|
@ -47,6 +47,7 @@ import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.WindowDebouncer;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
|
||||
import org.session.libsignal.utilities.HTTP;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.ThreadUtils;
|
||||
@ -54,20 +55,16 @@ import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.database.Storage;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
|
||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage;
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
||||
@ -80,7 +77,6 @@ import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
|
||||
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
|
||||
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
|
||||
@ -110,7 +106,6 @@ import dagger.hilt.EntryPoints;
|
||||
import dagger.hilt.android.HiltAndroidApp;
|
||||
import kotlin.Unit;
|
||||
import kotlinx.coroutines.Job;
|
||||
import network.loki.messenger.BuildConfig;
|
||||
|
||||
/**
|
||||
* Will be called once when the TextSecure process is created.
|
||||
@ -130,7 +125,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
private ExpiringMessageManager expiringMessageManager;
|
||||
private TypingStatusRepository typingStatusRepository;
|
||||
private TypingStatusSender typingStatusSender;
|
||||
private JobManager jobManager;
|
||||
private ReadReceiptManager readReceiptManager;
|
||||
private ProfileManager profileManager;
|
||||
public MessageNotifier messageNotifier = null;
|
||||
@ -145,7 +139,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
@Inject LokiAPIDatabase lokiAPIDatabase;
|
||||
@Inject Storage storage;
|
||||
@Inject MessageDataProvider messageDataProvider;
|
||||
@Inject JobDatabase jobDatabase;
|
||||
@Inject TextSecurePreferences textSecurePreferences;
|
||||
CallMessageProcessor callMessageProcessor;
|
||||
MessagingModuleConfiguration messagingModuleConfiguration;
|
||||
@ -164,10 +157,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
return (ApplicationContext) context.getApplicationContext();
|
||||
}
|
||||
|
||||
public TextSecurePreferences getPrefs() {
|
||||
return textSecurePreferences;
|
||||
}
|
||||
|
||||
public DatabaseComponent getDatabaseComponent() {
|
||||
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
|
||||
}
|
||||
@ -203,9 +192,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
storage,
|
||||
messageDataProvider,
|
||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
|
||||
// migrate session open group data
|
||||
OpenGroupMigrator.migrate(getDatabaseComponent());
|
||||
// end migration
|
||||
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
||||
Log.i(TAG, "onCreate()");
|
||||
startKovenant();
|
||||
@ -230,12 +216,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
initializeProfileManager();
|
||||
initializePeriodicTasks();
|
||||
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
|
||||
initializeJobManager();
|
||||
initializeWebRtc();
|
||||
initializeBlobProvider();
|
||||
resubmitProfilePictureIfNeeded();
|
||||
loadEmojiSearchIndexIfNeeded();
|
||||
EmojiSource.refresh();
|
||||
|
||||
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
|
||||
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -244,6 +232,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
Log.i(TAG, "App is now visible.");
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
|
||||
// If the user account hasn't been created or onboarding wasn't finished then don't start
|
||||
// the pollers
|
||||
if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ThreadUtils.queue(()->{
|
||||
if (poller != null) {
|
||||
poller.setCaughtUp(false);
|
||||
@ -264,7 +258,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
if (poller != null) {
|
||||
poller.stopIfNeeded();
|
||||
}
|
||||
ClosedGroupPollerV2.getShared().stop();
|
||||
ClosedGroupPollerV2.getShared().stopAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -278,10 +272,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
LocaleParser.Companion.configure(new LocaleParseHelper());
|
||||
}
|
||||
|
||||
public JobManager getJobManager() {
|
||||
return jobManager;
|
||||
}
|
||||
|
||||
public ExpiringMessageManager getExpiringMessageManager() {
|
||||
return expiringMessageManager;
|
||||
}
|
||||
@ -344,16 +334,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
|
||||
}
|
||||
|
||||
private void initializeJobManager() {
|
||||
this.jobManager = new JobManager(this, new JobManager.Configuration.Builder()
|
||||
.setDataSerializer(new JsonDataSerializer())
|
||||
.setJobFactories(JobManagerFactories.getJobFactories(this))
|
||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(this))
|
||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(this))
|
||||
.setJobStorage(new FastJobStorage(jobDatabase))
|
||||
.build());
|
||||
}
|
||||
|
||||
private void initializeExpiringMessageManager() {
|
||||
this.expiringMessageManager = new ExpiringMessageManager(this);
|
||||
}
|
||||
@ -376,10 +356,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
|
||||
private void initializePeriodicTasks() {
|
||||
BackgroundPollWorker.schedulePeriodic(this);
|
||||
|
||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
||||
UpdateApkRefreshListener.schedule(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeWebRtc() {
|
||||
@ -444,11 +420,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
String token = task.getResult().getToken();
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||
if (userPublicKey == null) return Unit.INSTANCE;
|
||||
if (TextSecurePreferences.isUsingFCM(this)) {
|
||||
LokiPushNotificationManager.register(token, userPublicKey, this, force);
|
||||
} else {
|
||||
LokiPushNotificationManager.unregister(token, this);
|
||||
}
|
||||
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
|
||||
if (TextSecurePreferences.isUsingFCM(this)) {
|
||||
LokiPushNotificationManager.register(token, userPublicKey, this, force);
|
||||
} else {
|
||||
LokiPushNotificationManager.unregister(token, this);
|
||||
}
|
||||
});
|
||||
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
@ -537,7 +517,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
TextSecurePreferences.setProfileName(this, displayName);
|
||||
}
|
||||
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
||||
if (!deleteDatabase("signal.db")) {
|
||||
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
|
||||
Log.d("Loki", "Failed to delete database.");
|
||||
}
|
||||
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static android.os.Build.VERSION.SDK_INT;
|
||||
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
@ -18,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
|
||||
import org.thoughtcrime.securesms.util.ThemeState;
|
||||
import org.thoughtcrime.securesms.util.UiModeUtilities;
|
||||
@ -92,6 +94,11 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
||||
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
|
||||
recreate();
|
||||
}
|
||||
|
||||
// apply lightStatusBar manually as API 26 does not update properly via applyTheme
|
||||
// https://issuetracker.google.com/issues/65883460?pli=1
|
||||
if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this);
|
||||
if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter {
|
||||
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
|
||||
|
||||
if (slide != null) {
|
||||
thumbnailView.setImageResource(glideRequests, slide, false, false);
|
||||
thumbnailView.setImageResource(glideRequests, slide, false, null);
|
||||
}
|
||||
|
||||
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
||||
|
@ -54,6 +54,7 @@ import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
@ -366,7 +367,7 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (recipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
MessageSender.send(message, recipient.getAddress());
|
||||
}
|
||||
|
||||
|
@ -60,6 +60,7 @@ import androidx.viewpager.widget.ViewPager;
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
@ -423,7 +424,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
|
||||
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
|
||||
long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset();
|
||||
saveTask.executeOnExecutor(
|
||||
AsyncTask.THREAD_POOL_EXECUTOR,
|
||||
new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
|
||||
@ -437,7 +438,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (conversationRecipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
MessageSender.send(message, conversationRecipient.getAddress());
|
||||
}
|
||||
|
||||
|
@ -210,8 +210,7 @@ public class PassphrasePromptActivity extends BaseActionBarActivity {
|
||||
try {
|
||||
signature = biometricSecretProvider.getOrCreateBiometricSignature(this);
|
||||
hasSignatureObject = true;
|
||||
throw new InvalidKeyException("e");
|
||||
} catch (InvalidKeyException e) {
|
||||
} catch (Exception e) {
|
||||
signature = null;
|
||||
hasSignatureObject = false;
|
||||
Log.e(TAG, "Error getting / creating signature", e);
|
||||
|
@ -74,10 +74,10 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
attachmentDatabase.setTransferState(messageID, attachmentId, attachmentState.value)
|
||||
}
|
||||
|
||||
override fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>? {
|
||||
override fun getMessageForQuote(timestamp: Long, author: Address): Triple<Long, Boolean, String>? {
|
||||
val messagingDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val message = messagingDatabase.getMessageFor(timestamp, author)
|
||||
return if (message != null) Pair(message.id, message.isMms) else null
|
||||
return if (message != null) Triple(message.id, message.isMms, message.body) else null
|
||||
}
|
||||
|
||||
override fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List<Attachment> {
|
||||
@ -176,6 +176,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
return messageDB.getMessageID(serverId, threadId)
|
||||
}
|
||||
|
||||
override fun getMessageIDs(serverIds: List<Long>, threadId: Long): Pair<List<Long>, List<Long>> {
|
||||
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
return messageDB.getMessageIDs(serverIds, threadId)
|
||||
}
|
||||
|
||||
override fun deleteMessage(messageID: Long, isSms: Boolean) {
|
||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||
else DatabaseComponent.get(context).mmsDatabase()
|
||||
@ -184,16 +189,27 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
|
||||
}
|
||||
|
||||
override fun updateMessageAsDeleted(timestamp: Long, author: String) {
|
||||
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
|
||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||
else DatabaseComponent.get(context).mmsDatabase()
|
||||
|
||||
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
|
||||
}
|
||||
|
||||
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val address = Address.fromSerialized(author)
|
||||
val message = database.getMessageFor(timestamp, address) ?: return
|
||||
val message = database.getMessageFor(timestamp, address) ?: return null
|
||||
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
|
||||
else DatabaseComponent.get(context).smsDatabase()
|
||||
messagingDatabase.markAsDeleted(message.id, message.isRead)
|
||||
messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention)
|
||||
if (message.isOutgoing) {
|
||||
messagingDatabase.deleteMessage(message.id)
|
||||
}
|
||||
|
||||
return message.id
|
||||
}
|
||||
|
||||
override fun getServerHashForMessage(messageID: Long): String? {
|
||||
|
@ -7,12 +7,21 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer
|
||||
|
||||
private const val TAG = "ScreenshotObserver"
|
||||
|
||||
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
|
||||
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
super.onChange(selfChange, uri)
|
||||
uri ?: return
|
||||
|
||||
// There is an odd bug where we can get notified for changes to 'content://media/external'
|
||||
// directly which is a protected folder, this code is to prevent that crash
|
||||
if (uri.scheme == "content" && uri.host == "media" && uri.path == "/external") { return }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryRelativeDataColumn(uri)
|
||||
} else {
|
||||
@ -26,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATA
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.RELATIVE_PATH
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) {
|
||||
|
||||
enum class Type {
|
||||
PROGRESS, FINISHED
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null)
|
||||
@JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null)
|
||||
@JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e)
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
|
||||
/**
|
||||
* Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23.
|
||||
*/
|
||||
public class BackupPassphrase {
|
||||
|
||||
private static final String TAG = BackupPassphrase.class.getSimpleName();
|
||||
|
||||
public static @Nullable String get(@NonNull Context context) {
|
||||
String passphrase = TextSecurePreferences.getBackupPassphrase(context);
|
||||
String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
||||
|
||||
if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) {
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
if (encryptedPassphrase == null) {
|
||||
Log.i(TAG, "Migrating to encrypted passphrase.");
|
||||
set(context, passphrase);
|
||||
encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
||||
}
|
||||
|
||||
KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase);
|
||||
return new String(KeyStoreHelper.unseal(data));
|
||||
}
|
||||
|
||||
public static void set(@NonNull Context context, @Nullable String passphrase) {
|
||||
if (passphrase == null || Build.VERSION.SDK_INT < 23) {
|
||||
TextSecurePreferences.setBackupPassphrase(context, passphrase);
|
||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, null);
|
||||
} else {
|
||||
KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes());
|
||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize());
|
||||
TextSecurePreferences.setBackupPassphrase(context, null);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.preference.PreferenceManager
|
||||
import android.preference.PreferenceManager.getDefaultSharedPreferencesName
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT
|
||||
import java.util.*
|
||||
|
||||
object BackupPreferences {
|
||||
// region Backup related
|
||||
fun getBackupRecords(context: Context): List<BackupProtos.SharedPreference> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val prefsFileName: String
|
||||
prefsFileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
getDefaultSharedPreferencesName(context)
|
||||
} else {
|
||||
context.packageName + "_preferences"
|
||||
}
|
||||
val prefList: LinkedList<BackupProtos.SharedPreference> = LinkedList<BackupProtos.SharedPreference>()
|
||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_NUMBER_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_NAME_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_URL_PREF)
|
||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_ID_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_KEY_PREF)
|
||||
addBackupEntryBoolean(prefList, preferences, prefsFileName, TextSecurePreferences.IS_USING_FCM)
|
||||
return prefList
|
||||
}
|
||||
|
||||
private fun addBackupEntryString(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
val value = prefs.getString(prefKey, null)
|
||||
if (value == null) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(prefKey)
|
||||
.setValue(value)
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun addBackupEntryInt(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
val value = prefs.getInt(prefKey, -1)
|
||||
if (value == -1) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference.
|
||||
.setValue(value.toString())
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun addBackupEntryBoolean(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
if (!prefs.contains(prefKey)) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference.
|
||||
.setValue(prefs.getBoolean(prefKey, false).toString())
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun logBackupEntry(prefName: String, wasIncluded: Boolean) {
|
||||
val sb = StringBuilder()
|
||||
sb.append("Backup preference ")
|
||||
sb.append(if (wasIncluded) "+ " else "- ")
|
||||
sb.append('\"').append(prefName).append("\" ")
|
||||
if (!wasIncluded) {
|
||||
sb.append("(is empty and not included)")
|
||||
}
|
||||
Log.d("Loki", sb.toString())
|
||||
} // endregion
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,447 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.annimon.stream.function.Consumer
|
||||
import com.annimon.stream.function.Predicate
|
||||
import com.google.protobuf.ByteString
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.utilities.Conversions
|
||||
import org.session.libsession.utilities.Util
|
||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Header
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||
import org.thoughtcrime.securesms.database.PushDatabase
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.Flushable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.LinkedList
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.IllegalBlockSizeException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.NoSuchPaddingException
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object FullBackupExporter {
|
||||
private val TAG = FullBackupExporter::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun export(context: Context,
|
||||
attachmentSecret: AttachmentSecret,
|
||||
input: SQLiteDatabase,
|
||||
fileUri: Uri,
|
||||
passphrase: String) {
|
||||
|
||||
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
|
||||
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
|
||||
outputStream.writeDatabaseVersion(input.version)
|
||||
val tables = exportSchema(input, outputStream)
|
||||
for (table in tables) if (shouldExportTable(table)) {
|
||||
count = when (table) {
|
||||
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
GroupReceiptDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
AttachmentDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
|
||||
},
|
||||
{ cursor: Cursor ->
|
||||
exportAttachment(attachmentSecret, cursor, outputStream)
|
||||
},
|
||||
count)
|
||||
}
|
||||
else -> {
|
||||
exportTable(table, input, outputStream, null, null, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (preference in BackupUtil.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (preference in BackupPreferences.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (avatar in AvatarHelper.getAvatarFiles(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
|
||||
}
|
||||
outputStream.writeEnd()
|
||||
}
|
||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to make full backup.", e)
|
||||
EventBus.getDefault().post(BackupEvent.createFinished(e))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun shouldExportTable(table: String): Boolean {
|
||||
return table != PushDatabase.TABLE_NAME &&
|
||||
|
||||
table != LokiBackupFilesDatabase.TABLE_NAME &&
|
||||
table != LokiAPIDatabase.openGroupProfilePictureTable &&
|
||||
|
||||
table != JobDatabase.Jobs.TABLE_NAME &&
|
||||
table != JobDatabase.Constraints.TABLE_NAME &&
|
||||
table != JobDatabase.Dependencies.TABLE_NAME &&
|
||||
|
||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith("sqlite_")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
|
||||
val tables: MutableList<String> = LinkedList()
|
||||
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val sql = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val type = cursor.getString(2)
|
||||
if (sql != null) {
|
||||
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
|
||||
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
|
||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
||||
if ("table" == type) {
|
||||
tables.add(name)
|
||||
}
|
||||
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportTable(table: String,
|
||||
input: SQLiteDatabase,
|
||||
outputStream: BackupFrameOutputStream,
|
||||
predicate: Predicate<Cursor>?,
|
||||
postProcess: Consumer<Cursor>?,
|
||||
count: Int): Int {
|
||||
var count = count
|
||||
val template = "INSERT INTO $table VALUES "
|
||||
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
if (predicate != null && !predicate.test(cursor)) continue
|
||||
|
||||
val statement = StringBuilder(template)
|
||||
val statementBuilder = SqlStatement.newBuilder()
|
||||
statement.append('(')
|
||||
for (i in 0 until cursor.columnCount) {
|
||||
statement.append('?')
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_STRING -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setStringParamter(cursor.getString(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_FLOAT -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setDoubleParameter(cursor.getDouble(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_INTEGER -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setIntegerParameter(cursor.getLong(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_BLOB -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
|
||||
}
|
||||
Cursor.FIELD_TYPE_NULL -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setNullparameter(true))
|
||||
}
|
||||
else -> {
|
||||
throw AssertionError("unknown type?" + cursor.getType(i))
|
||||
}
|
||||
}
|
||||
if (i < cursor.columnCount - 1) {
|
||||
statement.append(',')
|
||||
}
|
||||
}
|
||||
statement.append(')')
|
||||
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
|
||||
postProcess?.accept(cursor)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||
try {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
|
||||
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
|
||||
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
|
||||
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
|
||||
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
|
||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
|
||||
}
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
|
||||
var result: Long = 0
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
var read: Int
|
||||
val buffer = ByteArray(8192)
|
||||
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
|
||||
result += read.toLong()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
|
||||
val columns = arrayOf(MmsSmsColumns.EXPIRES_IN)
|
||||
val where = MmsSmsColumns.ID + " = ?"
|
||||
val args = arrayOf(mmsId.toString())
|
||||
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return mmsCursor.getLong(0) == 0L
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private class BackupFrameOutputStream : Closeable, Flushable {
|
||||
|
||||
private val outputStream: OutputStream
|
||||
private var cipher: Cipher
|
||||
private var mac: Mac
|
||||
private val cipherKey: ByteArray
|
||||
private val macKey: ByteArray
|
||||
private val iv: ByteArray
|
||||
|
||||
private var counter: Int = 0
|
||||
|
||||
constructor(outputStream: OutputStream, passphrase: String) : super() {
|
||||
try {
|
||||
val salt = Util.getSecretBytes(32)
|
||||
val key = BackupUtil.computeBackupKey(passphrase, salt)
|
||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||
val split = ByteUtil.split(derived, 32, 32)
|
||||
cipherKey = split[0]
|
||||
macKey = split[1]
|
||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
mac = Mac.getInstance("HmacSHA256")
|
||||
this.outputStream = outputStream
|
||||
iv = Util.getSecretBytes(16)
|
||||
counter = Conversions.byteArrayToInt(iv)
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
|
||||
.setIv(ByteString.copyFrom(iv))
|
||||
.setSalt(ByteString.copyFrom(salt)))
|
||||
.build().toByteArray()
|
||||
outputStream.write(Conversions.intToByteArray(header.size))
|
||||
outputStream.write(header)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is NoSuchAlgorithmException,
|
||||
is NoSuchPaddingException,
|
||||
is InvalidKeyException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSql(statement: SqlStatement) {
|
||||
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writePreferenceEntry(preference: SharedPreference?) {
|
||||
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAvatar(Avatar.newBuilder()
|
||||
.setName(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAttachment(Attachment.newBuilder()
|
||||
.setRowId(attachmentId.rowId)
|
||||
.setAttachmentId(attachmentId.uniqueId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setSticker(Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeDatabaseVersion(version: Int) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
|
||||
.build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeEnd() {
|
||||
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun writeStream(inputStream: InputStream) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.update(iv)
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
val ciphertext = cipher.update(buffer, 0, read)
|
||||
if (ciphertext != null) {
|
||||
outputStream.write(ciphertext)
|
||||
mac.update(ciphertext)
|
||||
}
|
||||
}
|
||||
val remainder = cipher.doFinal()
|
||||
outputStream.write(remainder)
|
||||
mac.update(remainder)
|
||||
val attachmentDigest = mac.doFinal()
|
||||
outputStream.write(attachmentDigest, 0, 10)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun write(out: OutputStream, frame: BackupFrame) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
val frameCiphertext = cipher.doFinal(frame.toByteArray())
|
||||
val frameMac = mac.doFinal(frameCiphertext)
|
||||
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
|
||||
out.write(length)
|
||||
out.write(frameCiphertext)
|
||||
out.write(frameMac, 0, 10)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun flush() {
|
||||
outputStream.flush()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,352 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Conversions
|
||||
import org.session.libsession.utilities.Util
|
||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.IllegalBlockSizeException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.NoSuchPaddingException
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object FullBackupImporter {
|
||||
/**
|
||||
* Because BackupProtos.SharedPreference was made only to serialize string values,
|
||||
* we use these 3-char prefixes to explicitly cast the values before inserting to a preference file.
|
||||
*/
|
||||
const val PREF_PREFIX_TYPE_INT = "i__"
|
||||
const val PREF_PREFIX_TYPE_BOOLEAN = "b__"
|
||||
|
||||
private val TAG = FullBackupImporter::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun importFromUri(context: Context,
|
||||
attachmentSecret: AttachmentSecret,
|
||||
db: SQLiteDatabase,
|
||||
fileUri: Uri,
|
||||
passphrase: String) {
|
||||
|
||||
val baseInputStream = context.contentResolver.openInputStream(fileUri)
|
||||
?: throw IOException("Cannot open an input stream for the file URI: $fileUri")
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
BackupRecordInputStream(baseInputStream, passphrase).use { inputStream ->
|
||||
db.beginTransaction()
|
||||
dropAllTables(db)
|
||||
var frame: BackupFrame
|
||||
while (!inputStream.readFrame().also { frame = it }.end) {
|
||||
if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count))
|
||||
when {
|
||||
frame.hasVersion() -> processVersion(db, frame.version)
|
||||
frame.hasStatement() -> processStatement(db, frame.statement)
|
||||
frame.hasPreference() -> processPreference(context, frame.preference)
|
||||
frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream)
|
||||
frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream)
|
||||
}
|
||||
}
|
||||
trimEntriesForExpiredMessages(context, db)
|
||||
db.setTransactionSuccessful()
|
||||
}
|
||||
} finally {
|
||||
if (db.inTransaction()) {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) {
|
||||
if (version.version > db.version) {
|
||||
throw DatabaseDowngradeException(db.version, version.version)
|
||||
}
|
||||
db.version = version.version
|
||||
}
|
||||
|
||||
private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) {
|
||||
val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_")
|
||||
val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_")
|
||||
val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_")
|
||||
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
|
||||
Log.i(TAG, "Ignoring import for statement: " + statement.statement)
|
||||
return
|
||||
}
|
||||
val parameters: MutableList<Any?> = LinkedList()
|
||||
for (parameter in statement.parametersList) {
|
||||
when {
|
||||
parameter.hasStringParamter() -> parameters.add(parameter.stringParamter)
|
||||
parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter)
|
||||
parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter)
|
||||
parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray())
|
||||
parameter.hasNullparameter() -> parameters.add(null)
|
||||
}
|
||||
}
|
||||
if (parameters.size > 0) {
|
||||
db.execSQL(statement.statement, parameters.toTypedArray())
|
||||
} else {
|
||||
db.execSQL(statement.statement)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret,
|
||||
db: SQLiteDatabase, attachment: Attachment,
|
||||
inputStream: BackupRecordInputStream) {
|
||||
val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
|
||||
val dataFile = File.createTempFile("part", ".mms", partsDirectory)
|
||||
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
|
||||
inputStream.readAttachmentTo(output.second, attachment.length)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath)
|
||||
contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?)
|
||||
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first)
|
||||
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
||||
"${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?",
|
||||
arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString()))
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) {
|
||||
inputStream.readAttachmentTo(FileOutputStream(
|
||||
AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length)
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref")
|
||||
private fun processPreference(context: Context, preference: SharedPreference) {
|
||||
val preferences = context.getSharedPreferences(preference.file, 0)
|
||||
val key = preference.key
|
||||
val value = preference.value
|
||||
|
||||
// See the comment next to PREF_PREFIX_TYPE_* constants.
|
||||
when {
|
||||
key.startsWith(PREF_PREFIX_TYPE_INT) ->
|
||||
preferences.edit().putInt(
|
||||
key.substring(PREF_PREFIX_TYPE_INT.length),
|
||||
value.toInt()
|
||||
).commit()
|
||||
key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) ->
|
||||
preferences.edit().putBoolean(
|
||||
key.substring(PREF_PREFIX_TYPE_BOOLEAN.length),
|
||||
value.toBoolean()
|
||||
).commit()
|
||||
else ->
|
||||
preferences.edit().putString(key, value).commit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropAllTables(db: SQLiteDatabase) {
|
||||
db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val name = cursor.getString(0)
|
||||
val type = cursor.getString(1)
|
||||
if ("table" == type && !name.startsWith("sqlite_")) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) {
|
||||
val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})"
|
||||
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null)
|
||||
val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID)
|
||||
val where = AttachmentDatabase.MMS_ID + trimmedCondition
|
||||
db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
DatabaseComponent.get(context).attachmentDatabase()
|
||||
.deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1)))
|
||||
}
|
||||
}
|
||||
db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID),
|
||||
ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
DatabaseComponent.get(context).threadDatabase().update(cursor.getLong(0), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackupRecordInputStream : Closeable {
|
||||
private val inputStream: InputStream
|
||||
private val cipher: Cipher
|
||||
private val mac: Mac
|
||||
private val cipherKey: ByteArray
|
||||
private val macKey: ByteArray
|
||||
private val iv: ByteArray
|
||||
|
||||
private var counter = 0
|
||||
|
||||
@Throws(IOException::class)
|
||||
constructor(inputStream: InputStream, passphrase: String) : super() {
|
||||
try {
|
||||
this.inputStream = inputStream
|
||||
val headerLengthBytes = ByteArray(4)
|
||||
Util.readFully(this.inputStream, headerLengthBytes)
|
||||
val headerLength = Conversions.byteArrayToInt(headerLengthBytes)
|
||||
val headerFrame = ByteArray(headerLength)
|
||||
Util.readFully(this.inputStream, headerFrame)
|
||||
val frame = BackupFrame.parseFrom(headerFrame)
|
||||
if (!frame.hasHeader()) {
|
||||
throw IOException("Backup stream does not start with header!")
|
||||
}
|
||||
val header = frame.header
|
||||
iv = header.iv.toByteArray()
|
||||
if (iv.size != 16) {
|
||||
throw IOException("Invalid IV length!")
|
||||
}
|
||||
val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null)
|
||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||
val split = ByteUtil.split(derived, 32, 32)
|
||||
cipherKey = split[0]
|
||||
macKey = split[1]
|
||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
counter = Conversions.byteArrayToInt(iv)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is NoSuchAlgorithmException,
|
||||
is NoSuchPaddingException,
|
||||
is InvalidKeyException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun readFrame(): BackupFrame {
|
||||
return readFrame(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun readAttachmentTo(out: OutputStream, length: Int) {
|
||||
var length = length
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.update(iv)
|
||||
val buffer = ByteArray(8192)
|
||||
while (length > 0) {
|
||||
val read = inputStream.read(buffer, 0, Math.min(buffer.size, length))
|
||||
if (read == -1) throw IOException("File ended early!")
|
||||
mac.update(buffer, 0, read)
|
||||
val plaintext = cipher.update(buffer, 0, read)
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.size)
|
||||
}
|
||||
length -= read
|
||||
}
|
||||
val plaintext = cipher.doFinal()
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.size)
|
||||
}
|
||||
out.close()
|
||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||
val theirMac = ByteArray(10)
|
||||
try {
|
||||
Util.readFully(inputStream, theirMac)
|
||||
} catch (e: IOException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw IOException("Bad MAC")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun readFrame(`in`: InputStream?): BackupFrame {
|
||||
return try {
|
||||
val length = ByteArray(4)
|
||||
Util.readFully(`in`, length)
|
||||
val frame = ByteArray(Conversions.byteArrayToInt(length))
|
||||
Util.readFully(`in`, frame)
|
||||
val theirMac = ByteArray(10)
|
||||
System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size)
|
||||
mac.update(frame, 0, frame.size - 10)
|
||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw IOException("Bad MAC")
|
||||
}
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
val plaintext = cipher.doFinal(frame, 0, frame.size - 10)
|
||||
BackupFrame.parseFrom(plaintext)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) :
|
||||
IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion")
|
||||
}
|
@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
@ -106,7 +107,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
messageRecord.getExpiresIn());
|
||||
this.timerView.startAnimation();
|
||||
|
||||
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) {
|
||||
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) {
|
||||
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
|
||||
}
|
||||
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
|
||||
|
@ -4,30 +4,48 @@ import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.bumptech.glide.request.target.BitmapImageViewTarget;
|
||||
|
||||
import org.session.libsignal.utilities.SettableFuture;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
public class GlideBitmapListeningTarget extends BitmapImageViewTarget {
|
||||
|
||||
private final SettableFuture<Boolean> loaded;
|
||||
private final WeakReference<View> loadingView;
|
||||
|
||||
public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
|
||||
public GlideBitmapListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) {
|
||||
super(view);
|
||||
this.loaded = loaded;
|
||||
this.loadingView = new WeakReference<View>(loadingView);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setResource(@Nullable Bitmap resource) {
|
||||
super.setResource(resource);
|
||||
loaded.set(true);
|
||||
|
||||
View loadingViewInstance = loadingView.get();
|
||||
|
||||
if (loadingViewInstance != null) {
|
||||
loadingViewInstance.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFailed(@Nullable Drawable errorDrawable) {
|
||||
super.onLoadFailed(errorDrawable);
|
||||
loaded.set(true);
|
||||
|
||||
View loadingViewInstance = loadingView.get();
|
||||
|
||||
if (loadingViewInstance != null) {
|
||||
loadingViewInstance.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,30 +3,48 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.bumptech.glide.request.target.DrawableImageViewTarget;
|
||||
|
||||
import org.session.libsignal.utilities.SettableFuture;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
|
||||
|
||||
private final SettableFuture<Boolean> loaded;
|
||||
private final WeakReference<View> loadingView;
|
||||
|
||||
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
|
||||
public GlideDrawableListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) {
|
||||
super(view);
|
||||
this.loaded = loaded;
|
||||
this.loadingView = new WeakReference<View>(loadingView);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setResource(@Nullable Drawable resource) {
|
||||
super.setResource(resource);
|
||||
loaded.set(true);
|
||||
|
||||
View loadingViewInstance = loadingView.get();
|
||||
|
||||
if (loadingViewInstance != null) {
|
||||
loadingViewInstance.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFailed(@Nullable Drawable errorDrawable) {
|
||||
super.onLoadFailed(errorDrawable);
|
||||
loaded.set(true);
|
||||
|
||||
View loadingViewInstance = loadingView.get();
|
||||
|
||||
if (loadingViewInstance != null) {
|
||||
loadingViewInstance.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,70 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewSeparatorBinding
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
|
||||
class LabeledSeparatorView : RelativeLayout {
|
||||
|
||||
private lateinit var binding: ViewSeparatorBinding
|
||||
private val path = Path()
|
||||
|
||||
private val paint: Paint by lazy {
|
||||
val result = Paint()
|
||||
result.style = Paint.Style.STROKE
|
||||
result.color = ThemeUtil.getThemedColor(context, R.attr.dividerHorizontal)
|
||||
result.strokeWidth = toPx(1, resources).toFloat()
|
||||
result.isAntiAlias = true
|
||||
result
|
||||
}
|
||||
|
||||
// region Lifecycle
|
||||
constructor(context: Context) : super(context) {
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
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() {
|
||||
binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context))
|
||||
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(binding.root, layoutParams)
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
override fun onDraw(c: Canvas) {
|
||||
super.onDraw(c)
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val hMargin = toPx(16, resources).toFloat()
|
||||
path.reset()
|
||||
path.moveTo(0.0f, h / 2)
|
||||
path.lineTo(binding.titleTextView.left - hMargin, h / 2)
|
||||
path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW)
|
||||
path.moveTo(binding.titleTextView.right + hMargin, h / 2)
|
||||
path.lineTo(w, h / 2)
|
||||
path.close()
|
||||
c.drawPath(path, paint)
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -1,158 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
public class LinkPreviewView extends FrameLayout {
|
||||
|
||||
private static final int TYPE_CONVERSATION = 0;
|
||||
private static final int TYPE_COMPOSE = 1;
|
||||
|
||||
private ViewGroup container;
|
||||
private OutlinedThumbnailView thumbnail;
|
||||
private TextView title;
|
||||
private TextView site;
|
||||
private View divider;
|
||||
private View closeButton;
|
||||
private View spinner;
|
||||
|
||||
private int type;
|
||||
private int defaultRadius;
|
||||
private CornerMask cornerMask;
|
||||
private Outliner outliner;
|
||||
private CloseClickedListener closeClickedListener;
|
||||
|
||||
public LinkPreviewView(Context context) {
|
||||
super(context);
|
||||
init(null);
|
||||
}
|
||||
|
||||
public LinkPreviewView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(attrs);
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.link_preview, this);
|
||||
|
||||
container = findViewById(R.id.linkpreview_container);
|
||||
thumbnail = findViewById(R.id.linkpreview_thumbnail);
|
||||
title = findViewById(R.id.linkpreview_title);
|
||||
site = findViewById(R.id.linkpreview_site);
|
||||
divider = findViewById(R.id.linkpreview_divider);
|
||||
spinner = findViewById(R.id.linkpreview_progress_wheel);
|
||||
closeButton = findViewById(R.id.linkpreview_close);
|
||||
defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius);
|
||||
cornerMask = new CornerMask(this);
|
||||
outliner = new Outliner();
|
||||
|
||||
outliner.setColor(getResources().getColor(R.color.transparent));
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0);
|
||||
type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0);
|
||||
typedArray.recycle();
|
||||
}
|
||||
|
||||
if (type == TYPE_COMPOSE) {
|
||||
container.setBackgroundColor(Color.TRANSPARENT);
|
||||
container.setPadding(0, 0, 0, 0);
|
||||
divider.setVisibility(VISIBLE);
|
||||
|
||||
closeButton.setOnClickListener(v -> {
|
||||
if (closeClickedListener != null) {
|
||||
closeClickedListener.onCloseClicked();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setWillNotDraw(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispatchDraw(Canvas canvas) {
|
||||
super.dispatchDraw(canvas);
|
||||
if (type == TYPE_COMPOSE) return;
|
||||
|
||||
cornerMask.mask(canvas);
|
||||
outliner.draw(canvas);
|
||||
}
|
||||
|
||||
public void setLoading() {
|
||||
title.setVisibility(GONE);
|
||||
site.setVisibility(GONE);
|
||||
thumbnail.setVisibility(GONE);
|
||||
spinner.setVisibility(VISIBLE);
|
||||
closeButton.setVisibility(GONE);
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showCloseButton) {
|
||||
setLinkPreview(glideRequests, linkPreview, showThumbnail);
|
||||
if (showCloseButton) {
|
||||
closeButton.setVisibility(VISIBLE);
|
||||
} else {
|
||||
closeButton.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
||||
title.setVisibility(VISIBLE);
|
||||
site.setVisibility(VISIBLE);
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
spinner.setVisibility(GONE);
|
||||
closeButton.setVisibility(VISIBLE);
|
||||
|
||||
title.setText(linkPreview.getTitle());
|
||||
|
||||
HttpUrl url = HttpUrl.parse(linkPreview.getUrl());
|
||||
if (url != null) {
|
||||
site.setText(url.topPrivateDomain());
|
||||
}
|
||||
|
||||
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
|
||||
thumbnail.showDownloadText(false);
|
||||
} else {
|
||||
thumbnail.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setCorners(int topLeft, int topRight) {
|
||||
cornerMask.setRadii(topLeft, topRight, 0, 0);
|
||||
outliner.setRadii(topLeft, topRight, 0, 0);
|
||||
thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius);
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) {
|
||||
this.closeClickedListener = closeClickedListener;
|
||||
}
|
||||
|
||||
public void setDownloadClickedListener(SlidesClickedListener listener) {
|
||||
thumbnail.setDownloadClickListener(listener);
|
||||
}
|
||||
|
||||
public interface CloseClickedListener {
|
||||
void onCloseClicked();
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
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;
|
||||
|
||||
public class OutlinedThumbnailView extends ThumbnailView {
|
||||
|
||||
private CornerMask cornerMask;
|
||||
private Outliner outliner;
|
||||
|
||||
public OutlinedThumbnailView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public OutlinedThumbnailView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
cornerMask = new CornerMask(this);
|
||||
outliner = new Outliner();
|
||||
|
||||
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
|
||||
setWillNotDraw(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispatchDraw(Canvas canvas) {
|
||||
super.dispatchDraw(canvas);
|
||||
|
||||
cornerMask.mask(canvas);
|
||||
outliner.draw(canvas);
|
||||
}
|
||||
|
||||
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
|
||||
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
|
||||
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
|
||||
postInvalidate();
|
||||
}
|
||||
}
|
@ -34,6 +34,8 @@ class ProfilePictureView @JvmOverloads constructor(
|
||||
private val profilePicturesCache = mutableMapOf<String, String?>()
|
||||
private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
||||
private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
||||
|
||||
// endregion
|
||||
|
||||
@ -43,10 +45,8 @@ class ProfilePictureView @JvmOverloads constructor(
|
||||
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
|
||||
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
|
||||
}
|
||||
fun isOpenGroupWithProfilePicture(recipient: Recipient): Boolean {
|
||||
return recipient.isOpenGroupRecipient && recipient.groupAvatarId != null
|
||||
}
|
||||
if (recipient.isGroupRecipient && !isOpenGroupWithProfilePicture(recipient)) {
|
||||
|
||||
if (recipient.isClosedGroupRecipient) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase()
|
||||
.getGroupMemberAddresses(recipient.address.toGroupString(), true)
|
||||
.sorted()
|
||||
@ -107,7 +107,7 @@ class ProfilePictureView @JvmOverloads constructor(
|
||||
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return
|
||||
val signalProfilePicture = recipient.contactPhoto
|
||||
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
|
||||
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
|
||||
|
||||
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
|
||||
glide.clear(imageView)
|
||||
glide.load(signalProfilePicture)
|
||||
@ -117,7 +117,12 @@ class ProfilePictureView @JvmOverloads constructor(
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.circleCrop()
|
||||
.into(imageView)
|
||||
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
|
||||
glide.clear(imageView)
|
||||
imageView.setImageDrawable(unknownOpenGroupDrawable)
|
||||
} else {
|
||||
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
|
||||
|
||||
glide.clear(imageView)
|
||||
glide.load(placeholder)
|
||||
.placeholder(unknownRecipientDrawable)
|
||||
|
@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
|
||||
/**
|
||||
* An extension of ViewPager to swallow erroneous multi-touch exceptions.
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/6919292/pointerindex-out-of-range-android-multitouch
|
||||
*/
|
||||
class SafeViewPager @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : ViewPager(context, attrs) {
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean = try {
|
||||
super.onTouchEvent(event)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = try {
|
||||
super.onInterceptTouchEvent(event)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
@ -52,19 +52,4 @@ public class StickerView extends FrameLayout {
|
||||
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
|
||||
image.setOnLongClickListener(l);
|
||||
}
|
||||
|
||||
public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) {
|
||||
boolean showControls = stickerSlide.asAttachment().getDataUri() == null;
|
||||
|
||||
image.setImageResource(glideRequests, stickerSlide, showControls, false);
|
||||
missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
public void setThumbnailClickListener(@NonNull SlideClickListener listener) {
|
||||
image.setThumbnailClickListener(listener);
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(@NonNull SlidesClickedListener listener) {
|
||||
image.setDownloadClickListener(listener);
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private static final char ELLIPSIS = '…';
|
||||
|
||||
private CharSequence previousText;
|
||||
private BufferType previousBufferType;
|
||||
private BufferType previousBufferType = BufferType.NORMAL;
|
||||
private float originalFontSize;
|
||||
private boolean useSystemEmoji;
|
||||
private boolean sizeChangeInProgress;
|
||||
@ -49,6 +49,15 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
@Override public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
// No need to do anything special if the text is null or empty
|
||||
if (text == null || text.length() == 0) {
|
||||
previousText = text;
|
||||
previousOverflowText = overflowText;
|
||||
previousBufferType = type;
|
||||
super.setText(text, type);
|
||||
return;
|
||||
}
|
||||
|
||||
EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text);
|
||||
|
||||
if (scaleEmojis && candidates != null && candidates.allEmojis) {
|
||||
@ -149,10 +158,15 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
|
||||
return Util.equals(previousText, text) &&
|
||||
Util.equals(previousOverflowText, overflowText) &&
|
||||
Util.equals(previousBufferType, bufferType) &&
|
||||
useSystemEmoji == useSystemEmoji() &&
|
||||
CharSequence finalPrevText = (previousText == null || previousText.length() == 0 ? "" : previousText);
|
||||
CharSequence finalText = (text == null || text.length() == 0 ? "" : text);
|
||||
CharSequence finalPrevOverflowText = (previousOverflowText == null || previousOverflowText.length() == 0 ? "" : previousOverflowText);
|
||||
CharSequence finalOverflowText = (overflowText == null || overflowText.length() == 0 ? "" : overflowText);
|
||||
|
||||
return Util.equals(finalPrevText, finalText) &&
|
||||
Util.equals(finalPrevOverflowText, finalOverflowText) &&
|
||||
Util.equals(previousBufferType, bufferType) &&
|
||||
useSystemEmoji == useSystemEmoji() &&
|
||||
!sizeChangeInProgress;
|
||||
}
|
||||
|
||||
|
@ -5,8 +5,9 @@ import androidx.annotation.AttrRes
|
||||
/**
|
||||
* Represents an action to be rendered
|
||||
*/
|
||||
data class ActionItem(
|
||||
data class ActionItem @JvmOverloads constructor(
|
||||
@AttrRes val iconRes: Int,
|
||||
val title: CharSequence,
|
||||
val action: Runnable
|
||||
val action: Runnable,
|
||||
val contentDescription: String? = null
|
||||
)
|
||||
|
@ -77,6 +77,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
||||
context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
|
||||
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
|
||||
}
|
||||
itemView.contentDescription = model.item.contentDescription
|
||||
title.text = model.item.title
|
||||
itemView.setOnClickListener {
|
||||
model.item.action.run()
|
||||
|
@ -32,7 +32,6 @@ import android.widget.RelativeLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@ -45,12 +44,15 @@ import com.annimon.stream.Stream
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
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.messaging.mentions.Mention
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
@ -155,6 +157,7 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
||||
import org.thoughtcrime.securesms.util.push
|
||||
import org.thoughtcrime.securesms.util.show
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@ -248,11 +251,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
var searchViewItem: MenuItem? = null
|
||||
|
||||
private var emojiPickerVisible = false
|
||||
|
||||
private val isScrolledToBottom: Boolean
|
||||
get() {
|
||||
val position = layoutManager?.findFirstCompletelyVisibleItemPosition() ?: 0
|
||||
return position == 0
|
||||
}
|
||||
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
|
||||
|
||||
private val layoutManager: LinearLayoutManager?
|
||||
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
|
||||
@ -293,6 +295,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
onDeselect(message, position, it)
|
||||
}
|
||||
},
|
||||
onAttachmentNeedsDownload = { attachmentId, mmsId ->
|
||||
// Start download (on IO thread)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
|
||||
}
|
||||
},
|
||||
glide = glide,
|
||||
lifecycleCoroutineScope = lifecycleScope
|
||||
)
|
||||
@ -338,41 +346,66 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
// messageIdToScroll
|
||||
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
|
||||
messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR))
|
||||
val thread = threadDb.getRecipientForThreadId(viewModel.threadId)
|
||||
if (thread == null) {
|
||||
val recipient = viewModel.recipient
|
||||
val openGroup = recipient.let { viewModel.openGroup }
|
||||
if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) {
|
||||
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
|
||||
return finish()
|
||||
}
|
||||
setUpRecyclerView()
|
||||
|
||||
setUpToolBar()
|
||||
setUpInputBar()
|
||||
setUpLinkPreviewObserver()
|
||||
restoreDraftIfNeeded()
|
||||
setUpUiStateObserver()
|
||||
binding!!.scrollToBottomButton.setOnClickListener {
|
||||
val layoutManager = binding?.conversationRecyclerView?.layoutManager ?: return@setOnClickListener
|
||||
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
|
||||
|
||||
if (layoutManager.isSmoothScrolling) {
|
||||
binding?.conversationRecyclerView?.scrollToPosition(0)
|
||||
} else {
|
||||
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
|
||||
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in
|
||||
// order to do the scroll, this can be very slow if there are a lot of messages so
|
||||
// instead we check the current position and if there are more than 10 items to scroll
|
||||
// we jump instantly to the 10th item and scroll from there (this should happen quick
|
||||
// enough to give a similar scroll effect without having to load everything)
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position > 10) {
|
||||
binding?.conversationRecyclerView?.scrollToPosition(10)
|
||||
}
|
||||
|
||||
binding?.conversationRecyclerView?.post {
|
||||
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
|
||||
|
||||
updateUnreadCountIndicator()
|
||||
setUpTypingObserver()
|
||||
setUpRecipientObserver()
|
||||
getLatestOpenGroupInfoIfNeeded()
|
||||
updateSubtitle()
|
||||
setUpBlockedBanner()
|
||||
setUpOutdatedClientBanner()
|
||||
binding!!.searchBottomBar.setEventListener(this)
|
||||
setUpSearchResultObserver()
|
||||
scrollToFirstUnreadMessageIfNeeded()
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
setUpMessageRequestsBar()
|
||||
viewModel.recipient?.let { recipient ->
|
||||
if (recipient.isOpenGroupRecipient && viewModel.openGroup == null) {
|
||||
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
|
||||
return finish()
|
||||
|
||||
val weakActivity = WeakReference(this)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
|
||||
|
||||
// Note: We are accessing the `adapter` property because we want it to be loaded on
|
||||
// the background thread to avoid blocking the UI thread and potentially hanging when
|
||||
// transitioning to the activity
|
||||
weakActivity.get()?.adapter ?: return@launch
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setUpRecyclerView()
|
||||
setUpTypingObserver()
|
||||
setUpRecipientObserver()
|
||||
getLatestOpenGroupInfoIfNeeded()
|
||||
setUpSearchResultObserver()
|
||||
scrollToFirstUnreadMessageIfNeeded()
|
||||
setUpOutdatedClientBanner()
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,7 +419,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
super.onResume()
|
||||
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId)
|
||||
val recipient = viewModel.recipient ?: return
|
||||
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
|
||||
}
|
||||
|
||||
contentResolver.registerContentObserver(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
@ -449,11 +486,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
handleRecyclerViewScrolled()
|
||||
}
|
||||
})
|
||||
|
||||
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
}
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpToolBar() {
|
||||
setSupportActionBar(binding?.toolbar)
|
||||
val binding = binding ?: return
|
||||
setSupportActionBar(binding.toolbar)
|
||||
val actionBar = supportActionBar ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
actionBar.title = ""
|
||||
@ -465,6 +507,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpInputBar() {
|
||||
binding!!.inputBar.isVisible = viewModel.openGroup == null || viewModel.openGroup?.canWrite == true
|
||||
binding!!.inputBar.delegate = this
|
||||
binding!!.inputBarRecordingView.delegate = this
|
||||
// GIF button
|
||||
@ -567,7 +610,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
|
||||
binding?.blockedBanner?.isVisible = recipient.isBlocked
|
||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
|
||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) }
|
||||
}
|
||||
|
||||
private fun setUpOutdatedClientBanner() {
|
||||
@ -647,6 +690,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// region Animation & Updating
|
||||
override fun onModified(recipient: Recipient) {
|
||||
viewModel.updateRecipient()
|
||||
|
||||
runOnUiThread {
|
||||
val threadRecipient = viewModel.recipient ?: return@runOnUiThread
|
||||
if (threadRecipient.isContactRecipient) {
|
||||
@ -654,7 +699,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
setUpMessageRequestsBar()
|
||||
invalidateOptionsMenu()
|
||||
updateSubtitle()
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
|
||||
maybeUpdateToolbar(threadRecipient)
|
||||
}
|
||||
}
|
||||
@ -663,6 +711,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
|
||||
}
|
||||
|
||||
private fun updateSendAfterApprovalText() {
|
||||
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
|
||||
}
|
||||
|
||||
private fun showOrHideInputIfNeeded() {
|
||||
val recipient = viewModel.recipient
|
||||
if (recipient != null && recipient.isClosedGroupRecipient) {
|
||||
@ -782,7 +834,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (!isShowingMentionCandidatesView) {
|
||||
additionalContentContainer.removeAllViews()
|
||||
val view = MentionCandidatesView(this)
|
||||
val view = MentionCandidatesView(this).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
|
||||
}
|
||||
view.glide = glide
|
||||
view.onCandidateSelected = { handleMentionSelected(it) }
|
||||
additionalContentContainer.addView(view)
|
||||
@ -899,15 +953,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val binding = binding ?: return
|
||||
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
|
||||
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
|
||||
binding.typingIndicatorViewContainer.isVisible
|
||||
showOrHidScrollToBottomButton()
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
|
||||
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
|
||||
updateUnreadCountIndicator()
|
||||
}
|
||||
|
||||
private fun showOrHidScrollToBottomButton(show: Boolean = true) {
|
||||
binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0
|
||||
private fun showScrollToBottomButtonIfApplicable() {
|
||||
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
|
||||
}
|
||||
|
||||
private fun updateUnreadCountIndicator() {
|
||||
@ -939,17 +992,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
override fun block(deleteThread: Boolean) {
|
||||
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(this)
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ ->
|
||||
viewModel.block()
|
||||
viewModel.block(this@ConversationActivityV2)
|
||||
if (deleteThread) {
|
||||
viewModel.deleteThread()
|
||||
finish()
|
||||
}
|
||||
}.show()
|
||||
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
button.setContentDescription("Confirm block")
|
||||
}
|
||||
|
||||
override fun copySessionID(sessionId: String) {
|
||||
@ -959,6 +1014,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun copyOpenGroupUrl(thread: Recipient) {
|
||||
if (!thread.isOpenGroupRecipient) { return }
|
||||
|
||||
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
|
||||
|
||||
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
|
||||
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
manager.setPrimaryClip(clip)
|
||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun showExpirationSettings(thread: Recipient) {
|
||||
if (thread.isClosedGroupRecipient) {
|
||||
val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
|
||||
@ -977,7 +1044,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.ConversationActivity_unblock) { _, _ ->
|
||||
viewModel.unblock()
|
||||
viewModel.unblock(this@ConversationActivityV2)
|
||||
}.show()
|
||||
}
|
||||
|
||||
@ -1038,33 +1105,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Log.e("Loki", "Failed to show emoji picker", e)
|
||||
return
|
||||
}
|
||||
|
||||
val binding = binding ?: return
|
||||
|
||||
emojiPickerVisible = true
|
||||
ViewUtil.hideKeyboard(this, visibleMessageView)
|
||||
binding?.reactionsShade?.isVisible = true
|
||||
showOrHidScrollToBottomButton(false)
|
||||
binding?.conversationRecyclerView?.suppressLayout(true)
|
||||
binding.reactionsShade.isVisible = true
|
||||
binding.scrollToBottomButton.isVisible = false
|
||||
binding.conversationRecyclerView.suppressLayout(true)
|
||||
reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message))
|
||||
reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener {
|
||||
override fun startHide() {
|
||||
binding?.reactionsShade?.let {
|
||||
emojiPickerVisible = false
|
||||
binding.reactionsShade.let {
|
||||
ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
|
||||
}
|
||||
showOrHidScrollToBottomButton(true)
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
binding?.conversationRecyclerView?.suppressLayout(false)
|
||||
binding.conversationRecyclerView.suppressLayout(false)
|
||||
|
||||
WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2);
|
||||
WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2);
|
||||
}
|
||||
|
||||
})
|
||||
val contentBounds = Rect()
|
||||
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
|
||||
val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) }
|
||||
val selectedConversationModel = SelectedConversationModel(
|
||||
messageContentBitmap,
|
||||
contentBounds.left.toFloat(),
|
||||
contentBounds.top.toFloat(),
|
||||
topLeft[0].toFloat(),
|
||||
topLeft[1].toFloat(),
|
||||
visibleMessageView.messageContentView.width,
|
||||
message.isOutgoing,
|
||||
visibleMessageView.messageContentView
|
||||
@ -1090,7 +1161,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
// Create the message
|
||||
val recipient = viewModel.recipient ?: return
|
||||
val reactionMessage = VisibleMessage()
|
||||
val emojiTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val emojiTimestamp = SnodeAPI.nowWithOffset
|
||||
reactionMessage.sentTimestamp = emojiTimestamp
|
||||
val author = textSecurePreferences.getLocalNumber()!!
|
||||
// Put the message in the database
|
||||
@ -1123,7 +1194,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
val message = VisibleMessage()
|
||||
val emojiTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val emojiTimestamp = SnodeAPI.nowWithOffset
|
||||
message.sentTimestamp = emojiTimestamp
|
||||
val author = textSecurePreferences.getLocalNumber()!!
|
||||
reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false)
|
||||
@ -1314,7 +1385,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
override fun sendMessage() {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (recipient.isContactRecipient && recipient.isBlocked) {
|
||||
BlockedDialog(recipient).show(supportFragmentManager, "Blocked Dialog")
|
||||
BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog")
|
||||
return
|
||||
}
|
||||
val binding = binding ?: return
|
||||
@ -1352,7 +1423,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
// Create the message
|
||||
val message = VisibleMessage()
|
||||
message.sentTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
message.sentTimestamp = SnodeAPI.nowWithOffset
|
||||
message.text = text
|
||||
val expiresInMillis = (viewModel.expirationConfiguration?.durationSeconds ?: 0) * 1000L
|
||||
val expireStartedAt = if (viewModel.expirationConfiguration?.expirationType == ExpirationType.DELETE_AFTER_SEND) {
|
||||
@ -1379,9 +1450,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val recipient = viewModel.recipient ?: return
|
||||
processMessageRequestApproval()
|
||||
// Create the message
|
||||
val sentTimestampMs = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val message = VisibleMessage()
|
||||
message.sentTimestamp = sentTimestampMs
|
||||
message.sentTimestamp = SnodeAPI.nowWithOffset
|
||||
message.text = body
|
||||
val quote = quotedMessage?.let {
|
||||
val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf()
|
||||
@ -1425,16 +1495,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning()
|
||||
if (!hasSeenGIFMetaDataWarning) {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.setTitle("Search GIFs?")
|
||||
builder.setMessage("You will not have full metadata protection when sending GIFs.")
|
||||
builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int ->
|
||||
builder.setTitle(R.string.giphy_permission_title)
|
||||
builder.setMessage(R.string.giphy_permission_message)
|
||||
builder.setPositiveButton(R.string.continue_2) { dialog: DialogInterface, _: Int ->
|
||||
textSecurePreferences.setHasSeenGIFMetaDataWarning()
|
||||
AttachmentManager.selectGif(this, PICK_GIF)
|
||||
dialog.dismiss()
|
||||
}
|
||||
builder.setNegativeButton(
|
||||
"Cancel"
|
||||
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||
builder.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
builder.create().show()
|
||||
} else {
|
||||
AttachmentManager.selectGif(this, PICK_GIF)
|
||||
@ -1540,7 +1610,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
showVoiceMessageUI()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
audioRecorder.startRecording()
|
||||
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each
|
||||
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
|
||||
} else {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.RECORD_AUDIO)
|
||||
@ -1714,6 +1784,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
endActionMode()
|
||||
}
|
||||
|
||||
override fun resyncMessage(messages: Set<MessageRecord>) {
|
||||
messages.iterator().forEach { messageRecord ->
|
||||
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true)
|
||||
}
|
||||
endActionMode()
|
||||
}
|
||||
|
||||
override fun resendMessage(messages: Set<MessageRecord>) {
|
||||
messages.iterator().forEach { messageRecord ->
|
||||
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey)
|
||||
@ -1782,7 +1859,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
private fun sendMediaSavedNotification() {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (recipient.isGroupRecipient) { return }
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val timestamp = SnodeAPI.nowWithOffset
|
||||
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
|
||||
val message = DataExtractionNotification(kind)
|
||||
MessageSender.send(message, recipient.address)
|
||||
@ -1874,6 +1951,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val selectedItems = setOf(message)
|
||||
when (action) {
|
||||
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
|
||||
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
|
||||
|
@ -39,10 +39,10 @@ class ConversationAdapter(
|
||||
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
||||
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
||||
private val onDeselect: (MessageRecord, Int) -> Unit,
|
||||
private val onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||
private val glide: GlideRequests,
|
||||
lifecycleCoroutineScope: LifecycleCoroutineScope
|
||||
)
|
||||
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
||||
) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
||||
private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() }
|
||||
private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() }
|
||||
var selectedItems = mutableSetOf<MessageRecord>()
|
||||
@ -120,7 +120,18 @@ class ConversationAdapter(
|
||||
}
|
||||
val contact = contactCache[senderIdHash]
|
||||
|
||||
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId, visibleMessageViewDelegate)
|
||||
visibleMessageView.bind(
|
||||
message,
|
||||
messageBefore,
|
||||
getMessageAfter(position, cursor),
|
||||
glide,
|
||||
searchQuery,
|
||||
contact,
|
||||
senderId,
|
||||
visibleMessageViewDelegate,
|
||||
onAttachmentNeedsDownload
|
||||
)
|
||||
|
||||
if (!message.isDeleted) {
|
||||
visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) }
|
||||
visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
|
||||
|
@ -81,6 +81,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
private View dropdownAnchor;
|
||||
private LinearLayout conversationItem;
|
||||
private View conversationBubble;
|
||||
private TextView conversationTimestamp;
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private EmojiImageView[] emojiViews;
|
||||
@ -116,6 +118,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
||||
conversationItem = findViewById(R.id.conversation_item);
|
||||
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
|
||||
@ -165,10 +169,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
|
||||
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
||||
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
||||
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
|
||||
|
||||
updateConversationTimestamp(messageRecord);
|
||||
@ -190,12 +192,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
|
||||
private void updateConversationTimestamp(MessageRecord message) {
|
||||
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationItem.removeAllViewsInLayout();
|
||||
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
|
||||
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
|
||||
conversationItem.requestLayout();
|
||||
if (message.isOutgoing()) conversationBubble.bringToFront();
|
||||
else conversationTimestamp.bringToFront();
|
||||
}
|
||||
|
||||
private void showAfterLayout(@NonNull MessageRecord messageRecord,
|
||||
@ -203,10 +201,11 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
boolean isMessageOnLeft) {
|
||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
|
||||
|
||||
float itemX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
float endX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
|
||||
conversationItem.setX(itemX);
|
||||
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
float endY = selectedConversationModel.getBubbleY() - statusBarHeight;
|
||||
conversationItem.setX(endX);
|
||||
conversationItem.setY(endY);
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
||||
@ -214,8 +213,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
int overlayHeight = getHeight();
|
||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
||||
|
||||
float endX = itemX;
|
||||
float endY = conversationItem.getY();
|
||||
float endApparentTop = endY;
|
||||
float endScale = 1f;
|
||||
|
||||
@ -265,9 +262,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
||||
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
|
||||
endApparentTop = endY;
|
||||
@ -354,11 +349,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
|
||||
conversationBubble.animate()
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
|
||||
conversationItem.animate()
|
||||
.x(endX)
|
||||
.y(endY)
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
}
|
||||
|
||||
@ -660,10 +658,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
// Select message
|
||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT)));
|
||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_select)));
|
||||
// Reply
|
||||
if (!message.isPending() && !message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
|
||||
boolean canWrite = openGroup == null || openGroup.getCanWrite();
|
||||
if (canWrite && !message.isPending() && !message.isFailed()) {
|
||||
items.add(
|
||||
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_reply_message))
|
||||
);
|
||||
}
|
||||
// Copy message text
|
||||
if (!containsControlMessage && hasText) {
|
||||
@ -671,11 +674,17 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
// Copy Session ID
|
||||
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)));
|
||||
items.add(new ActionItem(
|
||||
R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID))
|
||||
);
|
||||
}
|
||||
// Delete message
|
||||
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), () -> handleActionItemClicked(Action.DELETE)));
|
||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete),
|
||||
() -> handleActionItemClicked(Action.DELETE),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_delete_message)
|
||||
)
|
||||
);
|
||||
}
|
||||
// Ban user
|
||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||
@ -693,9 +702,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
if (message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
||||
}
|
||||
// Resync
|
||||
if (message.isSyncFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
|
||||
}
|
||||
// Save media
|
||||
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
|
||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_save_attachment))
|
||||
);
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(View.VISIBLE);
|
||||
@ -876,6 +891,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
public enum Action {
|
||||
REPLY,
|
||||
RESEND,
|
||||
RESYNC,
|
||||
DOWNLOAD,
|
||||
COPY_MESSAGE,
|
||||
COPY_SESSION_ID,
|
||||
|
@ -1,11 +1,15 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@ -21,6 +25,7 @@ import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import java.util.UUID
|
||||
|
||||
class ConversationViewModel(
|
||||
@ -30,17 +35,26 @@ class ConversationViewModel(
|
||||
private val storage: Storage
|
||||
) : ViewModel() {
|
||||
|
||||
val showSendAfterApprovalText: Boolean
|
||||
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||
val uiState: StateFlow<ConversationUiState> = _uiState
|
||||
|
||||
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
|
||||
repository.maybeGetRecipientForThreadId(threadId)
|
||||
}
|
||||
val expirationConfiguration: ExpirationConfiguration?
|
||||
get() = storage.getExpirationConfiguration(threadId)
|
||||
|
||||
val recipient: Recipient?
|
||||
get() = repository.maybeGetRecipientForThreadId(threadId)
|
||||
get() = _recipient.value
|
||||
|
||||
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
||||
storage.getOpenGroup(threadId)
|
||||
}
|
||||
val openGroup: OpenGroup?
|
||||
get() = storage.getOpenGroup(threadId)
|
||||
get() = _openGroup.value
|
||||
|
||||
val serverCapabilities: List<String>
|
||||
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
||||
@ -52,28 +66,46 @@ class ConversationViewModel(
|
||||
}
|
||||
|
||||
fun saveDraft(text: String) {
|
||||
repository.saveDraft(threadId, text)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
repository.saveDraft(threadId, text)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDraft(): String? {
|
||||
return repository.getDraft(threadId)
|
||||
val draft: String? = repository.getDraft(threadId)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.clearDrafts(threadId)
|
||||
}
|
||||
|
||||
return draft
|
||||
}
|
||||
|
||||
fun inviteContacts(contacts: List<Recipient>) {
|
||||
repository.inviteContacts(threadId, contacts)
|
||||
}
|
||||
|
||||
fun block() {
|
||||
fun block(context: Context) {
|
||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
||||
if (recipient.isContactRecipient) {
|
||||
repository.setBlocked(recipient, true)
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock() {
|
||||
fun unblock(context: Context) {
|
||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
|
||||
if (recipient.isContactRecipient) {
|
||||
repository.setBlocked(recipient, false)
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +196,10 @@ class ConversationViewModel(
|
||||
return repository.hasReceived(threadId)
|
||||
}
|
||||
|
||||
fun updateRecipient() {
|
||||
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
|
||||
}
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||
@ -189,3 +225,19 @@ data class ConversationUiState(
|
||||
val uiMessages: List<UiMessage> = emptyList(),
|
||||
val isMessageRequestAccepted: Boolean? = null
|
||||
)
|
||||
|
||||
data class RetrieveOnce<T>(val retrieval: () -> T?) {
|
||||
private var triedToRetrieve: Boolean = false
|
||||
private var _value: T? = null
|
||||
|
||||
val value: T?
|
||||
get() {
|
||||
if (triedToRetrieve) { return _value }
|
||||
|
||||
triedToRetrieve = true
|
||||
_value = retrieval()
|
||||
return _value
|
||||
}
|
||||
|
||||
fun updateTo(value: T?) { _value = value }
|
||||
}
|
||||
|
@ -69,7 +69,6 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
@ -88,7 +89,7 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
|
||||
binding.expiresContainer.visibility = View.GONE
|
||||
} else {
|
||||
binding.expiresContainer.visibility = View.VISIBLE
|
||||
val elapsed = System.currentTimeMillis() - messageRecord!!.expireStarted
|
||||
val elapsed = SnodeAPI.nowWithOffset - messageRecord!!.expireStarted
|
||||
val remaining = messageRecord!!.expiresIn - elapsed
|
||||
|
||||
val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1))
|
||||
|
@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(),
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
|
@ -38,14 +38,10 @@ public final class WindowUtil {
|
||||
}
|
||||
|
||||
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setNavigationBarColor(color);
|
||||
}
|
||||
|
||||
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
|
||||
|
||||
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
|
||||
@ -53,20 +49,14 @@ public final class WindowUtil {
|
||||
}
|
||||
|
||||
public static void clearLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
public static void setLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setStatusBarColor(color);
|
||||
}
|
||||
|
||||
|
@ -8,53 +8,39 @@ import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.AlbumThumbnailViewBinding
|
||||
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.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.conversation.v2.utilities.ThumbnailView
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
||||
|
||||
class AlbumThumbnailView : FrameLayout {
|
||||
|
||||
private lateinit var binding: AlbumThumbnailViewBinding
|
||||
|
||||
class AlbumThumbnailView : RelativeLayout {
|
||||
companion object {
|
||||
const val MAX_ALBUM_DISPLAY_SIZE = 3
|
||||
}
|
||||
|
||||
private val binding: AlbumThumbnailViewBinding by lazy { AlbumThumbnailViewBinding.bind(this) }
|
||||
|
||||
// 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()
|
||||
}
|
||||
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 cornerMask by lazy { CornerMask(this) }
|
||||
private var slides: List<Slide> = listOf()
|
||||
private var slideSize: Int = 0
|
||||
|
||||
private fun initialize() {
|
||||
binding = AlbumThumbnailViewBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas?) {
|
||||
super.dispatchDraw(canvas)
|
||||
cornerMask.mask(canvas)
|
||||
@ -63,26 +49,25 @@ class AlbumThumbnailView : FrameLayout {
|
||||
|
||||
// region Interaction
|
||||
|
||||
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient) {
|
||||
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) {
|
||||
val rawXInt = event.rawX.toInt()
|
||||
val rawYInt = event.rawY.toInt()
|
||||
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
|
||||
val testRect = Rect()
|
||||
// test each album child
|
||||
binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child ->
|
||||
binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed forEach@{ index, child ->
|
||||
child.getGlobalVisibleRect(testRect)
|
||||
if (testRect.contains(eventRect)) {
|
||||
// hit intersects with this particular child
|
||||
val slide = slides.getOrNull(index) ?: return
|
||||
val slide = slides.getOrNull(index) ?: return@forEach
|
||||
// only open to downloaded images
|
||||
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
||||
// restart download here
|
||||
// Restart download here (on IO thread)
|
||||
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||
val attachmentId = attachment.attachmentId.rowId
|
||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mms.getId()))
|
||||
onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId())
|
||||
}
|
||||
}
|
||||
if (slide.isInProgress) return
|
||||
if (slide.isInProgress) return@forEach
|
||||
|
||||
ActivityDispatcher.get(context)?.dispatchIntent { context ->
|
||||
MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient)
|
||||
@ -133,7 +118,7 @@ class AlbumThumbnailView : FrameLayout {
|
||||
else -> R.layout.album_thumbnail_3 // three stacked with additional text
|
||||
}
|
||||
|
||||
fun getThumbnailView(position: Int): KThumbnailView = when (position) {
|
||||
fun getThumbnailView(position: Int): ThumbnailView = when (position) {
|
||||
0 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1)
|
||||
1 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2)
|
||||
2 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3)
|
||||
|
@ -23,7 +23,7 @@ class LinkPreviewDraftView : LinearLayout {
|
||||
// Start out with the loader showing and the content view hidden
|
||||
binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
binding.linkPreviewDraftContainer.isVisible = false
|
||||
binding.thumbnailImageView.clipToOutline = true
|
||||
binding.thumbnailImageView.root.clipToOutline = true
|
||||
binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() }
|
||||
}
|
||||
|
||||
@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout {
|
||||
// Hide the loader and show the content view
|
||||
binding.linkPreviewDraftContainer.isVisible = true
|
||||
binding.linkPreviewDraftLoader.isVisible = false
|
||||
binding.thumbnailImageView.radius = toPx(4, resources)
|
||||
binding.thumbnailImageView.root.radius = toPx(4, resources)
|
||||
if (linkPreview.getThumbnail().isPresent) {
|
||||
// This internally fetches the thumbnail
|
||||
binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false)
|
||||
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null)
|
||||
}
|
||||
binding.linkPreviewDraftTitleTextView.text = linkPreview.title
|
||||
}
|
||||
|
@ -1,118 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class TypingIndicatorView extends LinearLayout {
|
||||
private boolean isActive;
|
||||
private long startTime;
|
||||
|
||||
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 View dot1;
|
||||
private View dot2;
|
||||
private View dot3;
|
||||
|
||||
public TypingIndicatorView(Context context) {
|
||||
super(context);
|
||||
initialize(null);
|
||||
}
|
||||
|
||||
public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize(attrs);
|
||||
}
|
||||
|
||||
private void initialize(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.view_typing_indicator, this);
|
||||
|
||||
setWillNotDraw(false);
|
||||
|
||||
dot1 = findViewById(R.id.typing_dot1);
|
||||
dot2 = findViewById(R.id.typing_dot2);
|
||||
dot3 = findViewById(R.id.typing_dot3);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0);
|
||||
int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE);
|
||||
typedArray.recycle();
|
||||
|
||||
dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
|
||||
dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
|
||||
dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (!isActive) {
|
||||
super.onDraw(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION;
|
||||
|
||||
render(dot1, timeInCycle, 0);
|
||||
render(dot2, timeInCycle, 150);
|
||||
render(dot3, timeInCycle, 300);
|
||||
|
||||
super.onDraw(canvas);
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void render(View dot, long timeInCycle, long start) {
|
||||
long end = start + DOT_DURATION;
|
||||
long peak = start + (DOT_DURATION / 2);
|
||||
|
||||
if (timeInCycle < start || timeInCycle > end) {
|
||||
renderDefault(dot);
|
||||
} else if (timeInCycle < peak) {
|
||||
renderFadeIn(dot, timeInCycle, start);
|
||||
} else {
|
||||
renderFadeOut(dot, timeInCycle, peak);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDefault(View dot) {
|
||||
dot.setAlpha(MIN_ALPHA);
|
||||
dot.setScaleX(MIN_SCALE);
|
||||
dot.setScaleY(MIN_SCALE);
|
||||
}
|
||||
|
||||
private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) {
|
||||
float percent = (float) (timeInCycle - fadeInStart) / 300;
|
||||
dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent);
|
||||
dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent);
|
||||
dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent);
|
||||
}
|
||||
|
||||
private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) {
|
||||
float percent = (float) (timeInCycle - fadeOutStart) / 300;
|
||||
dot.setAlpha(1 - (1 - MIN_ALPHA) * percent);
|
||||
dot.setScaleX(1 - (1 - MIN_SCALE) * percent);
|
||||
dot.setScaleY(1 - (1 - MIN_SCALE) * percent);
|
||||
}
|
||||
|
||||
public void startAnimation() {
|
||||
isActive = true;
|
||||
startTime = System.currentTimeMillis();
|
||||
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void stopAnimation() {
|
||||
isActive = false;
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewTypingIndicatorBinding
|
||||
|
||||
class TypingIndicatorView : LinearLayout {
|
||||
companion object {
|
||||
private const val CYCLE_DURATION: Long = 1500
|
||||
private const val DOT_DURATION: Long = 600
|
||||
private const val MIN_ALPHA = 0.4f
|
||||
private const val MIN_SCALE = 0.75f
|
||||
}
|
||||
|
||||
private val binding: ViewTypingIndicatorBinding by lazy {
|
||||
val binding = ViewTypingIndicatorBinding.bind(this)
|
||||
|
||||
if (tint != -1) {
|
||||
binding.typingDot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
|
||||
binding.typingDot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
|
||||
binding.typingDot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
|
||||
}
|
||||
|
||||
return@lazy binding
|
||||
}
|
||||
|
||||
private var isActive = false
|
||||
private var startTime: Long = 0
|
||||
private var tint: Int = -1
|
||||
|
||||
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 fun initialize(attrs: AttributeSet?) {
|
||||
setWillNotDraw(false)
|
||||
|
||||
if (attrs != null) {
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0)
|
||||
this.tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE)
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (!isActive) {
|
||||
super.onDraw(canvas)
|
||||
return
|
||||
}
|
||||
val timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION
|
||||
render(binding.typingDot1, timeInCycle, 0)
|
||||
render(binding.typingDot2, timeInCycle, 150)
|
||||
render(binding.typingDot3, timeInCycle, 300)
|
||||
super.onDraw(canvas)
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
private fun render(dot: View?, timeInCycle: Long, start: Long) {
|
||||
val end = start + DOT_DURATION
|
||||
val peak = start + DOT_DURATION / 2
|
||||
if (timeInCycle < start || timeInCycle > end) {
|
||||
renderDefault(dot)
|
||||
} else if (timeInCycle < peak) {
|
||||
renderFadeIn(dot, timeInCycle, start)
|
||||
} else {
|
||||
renderFadeOut(dot, timeInCycle, peak)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderDefault(dot: View?) {
|
||||
dot!!.alpha = MIN_ALPHA
|
||||
dot.scaleX = MIN_SCALE
|
||||
dot.scaleY = MIN_SCALE
|
||||
}
|
||||
|
||||
private fun renderFadeIn(dot: View?, timeInCycle: Long, fadeInStart: Long) {
|
||||
val percent = (timeInCycle - fadeInStart).toFloat() / 300
|
||||
dot!!.alpha = MIN_ALPHA + (1 - MIN_ALPHA) * percent
|
||||
dot.scaleX = MIN_SCALE + (1 - MIN_SCALE) * percent
|
||||
dot.scaleY = MIN_SCALE + (1 - MIN_SCALE) * percent
|
||||
}
|
||||
|
||||
private fun renderFadeOut(dot: View?, timeInCycle: Long, fadeOutStart: Long) {
|
||||
val percent = (timeInCycle - fadeOutStart).toFloat() / 300
|
||||
dot!!.alpha = 1 - (1 - MIN_ALPHA) * percent
|
||||
dot.scaleX = 1 - (1 - MIN_SCALE) * percent
|
||||
dot.scaleY = 1 - (1 - MIN_SCALE) * percent
|
||||
}
|
||||
|
||||
fun startAnimation() {
|
||||
isActive = true
|
||||
startTime = System.currentTimeMillis()
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun stopAnimation() {
|
||||
isActive = false
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ class TypingIndicatorViewContainer : LinearLayout {
|
||||
}
|
||||
|
||||
fun setTypists(typists: List<Recipient>) {
|
||||
if (typists.isEmpty()) { binding.typingIndicator.stopAnimation(); return }
|
||||
binding.typingIndicator.startAnimation()
|
||||
if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return }
|
||||
binding.typingIndicator.root.startAnimation()
|
||||
}
|
||||
}
|
@ -1,20 +1,25 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||
|
||||
import android.content.Context
|
||||
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.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.DialogBlockedBinding
|
||||
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.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
|
||||
/** Shown upon sending a message to a user that's blocked. */
|
||||
class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
|
||||
class BlockedDialog(private val recipient: Recipient, private val context: Context) : BaseDialog() {
|
||||
|
||||
override fun setContentView(builder: AlertDialog.Builder) {
|
||||
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
|
||||
@ -37,5 +42,10 @@ class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
|
||||
private fun unblock() {
|
||||
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false)
|
||||
dismiss()
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
@ -57,9 +57,9 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
val attachmentButtonsContainerHeight: Int
|
||||
get() = binding.attachmentsButtonContainer.height
|
||||
|
||||
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) }
|
||||
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} }
|
||||
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} }
|
||||
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} }
|
||||
|
||||
// region Lifecycle
|
||||
constructor(context: Context) : super(context) { initialize() }
|
||||
|
@ -7,6 +7,7 @@ import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ListView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
@ -41,7 +42,9 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr
|
||||
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(context)
|
||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_contact)
|
||||
}
|
||||
val mentionCandidate = getItem(position)
|
||||
cell.glide = glide
|
||||
cell.candidate = mentionCandidate
|
||||
|
@ -70,6 +70,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
|
||||
// Resend
|
||||
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
|
||||
// Resync
|
||||
menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed)
|
||||
// Save media
|
||||
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
|
||||
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
|
||||
@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
|
||||
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
|
||||
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
|
||||
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
|
||||
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
|
||||
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
|
||||
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
|
||||
@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
|
||||
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
||||
fun copyMessages(messages: Set<MessageRecord>)
|
||||
fun copySessionID(messages: Set<MessageRecord>)
|
||||
fun resyncMessage(messages: Set<MessageRecord>)
|
||||
fun resendMessage(messages: Set<MessageRecord>)
|
||||
fun showMessageDetail(messages: Set<MessageRecord>)
|
||||
fun saveAttachment(messages: Set<MessageRecord>)
|
||||
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.menus
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.AsyncTask
|
||||
@ -57,6 +58,10 @@ object ConversationMenuHelper {
|
||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
|
||||
inflater.inflate(R.menu.menu_conversation_expiration, menu)
|
||||
}
|
||||
// One-on-one chat menu allows copying the session id
|
||||
if (thread.isContactRecipient) {
|
||||
inflater.inflate(R.menu.menu_conversation_copy_session_id, menu)
|
||||
}
|
||||
// One-on-one chat menu (options that should only be present for one-on-one chats)
|
||||
if (thread.isContactRecipient) {
|
||||
if (thread.isBlocked) {
|
||||
@ -132,6 +137,7 @@ object ConversationMenuHelper {
|
||||
R.id.menu_block -> { block(context, thread, deleteThread = false) }
|
||||
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
|
||||
R.id.menu_copy_session_id -> { copySessionID(context, thread) }
|
||||
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(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) }
|
||||
@ -158,7 +164,7 @@ object ConversationMenuHelper {
|
||||
private fun call(context: Context, thread: Recipient) {
|
||||
|
||||
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
|
||||
AlertDialog.Builder(context)
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setTitle(R.string.ConversationActivity_call_title)
|
||||
.setMessage(R.string.ConversationActivity_call_prompt)
|
||||
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
|
||||
@ -167,7 +173,10 @@ object ConversationMenuHelper {
|
||||
}
|
||||
.setNeutralButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
}.show()
|
||||
}.create()
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.contentDescription = context.getString(R.string.AccessibilityId_settings)
|
||||
dialog.getButton(DialogInterface.BUTTON_NEGATIVE)?.contentDescription = context.getString(R.string.AccessibilityId_cancel_button)
|
||||
dialog.show()
|
||||
return
|
||||
}
|
||||
|
||||
@ -248,6 +257,12 @@ object ConversationMenuHelper {
|
||||
listener.copySessionID(thread.address.toString())
|
||||
}
|
||||
|
||||
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
|
||||
if (!thread.isOpenGroupRecipient) { return }
|
||||
val listener = context as? ConversationMenuListener ?: return
|
||||
listener.copyOpenGroupUrl(thread)
|
||||
}
|
||||
|
||||
private fun editClosedGroup(context: Context, thread: Recipient) {
|
||||
if (!thread.isClosedGroupRecipient) { return }
|
||||
val intent = Intent(context, EditClosedGroupActivity::class.java)
|
||||
@ -322,6 +337,7 @@ object ConversationMenuHelper {
|
||||
fun block(deleteThread: Boolean = false)
|
||||
fun unblock()
|
||||
fun copySessionID(sessionId: String)
|
||||
fun copyOpenGroupUrl(thread: Recipient)
|
||||
fun showExpirationSettings(thread: Recipient)
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,7 @@ class ControlMessageView : LinearLayout {
|
||||
binding.dateBreakTextView.showDateBreak(message, previous)
|
||||
binding.iconImageView.visibility = View.GONE
|
||||
var messageBody: CharSequence = message.getDisplayBody(context)
|
||||
binding.root.contentDescription= null
|
||||
when {
|
||||
message.isExpirationTimerUpdate -> {
|
||||
binding.iconImageView.setImageDrawable(
|
||||
@ -46,6 +47,7 @@ class ControlMessageView : LinearLayout {
|
||||
}
|
||||
message.isMessageRequestResponse -> {
|
||||
messageBody = context.getString(R.string.message_requests_accepted)
|
||||
binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message)
|
||||
}
|
||||
message.isCallLog -> {
|
||||
val drawable = when {
|
||||
|
@ -1,346 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.Group;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.flexbox.FlexboxLayout;
|
||||
import com.google.android.flexbox.JustifyContent;
|
||||
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.util.NumberUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener {
|
||||
|
||||
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
|
||||
private final int OUTER_MARGIN = ViewUtil.dpToPx(2);
|
||||
private static final int DEFAULT_THRESHOLD = 5;
|
||||
|
||||
private List<ReactionRecord> records;
|
||||
private long messageId;
|
||||
private ViewGroup container;
|
||||
private Group showLess;
|
||||
private VisibleMessageViewDelegate delegate;
|
||||
private Handler gestureHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable pressCallback;
|
||||
private Runnable longPressCallback;
|
||||
private long onDownTimestamp = 0;
|
||||
private static long longPressDurationThreshold = 250;
|
||||
private static long maxDoubleTapInterval = 200;
|
||||
private boolean extended = false;
|
||||
|
||||
public EmojiReactionsView(Context context) {
|
||||
super(context);
|
||||
init(null);
|
||||
}
|
||||
|
||||
public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(attrs);
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.view_emoji_reactions, this);
|
||||
|
||||
this.container = findViewById(R.id.layout_emoji_container);
|
||||
this.showLess = findViewById(R.id.group_show_less);
|
||||
|
||||
records = new ArrayList<>();
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0);
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
this.records.clear();
|
||||
container.removeAllViews();
|
||||
}
|
||||
|
||||
public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) {
|
||||
this.delegate = delegate;
|
||||
if (records.equals(this.records)) {
|
||||
return;
|
||||
}
|
||||
|
||||
FlexboxLayout containerLayout = (FlexboxLayout) this.container;
|
||||
containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START);
|
||||
this.records.clear();
|
||||
this.records.addAll(records);
|
||||
if (this.messageId != messageId) {
|
||||
extended = false;
|
||||
}
|
||||
this.messageId = messageId;
|
||||
|
||||
displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
if (v.getTag() == null) return false;
|
||||
|
||||
Reaction reaction = (Reaction) v.getTag();
|
||||
int action = event.getAction();
|
||||
if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms));
|
||||
else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback();
|
||||
else if (action == MotionEvent.ACTION_UP) onUp(reaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void displayReactions(int threshold) {
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold);
|
||||
|
||||
container.removeAllViews();
|
||||
LinearLayout overflowContainer = new LinearLayout(getContext());
|
||||
overflowContainer.setOrientation(LinearLayout.HORIZONTAL);
|
||||
int innerPadding = ViewUtil.dpToPx(4);
|
||||
overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding);
|
||||
|
||||
int pixelSize = ViewUtil.dpToPx(1);
|
||||
|
||||
for (Reaction reaction : reactions) {
|
||||
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
|
||||
if (overflowContainer.getParent() == null) {
|
||||
container.addView(overflowContainer);
|
||||
MarginLayoutParams overflowParams = (MarginLayoutParams) overflowContainer.getLayoutParams();
|
||||
overflowParams.height = ViewUtil.dpToPx(26);
|
||||
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
|
||||
overflowContainer.setLayoutParams(overflowParams);
|
||||
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_background));
|
||||
}
|
||||
View pill = buildPill(getContext(), this, reaction, true);
|
||||
pill.setOnClickListener(v -> {
|
||||
extended = true;
|
||||
displayReactions(Integer.MAX_VALUE);
|
||||
});
|
||||
pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE);
|
||||
pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE);
|
||||
overflowContainer.addView(pill);
|
||||
} else {
|
||||
View pill = buildPill(getContext(), this, reaction, false);
|
||||
pill.setTag(reaction);
|
||||
pill.setOnTouchListener(this);
|
||||
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
|
||||
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
|
||||
pill.setLayoutParams(params);
|
||||
container.addView(pill);
|
||||
}
|
||||
}
|
||||
|
||||
int overflowChildren = overflowContainer.getChildCount();
|
||||
int negativeMargin = ViewUtil.dpToPx(-8);
|
||||
for (int i = 0; i < overflowChildren; i++) {
|
||||
View child = overflowContainer.getChildAt(i);
|
||||
MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams();
|
||||
if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) {
|
||||
// if first and there is more than one child, or we are not the last child then set negative right margin
|
||||
childParams.setMargins(0,0, negativeMargin, 0);
|
||||
child.setLayoutParams(childParams);
|
||||
}
|
||||
}
|
||||
|
||||
if (threshold == Integer.MAX_VALUE) {
|
||||
showLess.setVisibility(VISIBLE);
|
||||
for (int id : showLess.getReferencedIds()) {
|
||||
findViewById(id).setOnClickListener(view -> {
|
||||
extended = false;
|
||||
displayReactions(DEFAULT_THRESHOLD);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showLess.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onReactionClicked(Reaction reaction) {
|
||||
if (reaction.messageId != 0) {
|
||||
MessageId messageId = new MessageId(reaction.messageId, reaction.isMms);
|
||||
delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender);
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) {
|
||||
Map<String, Reaction> counters = new LinkedHashMap<>();
|
||||
|
||||
for (ReactionRecord record : records) {
|
||||
String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji());
|
||||
Reaction info = counters.get(baseEmoji);
|
||||
|
||||
if (info == null) {
|
||||
info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
|
||||
} else {
|
||||
info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
|
||||
}
|
||||
|
||||
counters.put(baseEmoji, info);
|
||||
}
|
||||
|
||||
List<Reaction> reactions = new ArrayList<>(counters.values());
|
||||
|
||||
Collections.sort(reactions, Collections.reverseOrder());
|
||||
|
||||
if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) {
|
||||
List<Reaction> shortened = new ArrayList<>(threshold + 2);
|
||||
shortened.addAll(reactions.subList(0, threshold + 2));
|
||||
return shortened;
|
||||
} else {
|
||||
return reactions;
|
||||
}
|
||||
}
|
||||
|
||||
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) {
|
||||
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
|
||||
EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji);
|
||||
TextView countView = root.findViewById(R.id.reactions_pill_count);
|
||||
View spacer = root.findViewById(R.id.reactions_pill_spacer);
|
||||
|
||||
if (isCompact) {
|
||||
root.setPaddingRelative(1,1,1,1);
|
||||
ViewGroup.LayoutParams layoutParams = root.getLayoutParams();
|
||||
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
root.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
if (reaction.emoji != null) {
|
||||
emojiView.setImageEmoji(reaction.emoji);
|
||||
|
||||
if (reaction.count >= 1) {
|
||||
countView.setText(NumberUtil.getFormattedNumber(reaction.count));
|
||||
} else {
|
||||
countView.setVisibility(GONE);
|
||||
spacer.setVisibility(GONE);
|
||||
}
|
||||
} else {
|
||||
emojiView.setVisibility(GONE);
|
||||
spacer.setVisibility(GONE);
|
||||
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
|
||||
}
|
||||
|
||||
if (reaction.userWasSender && !isCompact) {
|
||||
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected));
|
||||
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor));
|
||||
} else {
|
||||
if (!isCompact) {
|
||||
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background));
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private void onDown(MessageId messageId) {
|
||||
removeLongPressCallback();
|
||||
Runnable newLongPressCallback = () -> {
|
||||
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
|
||||
if (delegate != null) {
|
||||
delegate.onReactionLongClicked(messageId);
|
||||
}
|
||||
};
|
||||
this.longPressCallback = newLongPressCallback;
|
||||
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold);
|
||||
onDownTimestamp = new Date().getTime();
|
||||
}
|
||||
|
||||
private void removeLongPressCallback() {
|
||||
if (longPressCallback != null) {
|
||||
gestureHandler.removeCallbacks(longPressCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void onUp(Reaction reaction) {
|
||||
if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) {
|
||||
removeLongPressCallback();
|
||||
if (pressCallback != null) {
|
||||
gestureHandler.removeCallbacks(pressCallback);
|
||||
this.pressCallback = null;
|
||||
} else {
|
||||
Runnable newPressCallback = () -> {
|
||||
onReactionClicked(reaction);
|
||||
pressCallback = null;
|
||||
};
|
||||
this.pressCallback = newPressCallback;
|
||||
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Reaction implements Comparable<Reaction> {
|
||||
private final long messageId;
|
||||
private final boolean isMms;
|
||||
private String emoji;
|
||||
private long count;
|
||||
private long sortIndex;
|
||||
private long lastSeen;
|
||||
private boolean userWasSender;
|
||||
|
||||
Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) {
|
||||
this.messageId = messageId;
|
||||
this.isMms = isMms;
|
||||
this.emoji = emoji;
|
||||
this.count = count;
|
||||
this.sortIndex = sortIndex;
|
||||
this.lastSeen = lastSeen;
|
||||
this.userWasSender = userWasSender;
|
||||
}
|
||||
|
||||
void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) {
|
||||
if (!this.userWasSender) {
|
||||
if (userWasSender || lastSeen > this.lastSeen) {
|
||||
this.emoji = emoji;
|
||||
}
|
||||
}
|
||||
|
||||
this.count = this.count + count;
|
||||
this.lastSeen = Math.max(this.lastSeen, lastSeen);
|
||||
this.userWasSender = this.userWasSender || userWasSender;
|
||||
}
|
||||
|
||||
@NonNull Reaction merge(@NonNull Reaction other) {
|
||||
this.count = this.count + other.count;
|
||||
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
|
||||
this.userWasSender = this.userWasSender || other.userWasSender;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Reaction rhs) {
|
||||
Reaction lhs = this;
|
||||
if (lhs.count == rhs.count ) {
|
||||
return Long.compare(lhs.sortIndex, rhs.sortIndex);
|
||||
} else {
|
||||
return Long.compare(lhs.count, rhs.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,291 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.AttributeSet
|
||||
import android.view.*
|
||||
import android.view.View.OnTouchListener
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewEmojiReactionsBinding
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber
|
||||
import java.util.*
|
||||
|
||||
class EmojiReactionsView : ConstraintLayout, OnTouchListener {
|
||||
companion object {
|
||||
private const val DEFAULT_THRESHOLD = 5
|
||||
private const val longPressDurationThreshold: Long = 250
|
||||
private const val maxDoubleTapInterval: Long = 200
|
||||
}
|
||||
|
||||
private val binding: ViewEmojiReactionsBinding by lazy { ViewEmojiReactionsBinding.bind(this) }
|
||||
|
||||
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
|
||||
private val OUTER_MARGIN = ViewUtil.dpToPx(2)
|
||||
private var records: MutableList<ReactionRecord>? = null
|
||||
private var messageId: Long = 0
|
||||
private var delegate: VisibleMessageViewDelegate? = null
|
||||
private val gestureHandler = Handler(Looper.getMainLooper())
|
||||
private var pressCallback: Runnable? = null
|
||||
private var longPressCallback: Runnable? = null
|
||||
private var onDownTimestamp: Long = 0
|
||||
private var extended = false
|
||||
|
||||
constructor(context: Context) : super(context) { init(null) }
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) }
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) }
|
||||
|
||||
private fun init(attrs: AttributeSet?) {
|
||||
records = ArrayList()
|
||||
|
||||
if (attrs != null) {
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0)
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
records!!.clear()
|
||||
binding.layoutEmojiContainer.removeAllViews()
|
||||
}
|
||||
|
||||
fun setReactions(messageId: Long, records: List<ReactionRecord>, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) {
|
||||
this.delegate = delegate
|
||||
if (records == this.records) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.layoutEmojiContainer.justifyContent = if (outgoing) JustifyContent.FLEX_END else JustifyContent.FLEX_START
|
||||
this.records!!.clear()
|
||||
this.records!!.addAll(records)
|
||||
if (this.messageId != messageId) {
|
||||
extended = false
|
||||
}
|
||||
this.messageId = messageId
|
||||
displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD)
|
||||
}
|
||||
|
||||
override fun onTouch(v: View, event: MotionEvent): Boolean {
|
||||
if (v.tag == null) return false
|
||||
val reaction = v.tag as Reaction
|
||||
val action = event.action
|
||||
if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun displayReactions(threshold: Int) {
|
||||
val userPublicKey = getLocalNumber(context)
|
||||
val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold)
|
||||
binding.layoutEmojiContainer.removeAllViews()
|
||||
val overflowContainer = LinearLayout(context)
|
||||
overflowContainer.orientation = LinearLayout.HORIZONTAL
|
||||
val innerPadding = ViewUtil.dpToPx(4)
|
||||
overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding)
|
||||
val pixelSize = ViewUtil.dpToPx(1)
|
||||
for (reaction in reactions) {
|
||||
if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) {
|
||||
if (overflowContainer.parent == null) {
|
||||
binding.layoutEmojiContainer.addView(overflowContainer)
|
||||
val overflowParams = overflowContainer.layoutParams as MarginLayoutParams
|
||||
overflowParams.height = ViewUtil.dpToPx(26)
|
||||
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
|
||||
overflowContainer.layoutParams = overflowParams
|
||||
overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
|
||||
}
|
||||
val pill = buildPill(context, this, reaction, true)
|
||||
pill.setOnClickListener { v: View? ->
|
||||
extended = true
|
||||
displayReactions(Int.MAX_VALUE)
|
||||
}
|
||||
pill.findViewById<View>(R.id.reactions_pill_count).visibility = GONE
|
||||
pill.findViewById<View>(R.id.reactions_pill_spacer).visibility = GONE
|
||||
overflowContainer.addView(pill)
|
||||
} else {
|
||||
val pill = buildPill(context, this, reaction, false)
|
||||
pill.tag = reaction
|
||||
pill.setOnTouchListener(this)
|
||||
val params = pill.layoutParams as MarginLayoutParams
|
||||
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
|
||||
pill.layoutParams = params
|
||||
binding.layoutEmojiContainer.addView(pill)
|
||||
}
|
||||
}
|
||||
val overflowChildren = overflowContainer.childCount
|
||||
val negativeMargin = ViewUtil.dpToPx(-8)
|
||||
for (i in 0 until overflowChildren) {
|
||||
val child = overflowContainer.getChildAt(i)
|
||||
val childParams = child.layoutParams as MarginLayoutParams
|
||||
if (i == 0 && overflowChildren > 1 || i + 1 < overflowChildren) {
|
||||
// if first and there is more than one child, or we are not the last child then set negative right margin
|
||||
childParams.setMargins(0, 0, negativeMargin, 0)
|
||||
child.layoutParams = childParams
|
||||
}
|
||||
}
|
||||
if (threshold == Int.MAX_VALUE) {
|
||||
binding.groupShowLess.visibility = VISIBLE
|
||||
for (id in binding.groupShowLess.referencedIds) {
|
||||
findViewById<View>(id).setOnClickListener { view: View? ->
|
||||
extended = false
|
||||
displayReactions(DEFAULT_THRESHOLD)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.groupShowLess.visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSortedReactionsList(records: List<ReactionRecord>, userPublicKey: String?, threshold: Int): List<Reaction> {
|
||||
val counters: MutableMap<String, Reaction> = LinkedHashMap()
|
||||
|
||||
records.forEach {
|
||||
val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji)
|
||||
val info = counters[baseEmoji]
|
||||
|
||||
if (info == null) {
|
||||
counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author)
|
||||
}
|
||||
else {
|
||||
info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author)
|
||||
}
|
||||
}
|
||||
|
||||
val reactions: List<Reaction> = ArrayList(counters.values)
|
||||
Collections.sort(reactions, Collections.reverseOrder())
|
||||
|
||||
return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) {
|
||||
val shortened: MutableList<Reaction> = ArrayList(threshold + 2)
|
||||
shortened.addAll(reactions.subList(0, threshold + 2))
|
||||
shortened
|
||||
} else {
|
||||
reactions
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPill(context: Context, parent: ViewGroup, reaction: Reaction, isCompact: Boolean): View {
|
||||
val root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false)
|
||||
val emojiView = root.findViewById<EmojiImageView>(R.id.reactions_pill_emoji)
|
||||
val countView = root.findViewById<TextView>(R.id.reactions_pill_count)
|
||||
val spacer = root.findViewById<View>(R.id.reactions_pill_spacer)
|
||||
if (isCompact) {
|
||||
root.setPaddingRelative(1, 1, 1, 1)
|
||||
val layoutParams = root.layoutParams
|
||||
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
root.layoutParams = layoutParams
|
||||
}
|
||||
if (reaction.emoji != null) {
|
||||
emojiView.setImageEmoji(reaction.emoji)
|
||||
if (reaction.count >= 1) {
|
||||
countView.text = getFormattedNumber(reaction.count)
|
||||
} else {
|
||||
countView.visibility = GONE
|
||||
spacer.visibility = GONE
|
||||
}
|
||||
} else {
|
||||
emojiView.visibility = GONE
|
||||
spacer.visibility = GONE
|
||||
countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count)
|
||||
}
|
||||
if (reaction.userWasSender && !isCompact) {
|
||||
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)
|
||||
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor))
|
||||
} else {
|
||||
if (!isCompact) {
|
||||
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private fun onReactionClicked(reaction: Reaction) {
|
||||
if (reaction.messageId != 0L) {
|
||||
val messageId = MessageId(reaction.messageId, reaction.isMms)
|
||||
delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDown(messageId: MessageId) {
|
||||
removeLongPressCallback()
|
||||
val newLongPressCallback = Runnable {
|
||||
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
if (delegate != null) {
|
||||
delegate!!.onReactionLongClicked(messageId)
|
||||
}
|
||||
}
|
||||
longPressCallback = newLongPressCallback
|
||||
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold)
|
||||
onDownTimestamp = Date().time
|
||||
}
|
||||
|
||||
private fun removeLongPressCallback() {
|
||||
if (longPressCallback != null) {
|
||||
gestureHandler.removeCallbacks(longPressCallback!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUp(reaction: Reaction) {
|
||||
if (Date().time - onDownTimestamp < longPressDurationThreshold) {
|
||||
removeLongPressCallback()
|
||||
if (pressCallback != null) {
|
||||
gestureHandler.removeCallbacks(pressCallback!!)
|
||||
pressCallback = null
|
||||
} else {
|
||||
val newPressCallback = Runnable {
|
||||
onReactionClicked(reaction)
|
||||
pressCallback = null
|
||||
}
|
||||
pressCallback = newPressCallback
|
||||
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class Reaction(
|
||||
internal val messageId: Long,
|
||||
internal val isMms: Boolean,
|
||||
internal var emoji: String?,
|
||||
internal var count: Long,
|
||||
internal val sortIndex: Long,
|
||||
internal var lastSeen: Long,
|
||||
internal var userWasSender: Boolean
|
||||
) : Comparable<Reaction?> {
|
||||
fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) {
|
||||
if (!this.userWasSender) {
|
||||
if (userWasSender || lastSeen > this.lastSeen) {
|
||||
this.emoji = emoji
|
||||
}
|
||||
}
|
||||
this.count = this.count + count
|
||||
this.lastSeen = Math.max(this.lastSeen, lastSeen)
|
||||
this.userWasSender = this.userWasSender || userWasSender
|
||||
}
|
||||
|
||||
fun merge(other: Reaction): Reaction {
|
||||
count = count + other.count
|
||||
lastSeen = Math.max(lastSeen, other.lastSeen)
|
||||
userWasSender = userWasSender || other.userWasSender
|
||||
return this
|
||||
}
|
||||
|
||||
override fun compareTo(other: Reaction?): Int {
|
||||
if (other == null) { return -1 }
|
||||
|
||||
if (this.count == other.count) {
|
||||
return this.sortIndex.compareTo(other.sortIndex)
|
||||
}
|
||||
|
||||
return this.count.compareTo(other.count)
|
||||
}
|
||||
}
|
||||
}
|
@ -4,11 +4,9 @@ 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.widget.LinearLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewLinkPreviewBinding
|
||||
@ -19,21 +17,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtiliti
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide
|
||||
import org.thoughtcrime.securesms.util.UiModeUtilities
|
||||
|
||||
class LinkPreviewView : LinearLayout {
|
||||
private lateinit var binding: ViewLinkPreviewBinding
|
||||
private val binding: ViewLinkPreviewBinding by lazy { ViewLinkPreviewBinding.bind(this) }
|
||||
private val cornerMask by lazy { CornerMask(this) }
|
||||
private var url: String? = 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() }
|
||||
|
||||
private fun initialize() {
|
||||
binding = ViewLinkPreviewBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
@ -48,8 +41,8 @@ class LinkPreviewView : LinearLayout {
|
||||
// Thumbnail
|
||||
if (linkPreview.getThumbnail().isPresent) {
|
||||
// This internally fetches the thumbnail
|
||||
binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
|
||||
binding.thumbnailImageView.loadIndicator.isVisible = false
|
||||
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
|
||||
binding.thumbnailImageView.root.loadIndicator.isVisible = false
|
||||
}
|
||||
// Title
|
||||
binding.titleTextView.text = linkPreview.title
|
||||
@ -64,8 +57,12 @@ class LinkPreviewView : LinearLayout {
|
||||
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 round the bottom corners if there is no body text
|
||||
if (message.body.isEmpty()) {
|
||||
cornerMask.setBottomRightRadius(cornerRadii[2])
|
||||
cornerMask.setBottomLeftRadius(cornerRadii[3])
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
@ -80,7 +77,7 @@ class LinkPreviewView : LinearLayout {
|
||||
val rawYInt = event.rawY.toInt()
|
||||
val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
|
||||
val previewRect = Rect()
|
||||
binding.mainLinkPreviewParent.getGlobalVisibleRect(previewRect)
|
||||
binding.mainLinkPreviewContainer.getGlobalVisibleRect(previewRect)
|
||||
if (previewRect.contains(hitRect)) {
|
||||
openURL()
|
||||
return
|
||||
|
@ -93,7 +93,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
|
||||
val backgroundColor = context.getAccentColor()
|
||||
binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor)
|
||||
binding.quoteViewAttachmentPreviewImageView.isVisible = false
|
||||
binding.quoteViewAttachmentThumbnailImageView.isVisible = false
|
||||
binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false
|
||||
when {
|
||||
attachments.audioSlide != null -> {
|
||||
binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone)
|
||||
@ -108,9 +108,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
|
||||
attachments.thumbnailSlide != null -> {
|
||||
val slide = attachments.thumbnailSlide!!
|
||||
// This internally fetches the thumbnail
|
||||
binding.quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources)
|
||||
binding.quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false)
|
||||
binding.quoteViewAttachmentThumbnailImageView.isVisible = true
|
||||
binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources)
|
||||
binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null)
|
||||
binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true
|
||||
binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
|
||||
}
|
||||
}
|
||||
|
@ -10,10 +10,8 @@ import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
@ -22,13 +20,12 @@ import androidx.core.graphics.BlendModeColorFilterCompat
|
||||
import androidx.core.graphics.BlendModeCompat
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||
import okhttp3.HttpUrl
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
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.getColorFromAttr
|
||||
@ -47,26 +44,30 @@ import org.thoughtcrime.securesms.util.getAccentColor
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class VisibleMessageContentView : LinearLayout {
|
||||
private lateinit var binding: ViewVisibleMessageContentBinding
|
||||
class VisibleMessageContentView : ConstraintLayout {
|
||||
private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
|
||||
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
|
||||
var onContentDoubleTap: (() -> Unit)? = null
|
||||
var delegate: VisibleMessageViewDelegate? = null
|
||||
var indexInAdapter: 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() {
|
||||
binding = ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean,
|
||||
glide: GlideRequests, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) {
|
||||
fun bind(
|
||||
message: MessageRecord,
|
||||
isStartOfMessageCluster: Boolean,
|
||||
isEndOfMessageCluster: Boolean,
|
||||
glide: GlideRequests,
|
||||
thread: Recipient,
|
||||
searchQuery: String?,
|
||||
contactIsTrusted: Boolean,
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit
|
||||
) {
|
||||
// Background
|
||||
val background = getBackground(message.isOutgoing)
|
||||
val color = if (message.isOutgoing) context.getAccentColor()
|
||||
@ -80,7 +81,7 @@ class VisibleMessageContentView : LinearLayout {
|
||||
|
||||
// reset visibilities / containers
|
||||
onContentClick.clear()
|
||||
binding.albumThumbnailView.clearViews()
|
||||
binding.albumThumbnailView.root.clearViews()
|
||||
onContentDoubleTap = null
|
||||
|
||||
if (message.isDeleted) {
|
||||
@ -88,28 +89,26 @@ class VisibleMessageContentView : LinearLayout {
|
||||
binding.deletedMessageView.root.bind(message, getTextColor(context, message))
|
||||
binding.bodyTextView.isVisible = false
|
||||
binding.quoteView.root.isVisible = false
|
||||
binding.linkPreviewView.isVisible = false
|
||||
binding.linkPreviewView.root.isVisible = false
|
||||
binding.untrustedView.root.isVisible = false
|
||||
binding.voiceMessageView.root.isVisible = false
|
||||
binding.documentView.root.isVisible = false
|
||||
binding.albumThumbnailView.isVisible = false
|
||||
binding.albumThumbnailView.root.isVisible = false
|
||||
binding.openGroupInvitationView.root.isVisible = false
|
||||
return
|
||||
} else {
|
||||
binding.deletedMessageView.root.isVisible = false
|
||||
}
|
||||
// clear the
|
||||
|
||||
// Note: Need to clear the body to prevent the message bubble getting incorrectly
|
||||
// sized based on text content from a recycled view
|
||||
binding.bodyTextView.text = null
|
||||
|
||||
|
||||
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
|
||||
|
||||
binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
|
||||
|
||||
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
|
||||
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
|
||||
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
|
||||
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
|
||||
binding.albumThumbnailView.isVisible = mediaThumbnailMessage
|
||||
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
|
||||
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
|
||||
|
||||
var hideBody = false
|
||||
@ -141,8 +140,7 @@ class VisibleMessageContentView : LinearLayout {
|
||||
val attachmentId = dbAttachment.attachmentId.rowId
|
||||
if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
|
||||
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
|
||||
// start download
|
||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, dbAttachment.mmsId))
|
||||
onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId)
|
||||
}
|
||||
}
|
||||
message.linkPreviews.forEach { preview ->
|
||||
@ -150,16 +148,18 @@ class VisibleMessageContentView : LinearLayout {
|
||||
val attachmentId = previewThumbnail.attachmentId.rowId
|
||||
if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
|
||||
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
|
||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, previewThumbnail.mmsId))
|
||||
onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
|
||||
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
|
||||
// Body text view is inside the link preview for layout convenience
|
||||
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
|
||||
|
||||
// When in a link preview ensure the bodyTextView can expand to the full width
|
||||
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
|
||||
}
|
||||
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
|
||||
hideBody = true
|
||||
@ -195,21 +195,21 @@ class VisibleMessageContentView : LinearLayout {
|
||||
if (contactIsTrusted || message.isOutgoing) {
|
||||
// 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
|
||||
binding.albumThumbnailView.bind(
|
||||
binding.albumThumbnailView.root.bind(
|
||||
glideRequests = glide,
|
||||
message = message,
|
||||
isStart = isStartOfMessageCluster,
|
||||
isEnd = isEndOfMessageCluster
|
||||
)
|
||||
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams
|
||||
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.albumThumbnailView.layoutParams = layoutParams
|
||||
binding.albumThumbnailView.root.layoutParams = layoutParams
|
||||
onContentClick.add { event ->
|
||||
binding.albumThumbnailView.calculateHitObject(event, message, thread)
|
||||
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
|
||||
}
|
||||
} else {
|
||||
hideBody = true
|
||||
binding.albumThumbnailView.clearViews()
|
||||
binding.albumThumbnailView.root.clearViews()
|
||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
|
||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||
}
|
||||
@ -222,6 +222,7 @@ class VisibleMessageContentView : LinearLayout {
|
||||
}
|
||||
|
||||
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
|
||||
binding.contentParent.apply { isVisible = children.any { it.isVisible } }
|
||||
|
||||
if (message.body.isNotEmpty() && !hideBody) {
|
||||
val color = getTextColor(context, message)
|
||||
@ -241,7 +242,7 @@ class VisibleMessageContentView : LinearLayout {
|
||||
}
|
||||
|
||||
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
|
||||
listOf<View>(albumThumbnailView, linkPreviewView, voiceMessageView.root, quoteView.root).none { it.isVisible }
|
||||
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
|
||||
|
||||
private fun getBackground(isOutgoing: Boolean): Drawable {
|
||||
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
|
||||
@ -256,8 +257,8 @@ class VisibleMessageContentView : LinearLayout {
|
||||
binding.openGroupInvitationView.root,
|
||||
binding.documentView.root,
|
||||
binding.quoteView.root,
|
||||
binding.linkPreviewView,
|
||||
binding.albumThumbnailView,
|
||||
binding.linkPreviewView.root,
|
||||
binding.albumThumbnailView.root,
|
||||
binding.bodyTextView
|
||||
).forEach { view: View -> view.isVisible = false }
|
||||
}
|
||||
|
@ -13,6 +13,9 @@ import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
@ -26,6 +29,7 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ViewUtil
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
@ -85,7 +89,7 @@ class VisibleMessageView : LinearLayout {
|
||||
var onPress: ((event: MotionEvent) -> Unit)? = null
|
||||
var onSwipeToReply: (() -> Unit)? = null
|
||||
var onLongPress: (() -> Unit)? = null
|
||||
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView }
|
||||
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root }
|
||||
|
||||
companion object {
|
||||
const val swipeToReplyThreshold = 64.0f // dp
|
||||
@ -108,7 +112,7 @@ class VisibleMessageView : LinearLayout {
|
||||
isHapticFeedbackEnabled = true
|
||||
setWillNotDraw(false)
|
||||
binding.messageInnerContainer.disableClipping()
|
||||
binding.messageContentView.disableClipping()
|
||||
binding.messageContentView.root.disableClipping()
|
||||
}
|
||||
// endregion
|
||||
|
||||
@ -122,6 +126,7 @@ class VisibleMessageView : LinearLayout {
|
||||
contact: Contact?,
|
||||
senderSessionID: String,
|
||||
delegate: VisibleMessageViewDelegate?,
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit
|
||||
) {
|
||||
val threadID = message.threadId
|
||||
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
|
||||
@ -157,7 +162,8 @@ class VisibleMessageView : LinearLayout {
|
||||
binding.profilePictureView.root.update(message.individualRecipient)
|
||||
binding.profilePictureView.root.setOnClickListener {
|
||||
if (thread.isOpenGroupRecipient) {
|
||||
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) {
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
|
||||
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
|
||||
val intent = Intent(context, ConversationActivityV2::class.java)
|
||||
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
|
||||
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
|
||||
@ -190,50 +196,75 @@ class VisibleMessageView : LinearLayout {
|
||||
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
|
||||
binding.dateBreakTextView.isVisible = showDateBreak
|
||||
// Message status indicator
|
||||
val (iconID, iconColor) = getMessageStatusImage(message)
|
||||
if (iconID != null) {
|
||||
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
|
||||
if (iconColor != null) {
|
||||
drawable?.setTint(iconColor)
|
||||
}
|
||||
binding.messageStatusImageView.setImageDrawable(drawable)
|
||||
}
|
||||
if (message.isOutgoing) {
|
||||
val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message)
|
||||
if (textId != null) {
|
||||
binding.messageStatusTextView.setText(textId)
|
||||
|
||||
if (iconColor != null) {
|
||||
binding.messageStatusTextView.setTextColor(iconColor)
|
||||
}
|
||||
}
|
||||
if (iconID != null) {
|
||||
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
|
||||
if (iconColor != null) {
|
||||
drawable?.setTint(iconColor)
|
||||
}
|
||||
binding.messageStatusImageView.setImageDrawable(drawable)
|
||||
}
|
||||
binding.messageStatusImageView.contentDescription = contentDescription
|
||||
|
||||
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
|
||||
binding.messageStatusImageView.isVisible =
|
||||
!message.isSent || message.id == lastMessageID
|
||||
binding.messageStatusTextView.isVisible = (
|
||||
textId != null && (
|
||||
!message.isSent ||
|
||||
message.id == lastMessageID
|
||||
)
|
||||
)
|
||||
binding.messageStatusImageView.isVisible = (
|
||||
iconID != null && (
|
||||
!message.isSent ||
|
||||
message.id == lastMessageID
|
||||
)
|
||||
)
|
||||
} else {
|
||||
binding.messageStatusTextView.isVisible = false
|
||||
binding.messageStatusImageView.isVisible = false
|
||||
}
|
||||
// Expiration timer
|
||||
updateExpirationTimer(message)
|
||||
// Emoji Reactions
|
||||
val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams
|
||||
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
|
||||
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.emojiReactionsView.layoutParams = emojiLayoutParams
|
||||
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
|
||||
if (message.reactions.isNotEmpty() &&
|
||||
(capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase()))
|
||||
) {
|
||||
binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
|
||||
binding.emojiReactionsView.isVisible = true
|
||||
} else {
|
||||
binding.emojiReactionsView.isVisible = false
|
||||
binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
|
||||
|
||||
if (message.reactions.isNotEmpty()) {
|
||||
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
|
||||
if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) {
|
||||
binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
|
||||
binding.emojiReactionsView.root.isVisible = true
|
||||
} else {
|
||||
binding.emojiReactionsView.root.isVisible = false
|
||||
}
|
||||
}
|
||||
else {
|
||||
binding.emojiReactionsView.root.isVisible = false
|
||||
}
|
||||
|
||||
// Populate content view
|
||||
binding.messageContentView.indexInAdapter = indexInAdapter
|
||||
binding.messageContentView.bind(
|
||||
binding.messageContentView.root.indexInAdapter = indexInAdapter
|
||||
binding.messageContentView.root.bind(
|
||||
message,
|
||||
isStartOfMessageCluster,
|
||||
isEndOfMessageCluster,
|
||||
glide,
|
||||
thread,
|
||||
searchQuery,
|
||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)
|
||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
|
||||
onAttachmentNeedsDownload
|
||||
)
|
||||
binding.messageContentView.delegate = delegate
|
||||
onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() }
|
||||
binding.messageContentView.root.delegate = delegate
|
||||
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
|
||||
}
|
||||
|
||||
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean {
|
||||
@ -256,25 +287,60 @@ class VisibleMessageView : LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
data class MessageStatusInfo(@DrawableRes val iconId: Int?,
|
||||
@ColorInt val iconTint: Int?,
|
||||
@StringRes val messageText: Int?,
|
||||
val contentDescription: String?)
|
||||
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
)
|
||||
message.isSyncFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
context.getColor(R.color.accent_orange),
|
||||
R.string.delivery_status_sync_failed,
|
||||
null
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
)
|
||||
message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
|
||||
)
|
||||
message.isRead ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
|
||||
null
|
||||
)
|
||||
else ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sent,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sent,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_tick)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateExpirationTimer(message: MessageRecord) {
|
||||
val container = binding.messageInnerContainer
|
||||
val content = binding.messageContentView
|
||||
val content = binding.messageContentView.root
|
||||
val expiration = binding.expirationTimerView
|
||||
val spacing = binding.messageContentSpacing
|
||||
container.removeAllViewsInLayout()
|
||||
container.addView(if (message.isOutgoing) expiration else content)
|
||||
container.addView(if (message.isOutgoing) content else expiration)
|
||||
container.addView(spacing, if (message.isOutgoing) 0 else 2)
|
||||
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
|
||||
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
container.layoutParams = containerParams
|
||||
@ -285,7 +351,7 @@ class VisibleMessageView : LinearLayout {
|
||||
if (message.expireStarted > 0) {
|
||||
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||
binding.expirationTimerView.startAnimation()
|
||||
if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) {
|
||||
if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) {
|
||||
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
|
||||
}
|
||||
} else if (!message.isMediaPending) {
|
||||
@ -319,7 +385,7 @@ class VisibleMessageView : LinearLayout {
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
|
||||
val iconSize = toPx(24, context.resources)
|
||||
val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing
|
||||
val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
|
||||
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
|
||||
val right = left + iconSize
|
||||
val bottom = top + iconSize
|
||||
@ -341,7 +407,7 @@ class VisibleMessageView : LinearLayout {
|
||||
|
||||
fun recycle() {
|
||||
binding.profilePictureView.root.recycle()
|
||||
binding.messageContentView.recycle()
|
||||
binding.messageContentView.root.recycle()
|
||||
}
|
||||
// endregion
|
||||
|
||||
@ -437,7 +503,7 @@ class VisibleMessageView : LinearLayout {
|
||||
}
|
||||
|
||||
fun onContentClick(event: MotionEvent) {
|
||||
binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
|
||||
binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
|
||||
}
|
||||
|
||||
private fun onPress(event: MotionEvent) {
|
||||
@ -457,7 +523,7 @@ class VisibleMessageView : LinearLayout {
|
||||
}
|
||||
|
||||
fun playVoiceMessage() {
|
||||
binding.messageContentView.playVoiceMessage()
|
||||
binding.messageContentView.root.playVoiceMessage()
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -14,9 +14,7 @@ open class BaseDialog : DialogFragment() {
|
||||
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)
|
||||
result.window?.setDimAmount(0.6f)
|
||||
return result
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.visible.LinkPreview
|
||||
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
|
||||
import org.session.libsession.messaging.messages.visible.Quote
|
||||
@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
|
||||
object ResendMessageUtilities {
|
||||
|
||||
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) {
|
||||
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) {
|
||||
val recipient: Recipient = messageRecord.recipient
|
||||
val message = VisibleMessage()
|
||||
message.id = messageRecord.getId()
|
||||
@ -55,8 +56,13 @@ object ResendMessageUtilities {
|
||||
val sentTimestamp = message.sentTimestamp
|
||||
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
|
||||
if (sentTimestamp != null && sender != null) {
|
||||
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
|
||||
if (isResync) {
|
||||
MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender)
|
||||
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = true)
|
||||
} else {
|
||||
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
|
||||
MessageSender.send(message, recipient.address)
|
||||
}
|
||||
}
|
||||
MessageSender.send(message, recipient.address)
|
||||
}
|
||||
}
|
@ -38,13 +38,12 @@ object TextUtilities {
|
||||
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
|
||||
val textLayout = layout ?: return emptyList()
|
||||
val lineRect = Rect()
|
||||
val bodyTextRect = Rect()
|
||||
getGlobalVisibleRect(bodyTextRect)
|
||||
val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) }
|
||||
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)) {
|
||||
lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop)
|
||||
if (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()
|
||||
|
@ -1,425 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.utilities;
|
||||
|
||||
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.net.Uri;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.UiThread;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.FitCenter;
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.ViewUtil;
|
||||
import org.session.libsignal.utilities.ListenableFuture;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.SettableFuture;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
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;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class ThumbnailView extends FrameLayout {
|
||||
|
||||
private static final String TAG = ThumbnailView.class.getSimpleName();
|
||||
private static final int WIDTH = 0;
|
||||
private static final int HEIGHT = 1;
|
||||
private static final int MIN_WIDTH = 0;
|
||||
private static final int MAX_WIDTH = 1;
|
||||
private static final int MIN_HEIGHT = 2;
|
||||
private static final int MAX_HEIGHT = 3;
|
||||
|
||||
private ImageView image;
|
||||
private View playOverlay;
|
||||
private View loadIndicator;
|
||||
private OnClickListener parentClickListener;
|
||||
|
||||
private final int[] dimens = new int[2];
|
||||
private final int[] bounds = new int[4];
|
||||
private final int[] measureDimens = new int[2];
|
||||
|
||||
private Optional<TransferControlView> transferControls = Optional.absent();
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private Slide slide = null;
|
||||
|
||||
public int radius;
|
||||
|
||||
public ThumbnailView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ThumbnailView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
|
||||
inflate(context, R.layout.thumbnail_view, this);
|
||||
|
||||
this.image = findViewById(R.id.thumbnail_image);
|
||||
this.playOverlay = findViewById(R.id.play_overlay);
|
||||
this.loadIndicator = findViewById(R.id.thumbnail_load_indicator);
|
||||
super.setOnClickListener(new ThumbnailClickDispatcher());
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0);
|
||||
bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0);
|
||||
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, 0);
|
||||
typedArray.recycle();
|
||||
} else {
|
||||
radius = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) {
|
||||
fillTargetDimensions(measureDimens, dimens, bounds);
|
||||
if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) {
|
||||
super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec);
|
||||
return;
|
||||
}
|
||||
|
||||
int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight();
|
||||
int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom();
|
||||
|
||||
super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY));
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousNameCombination")
|
||||
private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) {
|
||||
int dimensFilledCount = getNonZeroCount(dimens);
|
||||
int boundsFilledCount = getNonZeroCount(bounds);
|
||||
|
||||
if (dimensFilledCount == 0 || boundsFilledCount == 0) {
|
||||
targetDimens[WIDTH] = 0;
|
||||
targetDimens[HEIGHT] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
double naturalWidth = dimens[WIDTH];
|
||||
double naturalHeight = dimens[HEIGHT];
|
||||
|
||||
int minWidth = bounds[MIN_WIDTH];
|
||||
int maxWidth = bounds[MAX_WIDTH];
|
||||
int minHeight = bounds[MIN_HEIGHT];
|
||||
int maxHeight = bounds[MAX_HEIGHT];
|
||||
|
||||
if (dimensFilledCount > 0 && dimensFilledCount < dimens.length) {
|
||||
throw new IllegalStateException(String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %f x %f",
|
||||
naturalWidth, naturalHeight));
|
||||
}
|
||||
if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) {
|
||||
throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]",
|
||||
minWidth, maxWidth, minHeight, maxHeight));
|
||||
}
|
||||
|
||||
double measuredWidth = naturalWidth;
|
||||
double measuredHeight = naturalHeight;
|
||||
|
||||
boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth;
|
||||
boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight;
|
||||
|
||||
if (!widthInBounds || !heightInBounds) {
|
||||
double minWidthRatio = naturalWidth / minWidth;
|
||||
double maxWidthRatio = naturalWidth / maxWidth;
|
||||
double minHeightRatio = naturalHeight / minHeight;
|
||||
double maxHeightRatio = naturalHeight / maxHeight;
|
||||
|
||||
if (maxWidthRatio > 1 || maxHeightRatio > 1) {
|
||||
if (maxWidthRatio >= maxHeightRatio) {
|
||||
measuredWidth /= maxWidthRatio;
|
||||
measuredHeight /= maxWidthRatio;
|
||||
} else {
|
||||
measuredWidth /= maxHeightRatio;
|
||||
measuredHeight /= maxHeightRatio;
|
||||
}
|
||||
|
||||
measuredWidth = Math.max(measuredWidth, minWidth);
|
||||
measuredHeight = Math.max(measuredHeight, minHeight);
|
||||
|
||||
} else if (minWidthRatio < 1 || minHeightRatio < 1) {
|
||||
if (minWidthRatio <= minHeightRatio) {
|
||||
measuredWidth /= minWidthRatio;
|
||||
measuredHeight /= minWidthRatio;
|
||||
} else {
|
||||
measuredWidth /= minHeightRatio;
|
||||
measuredHeight /= minHeightRatio;
|
||||
}
|
||||
|
||||
measuredWidth = Math.min(measuredWidth, maxWidth);
|
||||
measuredHeight = Math.min(measuredHeight, maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
targetDimens[WIDTH] = (int) measuredWidth;
|
||||
targetDimens[HEIGHT] = (int) measuredHeight;
|
||||
}
|
||||
|
||||
private int getNonZeroCount(int[] vals) {
|
||||
int count = 0;
|
||||
for (int val : vals) {
|
||||
if (val > 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(OnClickListener l) {
|
||||
parentClickListener = l;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFocusable(boolean focusable) {
|
||||
super.setFocusable(focusable);
|
||||
if (transferControls.isPresent()) transferControls.get().setFocusable(focusable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClickable(boolean clickable) {
|
||||
super.setClickable(clickable);
|
||||
if (transferControls.isPresent()) transferControls.get().setClickable(clickable);
|
||||
}
|
||||
|
||||
private TransferControlView getTransferControls() {
|
||||
if (!transferControls.isPresent()) {
|
||||
transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub));
|
||||
}
|
||||
return transferControls.get();
|
||||
}
|
||||
|
||||
public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) {
|
||||
bounds[MIN_WIDTH] = minWidth;
|
||||
bounds[MAX_WIDTH] = maxWidth;
|
||||
bounds[MIN_HEIGHT] = minHeight;
|
||||
bounds[MAX_HEIGHT] = maxHeight;
|
||||
|
||||
forceLayout();
|
||||
}
|
||||
|
||||
@UiThread
|
||||
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
|
||||
boolean showControls, boolean isPreview)
|
||||
{
|
||||
return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0);
|
||||
}
|
||||
|
||||
@UiThread
|
||||
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
|
||||
boolean showControls, boolean isPreview,
|
||||
int naturalWidth, int naturalHeight)
|
||||
{
|
||||
if (showControls) {
|
||||
getTransferControls().setSlide(slide);
|
||||
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
|
||||
} else if (transferControls.isPresent()) {
|
||||
getTransferControls().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (slide.getThumbnailUri() != null && slide.hasPlayOverlay() &&
|
||||
(slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
{
|
||||
this.playOverlay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.playOverlay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (Util.equals(slide, this.slide)) {
|
||||
Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri());
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
|
||||
if (this.slide != null && this.slide.getFastPreflightId() != null &&
|
||||
this.slide.getFastPreflightId().equals(slide.getFastPreflightId()))
|
||||
{
|
||||
Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId());
|
||||
this.slide = slide;
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
|
||||
Log.i(TAG, "loading part with id " + slide.asAttachment().getDataUri()
|
||||
+ ", progress " + slide.getTransferState() + ", fast preflight id: " +
|
||||
slide.asAttachment().getFastPreflightId());
|
||||
|
||||
this.slide = slide;
|
||||
|
||||
dimens[WIDTH] = naturalWidth;
|
||||
dimens[HEIGHT] = naturalHeight;
|
||||
invalidate();
|
||||
|
||||
SettableFuture<Boolean> result = new SettableFuture<>();
|
||||
|
||||
if (slide.getThumbnailUri() != null) {
|
||||
buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result));
|
||||
} else if (slide.hasPlaceholder()) {
|
||||
buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(image, result));
|
||||
} else {
|
||||
glideRequests.load(R.drawable.ic_image_white_24dp).centerInside().into(image);
|
||||
result.set(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
|
||||
SettableFuture<Boolean> future = new SettableFuture<>();
|
||||
|
||||
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
|
||||
|
||||
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(withCrossFade());
|
||||
|
||||
if (radius > 0) {
|
||||
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
|
||||
} else {
|
||||
request = request.transforms(new CenterCrop());
|
||||
}
|
||||
|
||||
request.into(new GlideDrawableListeningTarget(image, future));
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public void setThumbnailClickListener(SlideClickListener listener) {
|
||||
this.thumbnailClickListener = listener;
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.downloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void clear(GlideRequests glideRequests) {
|
||||
glideRequests.clear(image);
|
||||
|
||||
if (transferControls.isPresent()) {
|
||||
getTransferControls().clear();
|
||||
}
|
||||
|
||||
slide = null;
|
||||
}
|
||||
|
||||
public void showDownloadText(boolean showDownloadText) {
|
||||
getTransferControls().setShowDownloadText(showDownloadText);
|
||||
}
|
||||
|
||||
public void showProgressSpinner() {
|
||||
getTransferControls().showProgressSpinner();
|
||||
}
|
||||
|
||||
public void setLoadIndicatorVisibile(boolean visible) {
|
||||
this.loadIndicator.setVisibility(visible ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
protected void setRadius(int radius) {
|
||||
this.radius = radius;
|
||||
}
|
||||
|
||||
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri()))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(withCrossFade()), new CenterCrop());
|
||||
|
||||
if (slide.isInProgress()) return request;
|
||||
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
|
||||
}
|
||||
|
||||
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
return applySizing(glideRequests.asBitmap()
|
||||
.load(slide.getPlaceholderRes(getContext().getTheme()))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE), new FitCenter());
|
||||
}
|
||||
|
||||
private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) {
|
||||
int[] size = new int[2];
|
||||
fillTargetDimensions(size, dimens, bounds);
|
||||
if (size[WIDTH] == 0 && size[HEIGHT] == 0) {
|
||||
size[WIDTH] = getDefaultWidth();
|
||||
size[HEIGHT] = getDefaultHeight();
|
||||
}
|
||||
|
||||
request = request.override(size[WIDTH], size[HEIGHT]);
|
||||
|
||||
if (radius > 0) {
|
||||
return request.transforms(fitting, new RoundedCorners(radius));
|
||||
} else {
|
||||
return request.transforms(fitting);
|
||||
}
|
||||
}
|
||||
|
||||
private int getDefaultWidth() {
|
||||
ViewGroup.LayoutParams params = getLayoutParams();
|
||||
if (params != null) {
|
||||
return Math.max(params.width, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int getDefaultHeight() {
|
||||
ViewGroup.LayoutParams params = getLayoutParams();
|
||||
if (params != null) {
|
||||
return Math.max(params.height, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private class ThumbnailClickDispatcher implements View.OnClickListener {
|
||||
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (thumbnailClickListener != null &&
|
||||
slide != null &&
|
||||
slide.asAttachment().getDataUri() != null &&
|
||||
slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE)
|
||||
{
|
||||
thumbnailClickListener.onClick(view, slide);
|
||||
} else if (parentClickListener != null) {
|
||||
parentClickListener.onClick(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DownloadClickDispatcher implements View.OnClickListener {
|
||||
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (downloadClickListener != null && slide != null) {
|
||||
downloadClickListener.onClick(view, Collections.singletonList(slide));
|
||||
} else {
|
||||
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,14 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
@ -29,31 +26,33 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import kotlin.Boolean
|
||||
import kotlin.Int
|
||||
import kotlin.getValue
|
||||
import kotlin.lazy
|
||||
import kotlin.let
|
||||
|
||||
open class KThumbnailView: FrameLayout {
|
||||
private lateinit var binding: ThumbnailViewBinding
|
||||
open class ThumbnailView: FrameLayout {
|
||||
companion object {
|
||||
private const val WIDTH = 0
|
||||
private const val HEIGHT = 1
|
||||
}
|
||||
|
||||
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
|
||||
|
||||
// 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 { binding.thumbnailImage }
|
||||
private val playOverlay by lazy { binding.playOverlay }
|
||||
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
|
||||
val downloadIndicator: View by lazy { binding.thumbnailDownloadIcon }
|
||||
|
||||
private val dimensDelegate = ThumbnailDimensDelegate()
|
||||
|
||||
private var slide: Slide? = null
|
||||
private var radius: Int = 0
|
||||
var radius: Int = 0
|
||||
|
||||
private fun initialize(attrs: AttributeSet?) {
|
||||
binding = ThumbnailViewBinding.inflate(LayoutInflater.from(context), this)
|
||||
if (attrs != null) {
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
|
||||
|
||||
@ -66,8 +65,6 @@ open class KThumbnailView: FrameLayout {
|
||||
|
||||
typedArray.recycle()
|
||||
}
|
||||
val background = ContextCompat.getColor(context, R.color.transparent_black_6)
|
||||
binding.root.background = ColorDrawable(background)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@ -80,8 +77,8 @@ open class KThumbnailView: FrameLayout {
|
||||
val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom
|
||||
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)
|
||||
MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)
|
||||
)
|
||||
}
|
||||
|
||||
@ -90,17 +87,17 @@ open class KThumbnailView: FrameLayout {
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture<Boolean> {
|
||||
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> {
|
||||
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
|
||||
|
||||
val currentSlide = this.slide
|
||||
|
||||
playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
|
||||
if (equals(currentSlide, slide)) {
|
||||
@ -116,8 +113,8 @@ open class KThumbnailView: FrameLayout {
|
||||
|
||||
this.slide = slide
|
||||
|
||||
loadIndicator.isVisible = slide.isInProgress
|
||||
downloadIndicator.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
||||
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
|
||||
binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
||||
|
||||
dimensDelegate.setDimens(naturalWidth, naturalHeight)
|
||||
invalidate()
|
||||
@ -126,13 +123,13 @@ open class KThumbnailView: FrameLayout {
|
||||
|
||||
when {
|
||||
slide.thumbnailUri != null -> {
|
||||
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result))
|
||||
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
|
||||
}
|
||||
slide.hasPlaceholder() -> {
|
||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result))
|
||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
|
||||
}
|
||||
else -> {
|
||||
glide.clear(image)
|
||||
glide.clear(binding.thumbnailImage)
|
||||
result.set(false)
|
||||
}
|
||||
}
|
||||
@ -176,7 +173,7 @@ open class KThumbnailView: FrameLayout {
|
||||
}
|
||||
|
||||
open fun clear(glideRequests: GlideRequests) {
|
||||
glideRequests.clear(image)
|
||||
glideRequests.clear(binding.thumbnailImage)
|
||||
slide = null
|
||||
}
|
||||
|
||||
@ -193,11 +190,8 @@ open class KThumbnailView: FrameLayout {
|
||||
request.transforms(CenterCrop())
|
||||
}
|
||||
|
||||
request.into(GlideDrawableListeningTarget(image, future))
|
||||
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future))
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
package org.thoughtcrime.securesms.crypto;
|
||||
|
||||
|
||||
import android.os.Build;
|
||||
import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK;
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
@ -45,44 +45,50 @@ public final class KeyStoreHelper {
|
||||
private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
|
||||
private static final String KEY_ALIAS = "SignalSecret";
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
public static SealedData seal(@NonNull byte[] input) {
|
||||
SecretKey secretKey = getOrCreateKeyStoreEntry();
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] data = cipher.doFinal(input);
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] data = cipher.doFinal(input);
|
||||
|
||||
return new SealedData(iv, data);
|
||||
return new SealedData(iv, data);
|
||||
}
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
public static byte[] unseal(@NonNull SealedData sealedData) {
|
||||
SecretKey secretKey = getKeyStoreEntry();
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
|
||||
|
||||
return cipher.doFinal(sealedData.data);
|
||||
return cipher.doFinal(sealedData.data);
|
||||
}
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey getOrCreateKeyStoreEntry() {
|
||||
if (hasKeyStoreEntry()) return getKeyStoreEntry();
|
||||
else return createKeyStoreEntry();
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey createKeyStoreEntry() {
|
||||
try {
|
||||
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
|
||||
@ -99,7 +105,6 @@ public final class KeyStoreHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey getKeyStoreEntry() {
|
||||
KeyStore keyStore = getKeyStore();
|
||||
|
||||
@ -137,7 +142,6 @@ public final class KeyStoreHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static boolean hasKeyStoreEntry() {
|
||||
try {
|
||||
KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
|
||||
@ -202,7 +206,5 @@ public final class KeyStoreHelper {
|
||||
return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -33,8 +33,9 @@ import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
|
||||
@ -318,6 +319,28 @@ public class AttachmentDatabase extends Database {
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
void deleteAttachmentsForMessages(long[] mmsIds) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
Cursor cursor = null;
|
||||
String mmsIdString = StringUtils.join(mmsIds, ',');
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " IN (?)",
|
||||
new String[] {mmsIdString}, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
database.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {mmsIdString});
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
public void deleteAttachment(@NonNull AttachmentId id) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
|
@ -23,7 +23,7 @@ import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.utilities.WindowDebouncer;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
|
@ -19,7 +19,7 @@ package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
@ -1,9 +1,9 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import androidx.core.database.getStringOrNull
|
||||
import net.sqlcipher.Cursor
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.session.libsignal.utilities.Base64
|
||||
|
||||
fun <T> SQLiteDatabase.get(table: String, query: String?, arguments: Array<String>?, get: (Cursor) -> T): T? {
|
||||
|
@ -6,7 +6,7 @@ import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
@ -12,7 +11,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.utilities.Address;
|
||||
@ -319,6 +318,38 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeProfilePicture(String groupID) {
|
||||
databaseHelper.getWritableDatabase()
|
||||
.execSQL("UPDATE " + TABLE_NAME +
|
||||
" SET " + AVATAR + " = NULL, " +
|
||||
AVATAR_ID + " = NULL, " +
|
||||
AVATAR_KEY + " = NULL, " +
|
||||
AVATAR_CONTENT_TYPE + " = NULL, " +
|
||||
AVATAR_RELAY + " = NULL, " +
|
||||
AVATAR_DIGEST + " = NULL, " +
|
||||
AVATAR_URL + " = NULL" +
|
||||
" WHERE " +
|
||||
GROUP_ID + " = ?",
|
||||
new String[] {groupID});
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(null));
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public boolean hasDownloadedProfilePicture(String groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?",
|
||||
new String[] {groupId},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return !cursor.isNull(0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void updateMembers(String groupId, List<Address> members) {
|
||||
Collections.sort(members);
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
@ -110,6 +110,11 @@ public class GroupReceiptDatabase extends Database {
|
||||
db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)});
|
||||
}
|
||||
|
||||
void deleteRowsForMessages(long[] mmsIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {StringUtils.join(mmsIds, ',')});
|
||||
}
|
||||
|
||||
void deleteAllRows() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
|
@ -1,249 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class JobDatabase extends Database {
|
||||
|
||||
public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE,
|
||||
Constraints.CREATE_TABLE,
|
||||
Dependencies.CREATE_TABLE };
|
||||
|
||||
public static final class Jobs {
|
||||
public static final String TABLE_NAME = "job_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
private static final String QUEUE_KEY = "queue_key";
|
||||
private static final String CREATE_TIME = "create_time";
|
||||
private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time";
|
||||
private static final String RUN_ATTEMPT = "run_attempt";
|
||||
private static final String MAX_ATTEMPTS = "max_attempts";
|
||||
private static final String MAX_BACKOFF = "max_backoff";
|
||||
private static final String MAX_INSTANCES = "max_instances";
|
||||
private static final String LIFESPAN = "lifespan";
|
||||
private static final String SERIALIZED_DATA = "serialized_data";
|
||||
private static final String IS_RUNNING = "is_running";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT UNIQUE, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
QUEUE_KEY + " TEXT, " +
|
||||
CREATE_TIME + " INTEGER, " +
|
||||
NEXT_RUN_ATTEMPT_TIME + " INTEGER, " +
|
||||
RUN_ATTEMPT + " INTEGER, " +
|
||||
MAX_ATTEMPTS + " INTEGER, " +
|
||||
MAX_BACKOFF + " INTEGER, " +
|
||||
MAX_INSTANCES + " INTEGER, " +
|
||||
LIFESPAN + " INTEGER, " +
|
||||
SERIALIZED_DATA + " TEXT, " +
|
||||
IS_RUNNING + " INTEGER)";
|
||||
}
|
||||
|
||||
public static final class Constraints {
|
||||
public static final String TABLE_NAME = "constraint_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
|
||||
}
|
||||
|
||||
public static final class Dependencies {
|
||||
public static final String TABLE_NAME = "dependency_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
DEPENDS_ON_JOB_SPEC_ID + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))";
|
||||
}
|
||||
|
||||
|
||||
public JobDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (FullSpec fullSpec : fullSpecs) {
|
||||
insertJobSpec(db, fullSpec.getJobSpec());
|
||||
insertConstraintSpecs(db, fullSpec.getConstraintSpecs());
|
||||
insertDependencySpecs(db, fullSpec.getDependencySpecs());
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<JobSpec> getAllJobSpecs() {
|
||||
List<JobSpec> jobs = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
jobs.add(jobSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, runAttempt);
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateAllJobsToBePending() {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, 0);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null);
|
||||
}
|
||||
|
||||
public synchronized void deleteJobs(@NonNull List<String> jobIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (String jobId : jobIds) {
|
||||
String[] arg = new String[]{jobId};
|
||||
|
||||
db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() {
|
||||
List<ConstraintSpec> constraints = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
constraints.add(constraintSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<DependencySpec> getAllDependencySpecs() {
|
||||
List<DependencySpec> dependencies = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
dependencies.add(dependencySpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.JOB_SPEC_ID, job.getId());
|
||||
contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey());
|
||||
contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey());
|
||||
contentValues.put(Jobs.CREATE_TIME, job.getCreateTime());
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime());
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt());
|
||||
contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts());
|
||||
contentValues.put(Jobs.MAX_BACKOFF, job.getMaxBackoff());
|
||||
contentValues.put(Jobs.MAX_INSTANCES, job.getMaxInstances());
|
||||
contentValues.put(Jobs.LIFESPAN, job.getLifespan());
|
||||
contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData());
|
||||
contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0);
|
||||
|
||||
db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
|
||||
private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List<ConstraintSpec> constraints) {
|
||||
for (ConstraintSpec constraintSpec : constraints) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId());
|
||||
contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey());
|
||||
db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List<DependencySpec> dependencies) {
|
||||
for (DependencySpec dependencySpec : dependencies) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId());
|
||||
contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId());
|
||||
db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.MAX_BACKOFF)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_INSTANCES)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1);
|
||||
}
|
||||
|
||||
private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY)));
|
||||
}
|
||||
|
||||
private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID)));
|
||||
}
|
||||
}
|
@ -300,6 +300,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
val lastHash = database.insertOrUpdate(lastMessageHashValueTable2, row, query, arrayOf( snode.toString(), publicKey, namespace.toString() ))
|
||||
}
|
||||
|
||||
override fun clearAllLastMessageHashes() {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(lastMessageHashValueTable2, null, null)
|
||||
}
|
||||
|
||||
override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set<String>? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?"
|
||||
@ -321,6 +326,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() ))
|
||||
}
|
||||
|
||||
override fun clearReceivedMessageHashValues() {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(receivedMessageHashValuesTable, null, null)
|
||||
}
|
||||
|
||||
override fun getAuthToken(server: String): String? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor ->
|
||||
@ -339,7 +349,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
}
|
||||
|
||||
override fun getLastMessageServerID(room: String, server: String): Long? {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val database = databaseHelper.readableDatabase
|
||||
val index = "$server.$room"
|
||||
return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor ->
|
||||
cursor.getInt(lastMessageServerID)
|
||||
@ -510,7 +520,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
}
|
||||
|
||||
fun getServerCapabilities(serverName: String): List<String> {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor ->
|
||||
cursor.getString(capabilities)
|
||||
}?.split(",") ?: emptyList()
|
||||
@ -523,7 +533,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
}
|
||||
|
||||
fun getLastInboxMessageId(serverName: String): Long? {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor ->
|
||||
cursor.getInt(lastInboxMessageServerId)
|
||||
}?.toLong()
|
||||
@ -540,7 +550,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
}
|
||||
|
||||
fun getLastOutboxMessageId(serverName: String): Long? {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor ->
|
||||
cursor.getInt(lastOutboxMessageServerId)
|
||||
}?.toLong()
|
||||
|
@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import net.sqlcipher.database.SQLiteDatabase.CONFLICT_REPLACE
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
|
||||
import org.session.libsignal.database.LokiMessageDatabaseProtocol
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
||||
@ -77,6 +77,25 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
fun deleteMessages(messageIDs: List<Long>) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.beginTransaction()
|
||||
|
||||
database.delete(
|
||||
messageIDTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
database.delete(
|
||||
messageThreadMappingTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return pair of sms or mms table-specific ID and whether it is in SMS table
|
||||
*/
|
||||
@ -96,6 +115,37 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
|
||||
// Retrieve the message ids
|
||||
val messageIdCursor = database
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT ${messageThreadMappingTable}.${messageID}, ${messageIDTable}.${messageType}
|
||||
FROM ${messageThreadMappingTable}
|
||||
JOIN ${messageIDTable} ON ${messageIDTable}.message_id = ${messageThreadMappingTable}.${messageID}
|
||||
WHERE (
|
||||
${messageThreadMappingTable}.${Companion.threadID} = $threadID AND
|
||||
${messageThreadMappingTable}.${Companion.serverID} IN (${serverIDs.joinToString(",")})
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
val smsMessageIds: MutableList<Long> = mutableListOf()
|
||||
val mmsMessageIds: MutableList<Long> = mutableListOf()
|
||||
while (messageIdCursor.moveToNext()) {
|
||||
if (messageIdCursor.getInt(1) == SMS_TYPE) {
|
||||
smsMessageIds.add(messageIdCursor.getLong(0))
|
||||
}
|
||||
else {
|
||||
mmsMessageIds.add(messageIdCursor.getLong(0))
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(smsMessageIds, mmsMessageIds)
|
||||
}
|
||||
|
||||
override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(3)
|
||||
@ -183,6 +233,15 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
}
|
||||
|
||||
fun deleteMessageServerHashes(messageIDs: List<Long>) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(
|
||||
messageHashTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(1)
|
||||
|
@ -7,7 +7,7 @@ import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
|
||||
import org.session.libsession.utilities.Address;
|
||||
|
@ -5,7 +5,7 @@ import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Document;
|
||||
@ -37,11 +37,19 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
||||
public abstract void markAsSent(long messageId, boolean secure);
|
||||
|
||||
public abstract void markAsSyncing(long id);
|
||||
|
||||
public abstract void markAsResyncing(long id);
|
||||
|
||||
public abstract void markAsSyncFailed(long id);
|
||||
|
||||
public abstract void markUnidentified(long messageId, boolean unidentified);
|
||||
|
||||
public abstract void markAsDeleted(long messageId, boolean read);
|
||||
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
|
||||
|
||||
public abstract boolean deleteMessage(long messageId);
|
||||
public abstract boolean deleteMessages(long[] messageId, long threadId);
|
||||
|
||||
public abstract void updateThreadId(long fromId, long toId);
|
||||
|
||||
@ -198,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
contentValues.put(THREAD_ID, newThreadId);
|
||||
db.update(getTableName(), contentValues, where, args);
|
||||
}
|
||||
|
||||
public static class SyncMessageId {
|
||||
|
||||
private final Address address;
|
||||
|
@ -36,6 +36,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Address.Companion.UNKNOWN
|
||||
import org.session.libsession.utilities.Address.Companion.fromExternal
|
||||
@ -60,11 +61,13 @@ import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.util.asSequence
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
@ -91,54 +94,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return 0
|
||||
}
|
||||
|
||||
fun addFailures(messageId: Long, failure: List<NetworkFailure>) {
|
||||
try {
|
||||
addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
fun isOutgoingMessage(timestamp: Long): Boolean =
|
||||
databaseHelper.writableDatabase.query(
|
||||
TABLE_NAME,
|
||||
arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
|
||||
DATE_SENT + " = ?",
|
||||
arrayOf(timestamp.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { cursor.getColumnIndexOrThrow(MESSAGE_BOX) }
|
||||
.map(cursor::getLong)
|
||||
.any { MmsSmsColumns.Types.isOutgoingMessageType(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFailure(messageId: Long, failure: NetworkFailure?) {
|
||||
try {
|
||||
removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
fun isOutgoingMessage(timestamp: Long): Boolean {
|
||||
val database = databaseHelper.writableDatabase
|
||||
var cursor: Cursor? = null
|
||||
var isOutgoing = false
|
||||
try {
|
||||
cursor = database.query(
|
||||
TABLE_NAME,
|
||||
arrayOf<String>(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
|
||||
DATE_SENT + " = ?",
|
||||
arrayOf(timestamp.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
while (cursor.moveToNext()) {
|
||||
if (MmsSmsColumns.Types.isOutgoingMessageType(
|
||||
cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(
|
||||
MESSAGE_BOX
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
isOutgoing = true
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
return isOutgoing
|
||||
}
|
||||
|
||||
fun incrementReceiptCount(
|
||||
messageId: SyncMessageId,
|
||||
@ -254,15 +225,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThreadIdFor(notification: NotificationInd): Long {
|
||||
val fromString =
|
||||
if (notification.from != null && notification.from.textString != null) toIsoString(
|
||||
notification.from.textString
|
||||
) else ""
|
||||
val recipient = Recipient.from(context, fromExternal(context, fromString), false)
|
||||
return get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
|
||||
}
|
||||
|
||||
private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.rawQuery(
|
||||
@ -273,10 +235,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
}
|
||||
|
||||
fun getMessages(idsAsString: String): Cursor {
|
||||
return rawQuery(idsAsString, null)
|
||||
}
|
||||
|
||||
fun getMessage(messageId: Long): Cursor {
|
||||
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
|
||||
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId))
|
||||
@ -312,48 +270,40 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsPendingInsecureSmsFallback(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
private fun markAs(
|
||||
messageId: Long,
|
||||
baseType: Long,
|
||||
threadId: Long = getThreadIdForMessage(messageId)
|
||||
) {
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
baseType,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
override fun markAsSyncing(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SYNCING_TYPE)
|
||||
}
|
||||
override fun markAsResyncing(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_RESYNCING_TYPE)
|
||||
}
|
||||
override fun markAsSyncFailed(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE)
|
||||
}
|
||||
|
||||
fun markAsSending(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENDING_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE)
|
||||
}
|
||||
|
||||
fun markAsSentFailed(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE)
|
||||
}
|
||||
|
||||
override fun markAsSent(messageId: Long, secure: Boolean) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0)
|
||||
}
|
||||
|
||||
override fun markUnidentified(messageId: Long, unidentified: Boolean) {
|
||||
@ -363,29 +313,25 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
|
||||
}
|
||||
|
||||
override fun markAsDeleted(messageId: Long, read: Boolean) {
|
||||
override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(READ, 1)
|
||||
contentValues.put(BODY, "")
|
||||
contentValues.put(HAS_MENTION, 0)
|
||||
database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
|
||||
val attachmentDatabase = get(context).attachmentDatabase()
|
||||
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
if (!read) {
|
||||
get(context).threadDatabase().decrementUnread(threadId, 1)
|
||||
val mentionChange = if (hasMention) { 1 } else { 0 }
|
||||
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
|
||||
}
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_DELETED_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
|
||||
}
|
||||
|
||||
override fun markExpireStarted(messageId: Long) {
|
||||
markExpireStarted(messageId, System.currentTimeMillis())
|
||||
markExpireStarted(messageId, SnodeAPI.nowWithOffset)
|
||||
}
|
||||
|
||||
override fun markExpireStarted(messageId: Long, startedTimestamp: Long) {
|
||||
@ -411,10 +357,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
}
|
||||
|
||||
fun setAllMessagesRead(): List<MarkedMessageInfo> {
|
||||
return setMessagesRead(READ + " = 0", null)
|
||||
}
|
||||
|
||||
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val result: MutableList<MarkedMessageInfo> = LinkedList()
|
||||
@ -423,7 +365,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
try {
|
||||
cursor = database.query(
|
||||
TABLE_NAME,
|
||||
arrayOf<String>(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED),
|
||||
arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED),
|
||||
where,
|
||||
arguments,
|
||||
null,
|
||||
@ -669,6 +611,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
contentValues.put(EXPIRES_IN, retrieved.expiresIn)
|
||||
contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt)
|
||||
contentValues.put(UNIDENTIFIED, retrieved.isUnidentified)
|
||||
contentValues.put(HAS_MENTION, retrieved.hasMention())
|
||||
contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse)
|
||||
if (!contentValues.containsKey(DATE_SENT)) {
|
||||
contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED))
|
||||
@ -700,7 +643,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
|
||||
if (runIncrement) {
|
||||
get(context).threadDatabase().incrementUnread(threadId, 1)
|
||||
val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 }
|
||||
get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount)
|
||||
}
|
||||
if (runThreadUpdate) {
|
||||
get(context).threadDatabase().update(threadId, true)
|
||||
@ -805,7 +749,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
// In open groups messages should be sorted by their server timestamp
|
||||
var receivedTimestamp = serverTimestamp
|
||||
if (serverTimestamp == 0L) {
|
||||
receivedTimestamp = System.currentTimeMillis()
|
||||
receivedTimestamp = SnodeAPI.nowWithOffset
|
||||
}
|
||||
contentValues.put(DATE_RECEIVED, receivedTimestamp)
|
||||
contentValues.put(SUBSCRIPTION_ID, message.subscriptionId)
|
||||
@ -1007,6 +951,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return threadDeleted
|
||||
}
|
||||
|
||||
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
|
||||
val attachmentDatabase = get(context).attachmentDatabase()
|
||||
val groupReceiptDatabase = get(context).groupReceiptDatabase()
|
||||
|
||||
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
|
||||
groupReceiptDatabase.deleteRowsForMessages(messageIds)
|
||||
|
||||
val database = databaseHelper.writableDatabase
|
||||
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
|
||||
|
||||
val threadDeleted = get(context).threadDatabase().update(threadId, false)
|
||||
notifyConversationListeners(threadId)
|
||||
notifyStickerListeners()
|
||||
notifyStickerPackListeners()
|
||||
return threadDeleted
|
||||
}
|
||||
|
||||
override fun updateThreadId(fromId: Long, toId: Long) {
|
||||
val contentValues = ContentValues(1)
|
||||
contentValues.put(THREAD_ID, toId)
|
||||
@ -1274,7 +1235,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val slideDeck = SlideDeck(context, message!!.attachments)
|
||||
return MediaMmsMessageRecord(
|
||||
id, message.recipient, message.recipient,
|
||||
1, System.currentTimeMillis(), System.currentTimeMillis(),
|
||||
1, SnodeAPI.nowWithOffset, SnodeAPI.nowWithOffset,
|
||||
0, threadId, message.body,
|
||||
slideDeck, slideDeck.slides.size,
|
||||
if (message.isSecure) MmsSmsColumns.Types.getOutgoingEncryptedMessageType() else MmsSmsColumns.Types.getOutgoingSmsMessageType(),
|
||||
@ -1282,7 +1243,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
LinkedList(),
|
||||
message.subscriptionId,
|
||||
message.expiresIn,
|
||||
System.currentTimeMillis(), 0,
|
||||
SnodeAPI.nowWithOffset, 0,
|
||||
if (message.outgoingQuote != null) Quote(
|
||||
message.outgoingQuote!!.id,
|
||||
message.outgoingQuote!!.author,
|
||||
@ -1290,7 +1251,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
message.outgoingQuote!!.missing,
|
||||
SlideDeck(context, message.outgoingQuote!!.attachments!!)
|
||||
) else null,
|
||||
message.sharedContacts, message.linkPreviews, listOf(), false
|
||||
message.sharedContacts, message.linkPreviews, listOf(), false, false
|
||||
)
|
||||
}
|
||||
|
||||
@ -1334,6 +1295,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT))
|
||||
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
|
||||
val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1)
|
||||
if (!isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0
|
||||
}
|
||||
@ -1351,7 +1313,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
dateSent, dateReceived, deliveryReceiptCount, threadId,
|
||||
contentLocationBytes, messageSize, expiry, status,
|
||||
transactionIdBytes, mailbox, slideDeck,
|
||||
readReceiptCount
|
||||
readReceiptCount, hasMention
|
||||
)
|
||||
}
|
||||
|
||||
@ -1385,6 +1347,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
|
||||
val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
|
||||
val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1
|
||||
val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1
|
||||
if (!isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0
|
||||
}
|
||||
@ -1394,25 +1357,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val attachments = get(context).attachmentDatabase().getAttachment(
|
||||
cursor
|
||||
)
|
||||
val contacts: List<Contact?> = getSharedContacts(
|
||||
cursor, attachments
|
||||
)
|
||||
val contactAttachments =
|
||||
contacts.map { obj: Contact? -> obj!!.avatarAttachment }
|
||||
.filter { a: Attachment? -> a != null }
|
||||
.toSet()
|
||||
val previews: List<LinkPreview?> = getLinkPreviews(
|
||||
cursor, attachments
|
||||
)
|
||||
val previewAttachments =
|
||||
previews.filter { lp: LinkPreview? -> lp!!.getThumbnail().isPresent }
|
||||
.map { lp: LinkPreview? -> lp!!.getThumbnail().get() }
|
||||
.toSet()
|
||||
val contacts: List<Contact?> = getSharedContacts(cursor, attachments)
|
||||
val contactAttachments: Set<Attachment?> =
|
||||
contacts.mapNotNull { it?.avatarAttachment }.toSet()
|
||||
val previews: List<LinkPreview?> = getLinkPreviews(cursor, attachments)
|
||||
val previewAttachments: Set<Attachment?> =
|
||||
previews.mapNotNull { it?.getThumbnail()?.orNull() }.toSet()
|
||||
val slideDeck = getSlideDeck(
|
||||
Stream.of(attachments)
|
||||
.filterNot { o: DatabaseAttachment? -> contactAttachments.contains(o) }
|
||||
.filterNot { o: DatabaseAttachment? -> previewAttachments.contains(o) }
|
||||
.toList()
|
||||
attachments
|
||||
.filterNot { o: DatabaseAttachment? -> o in contactAttachments }
|
||||
.filterNot { o: DatabaseAttachment? -> o in previewAttachments }
|
||||
)
|
||||
val quote = getQuote(cursor)
|
||||
val reactions = get(context).reactionDatabase().getReactions(cursor)
|
||||
@ -1421,7 +1375,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
|
||||
threadId, body, slideDeck!!, partCount, box, mismatches,
|
||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||
readReceiptCount, quote, contacts, previews, reactions, unidentified
|
||||
readReceiptCount, quote, contacts, previews, reactions, unidentified, hasMention
|
||||
)
|
||||
}
|
||||
|
||||
@ -1470,11 +1424,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor)
|
||||
val quoteText = retrievedQuote?.body
|
||||
val quoteMissing = retrievedQuote == null
|
||||
val attachments = get(context).attachmentDatabase().getAttachment(cursor)
|
||||
val quoteAttachments: List<Attachment?>? =
|
||||
Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote }
|
||||
val quoteDeck = (
|
||||
(retrievedQuote as? MmsMessageRecord)?.slideDeck ?:
|
||||
Stream.of(get(context).attachmentDatabase().getAttachment(cursor))
|
||||
.filter { obj: DatabaseAttachment? -> obj!!.isQuote }
|
||||
.toList()
|
||||
val quoteDeck = SlideDeck(context, quoteAttachments!!)
|
||||
.let { SlideDeck(context, it) }
|
||||
)
|
||||
return Quote(
|
||||
quoteId,
|
||||
fromExternal(context, quoteAuthor),
|
||||
@ -1574,6 +1530,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
SHARED_CONTACTS,
|
||||
LINK_PREVIEWS,
|
||||
UNIDENTIFIED,
|
||||
HAS_MENTION,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
|
||||
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
|
||||
@ -1614,5 +1571,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
const val CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;"
|
||||
const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;"
|
||||
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
|
||||
const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,8 @@ public interface MmsSmsColumns {
|
||||
public static final String REACTIONS_UNREAD = "reactions_unread";
|
||||
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
|
||||
|
||||
public static final String HAS_MENTION = "has_mention";
|
||||
|
||||
public static class Types {
|
||||
protected static final long TOTAL_MASK = 0xFFFFFFFF;
|
||||
|
||||
@ -45,8 +47,13 @@ public interface MmsSmsColumns {
|
||||
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
|
||||
public static final long BASE_DRAFT_TYPE = 27;
|
||||
protected static final long BASE_DELETED_TYPE = 28;
|
||||
protected static final long BASE_SYNCING_TYPE = 29;
|
||||
protected static final long BASE_RESYNCING_TYPE = 30;
|
||||
protected static final long BASE_SYNC_FAILED_TYPE = 31;
|
||||
|
||||
protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE,
|
||||
BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE,
|
||||
BASE_SYNC_FAILED_TYPE,
|
||||
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
|
||||
BASE_PENDING_SECURE_SMS_FALLBACK,
|
||||
BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
@ -107,6 +114,18 @@ public interface MmsSmsColumns {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isResyncingType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_RESYNCING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSyncingType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SYNCING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSyncFailedMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SYNC_FAILED_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isFailedMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
|
||||
}
|
||||
|
@ -22,8 +22,8 @@ import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteQueryBuilder;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Util;
|
||||
@ -75,7 +75,9 @@ public class MmsSmsDatabase extends Database {
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS,
|
||||
ReactionDatabase.REACTION_JSON_ALIAS};
|
||||
ReactionDatabase.REACTION_JSON_ALIAS,
|
||||
MmsSmsColumns.HAS_MENTION
|
||||
};
|
||||
|
||||
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
@ -186,7 +188,7 @@ public class MmsSmsDatabase extends Database {
|
||||
}
|
||||
|
||||
public Cursor getConversationSnippet(long threadId) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
return queryTables(PROJECTION, selection, order, null);
|
||||
@ -203,7 +205,7 @@ public class MmsSmsDatabase extends Database {
|
||||
}
|
||||
|
||||
public Cursor getUnread() {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC";
|
||||
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
|
||||
|
||||
return queryTables(PROJECTION, selection, order, null);
|
||||
@ -238,7 +240,7 @@ public class MmsSmsDatabase extends Database {
|
||||
}
|
||||
|
||||
public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull Address address) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
|
||||
@ -337,7 +339,9 @@ public class MmsSmsDatabase extends Database {
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
MmsDatabase.LINK_PREVIEWS,
|
||||
MmsSmsColumns.HAS_MENTION
|
||||
};
|
||||
|
||||
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
||||
@ -364,7 +368,9 @@ public class MmsSmsDatabase extends Database {
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
MmsDatabase.LINK_PREVIEWS,
|
||||
MmsSmsColumns.HAS_MENTION
|
||||
};
|
||||
|
||||
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
|
||||
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
|
||||
@ -408,6 +414,7 @@ public class MmsSmsDatabase extends Database {
|
||||
mmsColumnsPresent.add(MmsDatabase.STATUS);
|
||||
mmsColumnsPresent.add(MmsDatabase.UNIDENTIFIED);
|
||||
mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION);
|
||||
|
||||
mmsColumnsPresent.add(AttachmentDatabase.ROW_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID);
|
||||
@ -470,6 +477,7 @@ public class MmsSmsDatabase extends Database {
|
||||
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
|
||||
smsColumnsPresent.add(SmsDatabase.STATUS);
|
||||
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
|
||||
smsColumnsPresent.add(MmsSmsColumns.HAS_MENTION);
|
||||
smsColumnsPresent.add(ReactionDatabase.ROW_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID);
|
||||
smsColumnsPresent.add(ReactionDatabase.IS_MMS);
|
||||
|
@ -6,7 +6,7 @@ import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.session.libsignal.utilities.Base64;
|
||||
|
@ -48,6 +48,14 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXS = arrayOf(
|
||||
"CREATE INDEX IF NOT EXISTS reaction_message_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS reaction_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.IS_MMS + ");",
|
||||
"CREATE INDEX IF NOT EXISTS reaction_message_id_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ", " + ReactionDatabase.IS_MMS + ");",
|
||||
"CREATE INDEX IF NOT EXISTS reaction_sort_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.SORT_ID + ");",
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val CREATE_REACTION_TRIGGERS = arrayOf(
|
||||
"""
|
||||
|
@ -11,7 +11,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.MaterialColor;
|
||||
@ -284,7 +284,7 @@ public class RecipientDatabase extends Database {
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setBlocked(@NonNull List<Recipient> recipients, boolean blocked) {
|
||||
public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
|
||||
SQLiteDatabase db = getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
try {
|
||||
|
@ -1,13 +1,13 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.Cursor;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.database.getStringOrNull
|
||||
import net.sqlcipher.Cursor
|
||||
import android.database.Cursor
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
@ -75,21 +75,6 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
||||
}
|
||||
|
||||
fun contactFromCursor(cursor: Cursor): Contact {
|
||||
val sessionID = cursor.getString(sessionID)
|
||||
val contact = Contact(sessionID)
|
||||
contact.name = cursor.getStringOrNull(name)
|
||||
contact.nickname = cursor.getStringOrNull(nickname)
|
||||
contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
|
||||
contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
|
||||
cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
|
||||
contact.profilePictureEncryptionKey = Base64.decode(it)
|
||||
}
|
||||
contact.threadID = cursor.getLong(threadID)
|
||||
contact.isTrusted = cursor.getInt(isTrusted) != 0
|
||||
return contact
|
||||
}
|
||||
|
||||
fun contactFromCursor(cursor: android.database.Cursor): Contact {
|
||||
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
|
||||
val contact = Contact(sessionID)
|
||||
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
|
||||
|
@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import net.sqlcipher.Cursor
|
||||
import android.database.Cursor
|
||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
|
||||
@ -46,7 +46,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
||||
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
|
||||
}
|
||||
|
||||
fun getAllPendingJobs(type: String): Map<String, Job?> {
|
||||
fun getAllJobs(type: String): Map<String, Job?> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor ->
|
||||
val jobID = cursor.getString(jobID)
|
||||
@ -83,11 +83,11 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupAvatarDownloadJob(server: String, room: String): GroupAvatarDownloadJob? {
|
||||
fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(GroupAvatarDownloadJob.KEY)) {
|
||||
jobFromCursor(it) as GroupAvatarDownloadJob?
|
||||
}.filterNotNull().find { it.server == server && it.room == room }
|
||||
}.filterNotNull().find { it.server == server && it.room == room && (imageId == null || it.imageId == imageId) }
|
||||
}
|
||||
|
||||
fun cancelPendingMessageSendJobs(threadID: Long) {
|
||||
|
@ -28,13 +28,15 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteStatement;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.session.libsession.messaging.calls.CallMessageType;
|
||||
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage;
|
||||
import org.session.libsession.messaging.messages.signal.IncomingTextMessage;
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatch;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatchList;
|
||||
@ -53,6 +55,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@ -104,7 +107,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
PROTOCOL, READ, STATUS, TYPE,
|
||||
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
|
||||
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
|
||||
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED,
|
||||
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, HAS_MENTION,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
|
||||
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
|
||||
@ -122,6 +125,9 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
public static String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + REACTIONS_UNREAD + " INTEGER DEFAULT 0;";
|
||||
|
||||
public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;";
|
||||
|
||||
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache();
|
||||
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
|
||||
|
||||
@ -197,6 +203,21 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsSyncing(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNCING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsResyncing(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_RESYNCING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsSyncFailed(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNC_FAILED_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markUnidentified(long id, boolean unidentified) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
@ -207,20 +228,23 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsDeleted(long messageId, boolean read) {
|
||||
public void markAsDeleted(long messageId, boolean read, boolean hasMention) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(BODY, "");
|
||||
contentValues.put(HAS_MENTION, 0);
|
||||
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)});
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
if (!read) { DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1); }
|
||||
if (!read) {
|
||||
DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1, (hasMention ? 1 : 0));
|
||||
}
|
||||
updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markExpireStarted(long id) {
|
||||
markExpireStarted(id, System.currentTimeMillis());
|
||||
markExpireStarted(id, SnodeAPI.getNowWithOffset());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -433,6 +457,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
values.put(EXPIRES_IN, message.getExpiresIn());
|
||||
values.put(EXPIRE_STARTED, message.getExpireStartedAt());
|
||||
values.put(UNIDENTIFIED, message.isUnidentified());
|
||||
values.put(HAS_MENTION, message.hasMention());
|
||||
|
||||
if (!TextUtils.isEmpty(message.getPseudoSubject()))
|
||||
values.put(SUBJECT, message.getPseudoSubject());
|
||||
@ -451,7 +476,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
long messageId = db.insert(TABLE_NAME, null, values);
|
||||
|
||||
if (unread && runIncrement) {
|
||||
DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1);
|
||||
DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0));
|
||||
}
|
||||
|
||||
if (runThreadUpdate) {
|
||||
@ -529,7 +554,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
contentValues.put(THREAD_ID, threadId);
|
||||
contentValues.put(BODY, message.getMessageBody());
|
||||
contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset());
|
||||
contentValues.put(DATE_SENT, message.getSentTimestampMillis());
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(TYPE, type);
|
||||
@ -610,6 +635,30 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteMessages(long[] messageIds, long threadId) {
|
||||
String[] argsArray = new String[messageIds.length];
|
||||
String[] argValues = new String[messageIds.length];
|
||||
Arrays.fill(argsArray, "?");
|
||||
|
||||
for (int i = 0; i < messageIds.length; i++) {
|
||||
argValues[i] = (messageIds[i] + "");
|
||||
}
|
||||
|
||||
String combinedMessageIdArgss = StringUtils.join(messageIds, ',');
|
||||
String combinedMessageIds = StringUtils.join(messageIds, ',');
|
||||
Log.i("MessageDatabase", "Deleting: " + combinedMessageIds);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(
|
||||
TABLE_NAME,
|
||||
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
|
||||
argValues
|
||||
);
|
||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateThreadId(long fromId, long toId) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
@ -752,11 +801,11 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
public MessageRecord getCurrent() {
|
||||
return new SmsMessageRecord(id, message.getMessageBody(),
|
||||
message.getRecipient(), message.getRecipient(),
|
||||
System.currentTimeMillis(), System.currentTimeMillis(),
|
||||
SnodeAPI.getNowWithOffset(), SnodeAPI.getNowWithOffset(),
|
||||
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
|
||||
threadId, 0, new LinkedList<IdentityKeyMismatch>(),
|
||||
message.getExpiresIn(),
|
||||
System.currentTimeMillis(), 0, false, Collections.emptyList());
|
||||
SnodeAPI.getNowWithOffset(), 0, false, Collections.emptyList(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -797,6 +846,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED));
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY));
|
||||
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1;
|
||||
boolean hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.HAS_MENTION)) == 1;
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
@ -810,7 +860,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
recipient,
|
||||
dateSent, dateReceived, deliveryReceiptCount, type,
|
||||
threadId, status, mismatches,
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention);
|
||||
}
|
||||
|
||||
private List<IdentityKeyMismatch> getMismatches(String document) {
|
||||
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.BlindedIdMapping
|
||||
import org.session.libsession.messaging.calls.CallMessageType
|
||||
@ -24,10 +25,12 @@ import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessag
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
|
||||
import org.session.libsession.messaging.messages.visible.Attachment
|
||||
import org.session.libsession.messaging.messages.visible.Profile
|
||||
import org.session.libsession.messaging.messages.visible.Reaction
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.GroupMember
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
|
||||
@ -52,15 +55,15 @@ import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.KeyHelper
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol {
|
||||
|
||||
@ -72,25 +75,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair()
|
||||
}
|
||||
|
||||
override fun getUserDisplayName(): String? {
|
||||
return TextSecurePreferences.getProfileName(context)
|
||||
override fun getUserProfile(): Profile {
|
||||
val displayName = TextSecurePreferences.getProfileName(context)!!
|
||||
val profileKey = ProfileKeyUtil.getProfileKey(context)
|
||||
val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context)
|
||||
return Profile(displayName, profileKey, profilePictureUrl)
|
||||
}
|
||||
|
||||
override fun getUserProfileKey(): ByteArray? {
|
||||
return ProfileKeyUtil.getProfileKey(context)
|
||||
}
|
||||
|
||||
override fun getUserProfilePictureURL(): String? {
|
||||
return TextSecurePreferences.getProfilePictureURL(context)
|
||||
}
|
||||
|
||||
override fun setUserProfilePictureURL(newValue: String) {
|
||||
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
|
||||
Recipient.from(context, it, false)
|
||||
}
|
||||
TextSecurePreferences.setProfilePictureURL(context, newValue)
|
||||
RetrieveProfileAvatarJob(ourRecipient, newValue)
|
||||
ApplicationContext.getInstance(context).jobManager.add(RetrieveProfileAvatarJob(ourRecipient, newValue))
|
||||
override fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) {
|
||||
val database = DatabaseComponent.get(context).recipientDatabase()
|
||||
database.setProfileAvatar(recipient, profileAvatar)
|
||||
}
|
||||
|
||||
override fun getOrGenerateRegistrationID(): Int {
|
||||
@ -118,9 +112,9 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
threadDb.setRead(threadId, updateLastSeen)
|
||||
}
|
||||
|
||||
override fun incrementUnread(threadId: Long, amount: Int) {
|
||||
override fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) {
|
||||
val threadDb = DatabaseComponent.get(context).threadDatabase()
|
||||
threadDb.incrementUnread(threadId, amount)
|
||||
threadDb.incrementUnread(threadId, amount, unreadMentionAmount)
|
||||
}
|
||||
|
||||
override fun updateThread(threadId: Long, unarchive: Boolean) {
|
||||
@ -239,7 +233,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
|
||||
override fun getAllPendingJobs(type: String): Map<String, Job?> {
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getAllPendingJobs(type)
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type)
|
||||
}
|
||||
|
||||
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {
|
||||
@ -254,8 +248,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getMessageReceiveJob(messageReceiveJobID)
|
||||
}
|
||||
|
||||
override fun getGroupAvatarDownloadJob(server: String, room: String): GroupAvatarDownloadJob? {
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room)
|
||||
override fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? {
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId)
|
||||
}
|
||||
|
||||
override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) {
|
||||
@ -352,6 +346,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue)
|
||||
}
|
||||
|
||||
override fun removeProfilePicture(groupID: String) {
|
||||
DatabaseComponent.get(context).groupDatabase().removeProfilePicture(groupID)
|
||||
}
|
||||
|
||||
override fun hasDownloadedProfilePicture(groupID: String): Boolean {
|
||||
return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID)
|
||||
}
|
||||
|
||||
override fun getReceivedMessageTimestamps(): Set<Long> {
|
||||
return SessionMetaProtocol.getTimestamps()
|
||||
}
|
||||
@ -397,6 +399,22 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSyncing(timestamp: Long, author: String) {
|
||||
DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) }
|
||||
}
|
||||
|
||||
private fun getMmsDatabaseElseSms(isMms: Boolean) =
|
||||
if (isMms) DatabaseComponent.get(context).mmsDatabase()
|
||||
else DatabaseComponent.get(context).smsDatabase()
|
||||
|
||||
override fun markAsResyncing(timestamp: Long, author: String) {
|
||||
DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) }
|
||||
}
|
||||
|
||||
override fun markAsSending(timestamp: Long, author: String) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
@ -422,7 +440,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun setErrorMessage(timestamp: Long, author: String, error: Exception) {
|
||||
override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
if (messageRecord.isMms) {
|
||||
@ -445,6 +463,26 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
|
||||
database.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) }
|
||||
|
||||
if (error.localizedMessage != null) {
|
||||
val message: String
|
||||
if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) {
|
||||
message = "429: Rate limited."
|
||||
} else {
|
||||
message = error.localizedMessage!!
|
||||
}
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message)
|
||||
} else {
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearErrorMessage(messageID: Long) {
|
||||
val db = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
db.clearErrorMessage(messageID)
|
||||
@ -494,7 +532,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
val expirationConfig = getExpirationConfiguration(threadId)
|
||||
val expiresInMillis = (expirationConfig?.durationSeconds ?: 0) * 100L
|
||||
val expireStartedAt = if (expirationConfig?.expirationType == ExpirationType.DELETE_AFTER_SEND) sentTimestamp else 0
|
||||
val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true)
|
||||
val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true, false)
|
||||
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
|
||||
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
|
||||
val smsDB = DatabaseComponent.get(context).smsDatabase()
|
||||
@ -601,8 +639,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return DatabaseComponent.get(context).groupDatabase().allGroups
|
||||
}
|
||||
|
||||
override fun addOpenGroup(urlAsString: String) {
|
||||
OpenGroupManager.addOpenGroup(urlAsString, context)
|
||||
override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
|
||||
return OpenGroupManager.addOpenGroup(urlAsString, context)
|
||||
}
|
||||
|
||||
override fun onOpenGroupAdded(server: String) {
|
||||
@ -714,8 +752,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
recipientDatabase.setApproved(recipient, true)
|
||||
threadDatabase.setHasSent(threadId, true)
|
||||
}
|
||||
if (contact.isBlocked == true) {
|
||||
recipientDatabase.setBlocked(recipient, true)
|
||||
|
||||
val contactIsBlocked: Boolean? = contact.isBlocked
|
||||
if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) {
|
||||
recipientDatabase.setBlocked(recipient, contactIsBlocked)
|
||||
threadDatabase.deleteConversation(threadId)
|
||||
}
|
||||
}
|
||||
@ -744,6 +784,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return mmsSmsDb.getConversationCount(threadID)
|
||||
}
|
||||
|
||||
override fun deleteConversation(threadId: Long) {
|
||||
val threadDB = DatabaseComponent.get(context).threadDatabase()
|
||||
threadDB.deleteConversation(threadId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
|
||||
@ -773,6 +818,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
@ -805,6 +851,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
val smsDb = DatabaseComponent.get(context).smsDatabase()
|
||||
val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
|
||||
val threadId = threadDB.getOrCreateThreadIdFor(sender)
|
||||
val profile = response.profile
|
||||
if (profile != null) {
|
||||
val profileManager = SSKEnvironment.shared.profileManager
|
||||
val name = profile.displayName!!
|
||||
if (name.isNotEmpty()) {
|
||||
profileManager.setName(context, sender, name)
|
||||
}
|
||||
val newProfileKey = profile.profileKey
|
||||
|
||||
val needsProfilePicture = !AvatarHelper.avatarFileExists(context, sender.address)
|
||||
val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true
|
||||
val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey))
|
||||
|
||||
if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) {
|
||||
profileManager.setProfileKey(context, sender, newProfileKey!!)
|
||||
profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN)
|
||||
profileManager.setProfilePictureURL(context, sender, profile.profilePictureURL!!)
|
||||
}
|
||||
}
|
||||
threadDB.setHasSent(threadId, true)
|
||||
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
|
||||
val mappings = mutableMapOf<String, BlindedIdMapping>()
|
||||
@ -855,6 +920,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
@ -1010,7 +1076,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
|
||||
}
|
||||
|
||||
override fun unblock(toUnblock: List<Recipient>) {
|
||||
override fun unblock(toUnblock: Iterable<Recipient>) {
|
||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||
recipientDb.setBlocked(toUnblock, false)
|
||||
}
|
||||
|
@ -32,9 +32,10 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Contact;
|
||||
import org.session.libsession.utilities.DelimiterUtil;
|
||||
@ -57,7 +58,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
@ -87,6 +87,7 @@ public class ThreadDatabase extends Database {
|
||||
private static final String SNIPPET_CHARSET = "snippet_cs";
|
||||
public static final String READ = "read";
|
||||
public static final String UNREAD_COUNT = "unread_count";
|
||||
public static final String UNREAD_MENTION_COUNT = "unread_mention_count";
|
||||
public static final String TYPE = "type";
|
||||
private static final String ERROR = "error";
|
||||
public static final String SNIPPET_TYPE = "snippet_type";
|
||||
@ -117,7 +118,7 @@ public class ThreadDatabase extends Database {
|
||||
};
|
||||
|
||||
private static final String[] THREAD_PROJECTION = {
|
||||
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE,
|
||||
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, TYPE, ERROR, SNIPPET_TYPE,
|
||||
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED
|
||||
};
|
||||
|
||||
@ -135,13 +136,18 @@ public class ThreadDatabase extends Database {
|
||||
"ADD COLUMN " + IS_PINNED + " INTEGER DEFAULT 0;";
|
||||
}
|
||||
|
||||
public static String getUnreadMentionCountCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;";
|
||||
}
|
||||
|
||||
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
private long createThreadForRecipient(Address address, boolean group, int distributionType) {
|
||||
ContentValues contentValues = new ContentValues(4);
|
||||
long date = System.currentTimeMillis();
|
||||
long date = SnodeAPI.getNowWithOffset();
|
||||
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
@ -293,9 +299,10 @@ public class ThreadDatabase extends Database {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(UNREAD_COUNT, 0);
|
||||
contentValues.put(UNREAD_MENTION_COUNT, 0);
|
||||
|
||||
if (lastSeen) {
|
||||
contentValues.put(LAST_SEEN, System.currentTimeMillis());
|
||||
contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset());
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
@ -312,20 +319,28 @@ public class ThreadDatabase extends Database {
|
||||
}};
|
||||
}
|
||||
|
||||
public void incrementUnread(long threadId, int amount) {
|
||||
public void incrementUnread(long threadId, int amount, int unreadMentionAmount) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
|
||||
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ? WHERE " + ID + " = ?",
|
||||
new String[] {String.valueOf(amount),
|
||||
String.valueOf(threadId)});
|
||||
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " +
|
||||
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " + ? WHERE " + ID + " = ?",
|
||||
new String[] {
|
||||
String.valueOf(amount),
|
||||
String.valueOf(unreadMentionAmount),
|
||||
String.valueOf(threadId)
|
||||
});
|
||||
}
|
||||
|
||||
public void decrementUnread(long threadId, int amount) {
|
||||
public void decrementUnread(long threadId, int amount, int unreadMentionAmount) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
|
||||
UNREAD_COUNT + " = " + UNREAD_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0",
|
||||
new String[] {String.valueOf(amount),
|
||||
String.valueOf(threadId)});
|
||||
UNREAD_COUNT + " = " + UNREAD_COUNT + " - ?, " +
|
||||
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0",
|
||||
new String[] {
|
||||
String.valueOf(amount),
|
||||
String.valueOf(unreadMentionAmount),
|
||||
String.valueOf(threadId)
|
||||
});
|
||||
}
|
||||
|
||||
public void setDistributionType(long threadId, int distributionType) {
|
||||
@ -506,7 +521,7 @@ public class ThreadDatabase extends Database {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
if (timestamp == -1) {
|
||||
contentValues.put(LAST_SEEN, System.currentTimeMillis());
|
||||
contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset());
|
||||
} else {
|
||||
contentValues.put(LAST_SEEN, timestamp);
|
||||
}
|
||||
@ -785,77 +800,6 @@ public class ThreadDatabase extends Database {
|
||||
return query;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getHttpOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.HTTP_PREFIX+OpenGroupMigrator.OPEN_GET_SESSION_TRAILING_DOT_ENCODED +"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getLegacyOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.LEGACY_GROUP_ENCODED_ID+"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ThreadRecord> getHttpsOxenOpenGroups() {
|
||||
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
|
||||
String selection = OpenGroupMigrator.NEW_GROUP_ENCODED_ID+"%";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(where, 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{selection});
|
||||
if (cursor == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ThreadRecord> threads = new ArrayList<>();
|
||||
try {
|
||||
Reader reader = readerFor(cursor);
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
threads.add(record);
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
public void migrateEncodedGroup(long threadId, @NotNull String newEncodedGroupId) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(ADDRESS, newEncodedGroupId);
|
||||
@ -911,6 +855,7 @@ public class ThreadDatabase extends Database {
|
||||
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE));
|
||||
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
|
||||
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
|
||||
int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT));
|
||||
long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE));
|
||||
boolean archived = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.ARCHIVED)) != 0;
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS));
|
||||
@ -926,7 +871,7 @@ public class ThreadDatabase extends Database {
|
||||
}
|
||||
|
||||
return new ThreadRecord(body, snippetUri, recipient, date, count,
|
||||
unreadCount, threadId, deliveryReceiptCount, status, type,
|
||||
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
|
||||
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,18 @@
|
||||
package org.thoughtcrime.securesms.database.helpers;
|
||||
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteDatabaseHook;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
import net.zetetic.database.sqlcipher.SQLiteConnection;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabaseHook;
|
||||
import net.zetetic.database.sqlcipher.SQLiteException;
|
||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
|
||||
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
@ -21,7 +25,6 @@ import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiMessageDatabase;
|
||||
@ -36,6 +39,11 @@ import org.thoughtcrime.securesms.database.SessionContactDatabase;
|
||||
import org.thoughtcrime.securesms.database.SessionJobDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
|
||||
@ -77,40 +85,191 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
private static final int lokiV37 = 58;
|
||||
private static final int lokiV38 = 59;
|
||||
private static final int lokiV39 = 60;
|
||||
private static final int lokiV40 = 61;
|
||||
|
||||
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||
private static final int DATABASE_VERSION = lokiV39;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
private static final int DATABASE_VERSION = lokiV40;
|
||||
private static final int MIN_DATABASE_VERSION = lokiV7;
|
||||
private static final String CIPHER3_DATABASE_NAME = "signal.db";
|
||||
public static final String DATABASE_NAME = "signal_v4.db";
|
||||
|
||||
private final Context context;
|
||||
private final DatabaseSecret databaseSecret;
|
||||
|
||||
public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) {
|
||||
super(context, DATABASE_NAME, null, DATABASE_VERSION, new SQLiteDatabaseHook() {
|
||||
@Override
|
||||
public void preKey(SQLiteDatabase db) {
|
||||
db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;");
|
||||
db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postKey(SQLiteDatabase db) {
|
||||
db.rawExecSQL("PRAGMA kdf_iter = '1';");
|
||||
db.rawExecSQL("PRAGMA cipher_page_size = 4096;");
|
||||
// if not vacuumed in a while, perform that operation
|
||||
long currentTime = System.currentTimeMillis();
|
||||
// 7 days
|
||||
if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) {
|
||||
db.rawExecSQL("VACUUM;");
|
||||
TextSecurePreferences.setLastVacuumNow(context);
|
||||
super(
|
||||
context,
|
||||
DATABASE_NAME,
|
||||
databaseSecret.asString(),
|
||||
null,
|
||||
DATABASE_VERSION,
|
||||
MIN_DATABASE_VERSION,
|
||||
null,
|
||||
new SQLiteDatabaseHook() {
|
||||
@Override
|
||||
public void preKey(SQLiteConnection connection) {
|
||||
SQLCipherOpenHelper.applySQLCipherPragmas(connection, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
public void postKey(SQLiteConnection connection) {
|
||||
SQLCipherOpenHelper.applySQLCipherPragmas(connection, true);
|
||||
|
||||
// if not vacuumed in a while, perform that operation
|
||||
long currentTime = System.currentTimeMillis();
|
||||
// 7 days
|
||||
if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) {
|
||||
connection.execute("VACUUM;", null, null);
|
||||
TextSecurePreferences.setLastVacuumNow(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Note: Now that we support concurrent database reads the migrations are actually non-blocking
|
||||
// because of this we need to initially open the database with writeAheadLogging (WAL mode) disabled
|
||||
// and enable it once the database officially opens it's connection (which will cause it to re-connect
|
||||
// in WAL mode) - this is a little inefficient but will prevent SQL-related errors/crashes due to
|
||||
// incomplete migrations
|
||||
false
|
||||
);
|
||||
|
||||
this.context = context.getApplicationContext();
|
||||
this.databaseSecret = databaseSecret;
|
||||
}
|
||||
|
||||
private static void applySQLCipherPragmas(SQLiteConnection connection, boolean useSQLCipher4) {
|
||||
if (useSQLCipher4) {
|
||||
connection.execute("PRAGMA kdf_iter = '256000';", null, null);
|
||||
}
|
||||
else {
|
||||
connection.execute("PRAGMA cipher_compatibility = 3;", null, null);
|
||||
connection.execute("PRAGMA kdf_iter = '1';", null, null);
|
||||
}
|
||||
|
||||
connection.execute("PRAGMA cipher_page_size = 4096;", null, null);
|
||||
}
|
||||
|
||||
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException {
|
||||
return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() {
|
||||
@Override
|
||||
public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
|
||||
|
||||
@Override
|
||||
public void postKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
|
||||
});
|
||||
}
|
||||
|
||||
public static void migrateSqlCipher3To4IfNeeded(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) throws Exception {
|
||||
String oldDbPath = context.getDatabasePath(CIPHER3_DATABASE_NAME).getPath();
|
||||
File oldDbFile = new File(oldDbPath);
|
||||
|
||||
// If the old SQLCipher3 database file doesn't exist then no need to do anything
|
||||
if (!oldDbFile.exists()) { return; }
|
||||
|
||||
// Define the location for the new database
|
||||
String newDbPath = context.getDatabasePath(DATABASE_NAME).getPath();
|
||||
File newDbFile = new File(newDbPath);
|
||||
|
||||
try {
|
||||
// If the new database file already exists then check if it's valid first, if it's in an
|
||||
// invalid state we should delete it and try to migrate again
|
||||
if (newDbFile.exists()) {
|
||||
// If the old database hasn't been modified since the new database was created, then we can
|
||||
// assume the user hasn't downgraded for some reason and made changes to the old database and
|
||||
// can remove the old database file (it won't be used anymore)
|
||||
if (oldDbFile.lastModified() <= newDbFile.lastModified()) {
|
||||
try {
|
||||
SQLiteDatabase newDb = SQLCipherOpenHelper.open(newDbPath, databaseSecret, true);
|
||||
int version = newDb.getVersion();
|
||||
newDb.close();
|
||||
|
||||
// Make sure the new database has it's version set correctly (if not then the migration didn't
|
||||
// fully succeed and the database will try to create all it's tables and immediately fail so
|
||||
// we will need to remove and remigrate)
|
||||
if (version > 0) {
|
||||
// TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past
|
||||
// //noinspection ResultOfMethodCallIgnored
|
||||
// oldDbFile.delete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
Log.i(TAG, "Failed to retrieve version from new database, assuming invalid and remigrating");
|
||||
}
|
||||
}
|
||||
|
||||
// If the old database does have newer changes then the new database could have stale/invalid
|
||||
// data and we should re-migrate to avoid losing any data or issues
|
||||
if (!newDbFile.delete()) {
|
||||
throw new Exception("Failed to remove invalid new database");
|
||||
}
|
||||
}
|
||||
|
||||
if (!newDbFile.createNewFile()) {
|
||||
throw new Exception("Failed to create new database");
|
||||
}
|
||||
|
||||
// Open the old database and extract it's version
|
||||
SQLiteDatabase oldDb = SQLCipherOpenHelper.open(oldDbPath, databaseSecret, false);
|
||||
int oldDbVersion = oldDb.getVersion();
|
||||
|
||||
// Export the old database to the new one (will have the default 'kdf_iter' and 'page_size' settings)
|
||||
oldDb.rawExecSQL(
|
||||
String.format("ATTACH DATABASE '%s' AS sqlcipher4 KEY '%s'", newDbPath, databaseSecret.asString())
|
||||
);
|
||||
Cursor cursor = oldDb.rawQuery("SELECT sqlcipher_export('sqlcipher4')");
|
||||
cursor.moveToLast();
|
||||
cursor.close();
|
||||
oldDb.rawExecSQL("DETACH DATABASE sqlcipher4");
|
||||
oldDb.close();
|
||||
|
||||
// Open the newly migrated database (to ensure it works) and set it's version so we don't try
|
||||
// to run any of our custom migrations
|
||||
SQLiteDatabase newDb = SQLCipherOpenHelper.open(newDbPath, databaseSecret, true);
|
||||
newDb.setVersion(oldDbVersion);
|
||||
newDb.close();
|
||||
|
||||
// TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past
|
||||
// Remove the old database file since it will no longer be used
|
||||
// //noinspection ResultOfMethodCallIgnored
|
||||
// oldDbFile.delete();
|
||||
}
|
||||
catch (Exception e) {
|
||||
Log.e(TAG, "Migration from SQLCipher3 to SQLCipher4 failed", e);
|
||||
|
||||
// If an exception was thrown then we should remove the new database file (it's probably invalid)
|
||||
if (!newDbFile.delete()) {
|
||||
Log.e(TAG, "Unable to delete invalid new database file");
|
||||
}
|
||||
|
||||
// Notify the user of the issue so they know they can downgrade until the issue is fixed
|
||||
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
|
||||
String channelId = context.getString(R.string.NotificationChannel_failures);
|
||||
|
||||
if (NotificationChannels.supported()) {
|
||||
NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH);
|
||||
channel.enableVibration(true);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(context.getResources().getColor(R.color.textsecure_primary))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentTitle(context.getString(R.string.ErrorNotifier_migration))
|
||||
.setContentText(context.getString(R.string.ErrorNotifier_migration_downgrade))
|
||||
.setAutoCancel(true);
|
||||
|
||||
if (!NotificationChannels.supported()) {
|
||||
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||
}
|
||||
|
||||
notificationManager.notify(5874, builder.build());
|
||||
|
||||
// Throw the error (app will crash but there is nothing else we can do unfortunately)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(SmsDatabase.CREATE_TABLE);
|
||||
@ -125,9 +284,6 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
for (String sql : SearchDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
for (String sql : JobDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOnionRequestPathTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSwarmTableCommand());
|
||||
@ -183,6 +339,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(LokiAPIDatabase.RESET_SEQ_NO); // probably not needed but consistent with all migrations
|
||||
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
|
||||
db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND);
|
||||
db.execSQL(ThreadDatabase.getUnreadMentionCountCommand());
|
||||
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND);
|
||||
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
@ -192,6 +351,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
executeStatements(db, DraftDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
|
||||
|
||||
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
|
||||
}
|
||||
@ -199,9 +359,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
@Override
|
||||
public void onConfigure(SQLiteDatabase db) {
|
||||
super.onConfigure(db);
|
||||
// Loki - Enable write ahead logging mode and increase the cache size.
|
||||
// This should be disabled if we ever run into serious race condition bugs.
|
||||
db.enableWriteAheadLogging();
|
||||
|
||||
db.execSQL("PRAGMA cache_size = 10000");
|
||||
}
|
||||
|
||||
@ -419,6 +577,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV39) {
|
||||
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV40) {
|
||||
db.execSQL(ThreadDatabase.getUnreadMentionCountCommand());
|
||||
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV41) {
|
||||
db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand());
|
||||
db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND);
|
||||
db.execSQL(ExpirationConfigurationDatabase.MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND);
|
||||
@ -431,12 +599,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public SQLiteDatabase getReadableDatabase() {
|
||||
return getReadableDatabase(databaseSecret.asString());
|
||||
}
|
||||
@Override
|
||||
public void onOpen(SQLiteDatabase db) {
|
||||
super.onOpen(db);
|
||||
|
||||
public SQLiteDatabase getWritableDatabase() {
|
||||
return getWritableDatabase(databaseSecret.asString());
|
||||
// Now that the database is officially open (ie. the migrations are completed) we want to enable
|
||||
// write ahead logging (WAL mode) to officially support concurrent read connections
|
||||
db.enableWriteAheadLogging();
|
||||
}
|
||||
|
||||
public void markCurrent(SQLiteDatabase db) {
|
||||
|
@ -80,6 +80,18 @@ public abstract class DisplayRecord {
|
||||
return !isFailed() && !isPending();
|
||||
}
|
||||
|
||||
public boolean isSyncing() {
|
||||
return MmsSmsColumns.Types.isSyncingType(type);
|
||||
}
|
||||
|
||||
public boolean isResyncing() {
|
||||
return MmsSmsColumns.Types.isResyncingType(type);
|
||||
}
|
||||
|
||||
public boolean isSyncFailed() {
|
||||
return MmsSmsColumns.Types.isSyncFailedMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
return MmsSmsColumns.Types.isFailedMessageType(type)
|
||||
|| MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type)
|
||||
|
@ -57,12 +57,12 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
long expiresIn, long expireStarted, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews,
|
||||
@NonNull List<ReactionRecord> reactions, boolean unidentified)
|
||||
@NonNull List<ReactionRecord> reactions, boolean unidentified, boolean hasMention)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent,
|
||||
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||
expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts,
|
||||
linkPreviews, unidentified, reactions);
|
||||
linkPreviews, unidentified, reactions, hasMention);
|
||||
this.partCount = partCount;
|
||||
}
|
||||
|
||||
|
@ -51,7 +51,8 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
private final long expireStarted;
|
||||
private final boolean unidentified;
|
||||
public final long id;
|
||||
private final List<ReactionRecord> reactions;
|
||||
private final List<ReactionRecord> reactions;
|
||||
private final boolean hasMention;
|
||||
|
||||
public abstract boolean isMms();
|
||||
public abstract boolean isMmsNotification();
|
||||
@ -63,7 +64,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> networkFailures,
|
||||
long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention)
|
||||
{
|
||||
super(body, conversationRecipient, dateSent, dateReceived,
|
||||
threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount);
|
||||
@ -75,6 +76,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
this.expireStarted = expireStarted;
|
||||
this.unidentified = unidentified;
|
||||
this.reactions = reactions;
|
||||
this.hasMention = hasMention;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
@ -97,6 +99,8 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
}
|
||||
public long getExpireStarted() { return expireStarted; }
|
||||
|
||||
public boolean getHasMention() { return hasMention; }
|
||||
|
||||
public boolean isMediaPending() {
|
||||
return false;
|
||||
}
|
||||
|
@ -27,9 +27,9 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
||||
List<NetworkFailure> networkFailures, long expiresIn,
|
||||
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions)
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention);
|
||||
this.slideDeck = slideDeck;
|
||||
this.quote = quote;
|
||||
this.contacts.addAll(contacts);
|
||||
|
@ -50,12 +50,12 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
|
||||
long dateSent, long dateReceived, int deliveryReceiptCount,
|
||||
long threadId, byte[] contentLocation, long messageSize,
|
||||
long expiry, int status, byte[] transactionId, long mailbox,
|
||||
SlideDeck slideDeck, int readReceiptCount)
|
||||
SlideDeck slideDeck, int readReceiptCount, boolean hasMention)
|
||||
{
|
||||
super(id, "", conversationRecipient, individualRecipient,
|
||||
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
||||
emptyList(), emptyList(),
|
||||
0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList());
|
||||
0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention);
|
||||
|
||||
this.contentLocation = contentLocation;
|
||||
this.messageSize = messageSize;
|
||||
|
@ -43,12 +43,12 @@ public class SmsMessageRecord extends MessageRecord {
|
||||
long type, long threadId,
|
||||
int status, List<IdentityKeyMismatch> mismatches,
|
||||
long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
|
||||
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention)
|
||||
{
|
||||
super(id, body, recipient, individualRecipient,
|
||||
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
|
||||
mismatches, new LinkedList<>(),
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention);
|
||||
}
|
||||
|
||||
public long getType() {
|
||||
|
@ -45,39 +45,37 @@ public class ThreadRecord extends DisplayRecord {
|
||||
private @Nullable final Uri snippetUri;
|
||||
private final long count;
|
||||
private final int unreadCount;
|
||||
private final int unreadMentionCount;
|
||||
private final int distributionType;
|
||||
private final boolean archived;
|
||||
private final long expiresIn;
|
||||
private final long lastSeen;
|
||||
private final boolean pinned;
|
||||
private final int recipientHash;
|
||||
private final int initialRecipientHash;
|
||||
|
||||
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
|
||||
@NonNull Recipient recipient, long date, long count, int unreadCount,
|
||||
long threadId, int deliveryReceiptCount, int status, long snippetType,
|
||||
int distributionType, boolean archived, long expiresIn, long lastSeen,
|
||||
int readReceiptCount, boolean pinned)
|
||||
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
|
||||
long snippetType, int distributionType, boolean archived, long expiresIn,
|
||||
long lastSeen, int readReceiptCount, boolean pinned)
|
||||
{
|
||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||
this.snippetUri = snippetUri;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.distributionType = distributionType;
|
||||
this.archived = archived;
|
||||
this.expiresIn = expiresIn;
|
||||
this.lastSeen = lastSeen;
|
||||
this.pinned = pinned;
|
||||
this.recipientHash = recipient.hashCode();
|
||||
this.snippetUri = snippetUri;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.unreadMentionCount = unreadMentionCount;
|
||||
this.distributionType = distributionType;
|
||||
this.archived = archived;
|
||||
this.expiresIn = expiresIn;
|
||||
this.lastSeen = lastSeen;
|
||||
this.pinned = pinned;
|
||||
this.initialRecipientHash = recipient.hashCode();
|
||||
}
|
||||
|
||||
public @Nullable Uri getSnippetUri() {
|
||||
return snippetUri;
|
||||
}
|
||||
|
||||
public int getRecipientHash() {
|
||||
return recipientHash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
if (isGroupUpdateMessage()) {
|
||||
@ -153,6 +151,10 @@ public class ThreadRecord extends DisplayRecord {
|
||||
return unreadCount;
|
||||
}
|
||||
|
||||
public int getUnreadMentionCount() {
|
||||
return unreadMentionCount;
|
||||
}
|
||||
|
||||
public long getDate() {
|
||||
return getDateReceived();
|
||||
}
|
||||
@ -176,4 +178,8 @@ public class ThreadRecord extends DisplayRecord {
|
||||
public boolean isPinned() {
|
||||
return pinned;
|
||||
}
|
||||
|
||||
public int getInitialRecipientHash() {
|
||||
return initialRecipientHash;
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,6 @@ interface DatabaseComponent {
|
||||
fun recipientDatabase(): RecipientDatabase
|
||||
fun groupReceiptDatabase(): GroupReceiptDatabase
|
||||
fun searchDatabase(): SearchDatabase
|
||||
fun jobDatabase(): JobDatabase
|
||||
fun lokiAPIDatabase(): LokiAPIDatabase
|
||||
fun lokiMessageDatabase(): LokiMessageDatabase
|
||||
fun lokiThreadDatabase(): LokiThreadDatabase
|
||||
|
@ -6,7 +6,7 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
|
||||
@ -24,7 +24,7 @@ object DatabaseModule {
|
||||
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
SQLiteDatabase.loadLibs(context)
|
||||
System.loadLibrary("sqlcipher")
|
||||
}
|
||||
|
||||
@Provides
|
||||
@ -39,6 +39,7 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper {
|
||||
val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret
|
||||
SQLCipherOpenHelper.migrateSqlCipher3To4IfNeeded(context, dbSecret)
|
||||
return SQLCipherOpenHelper(context, dbSecret)
|
||||
}
|
||||
|
||||
@ -91,10 +92,6 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun searchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SearchDatabase(context,openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJobDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = JobDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLokiApiDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiAPIDatabase(context,openHelper)
|
||||
|
@ -15,7 +15,7 @@ import java.util.concurrent.Executors
|
||||
|
||||
object OpenGroupManager {
|
||||
private val executorService = Executors.newScheduledThreadPool(4)
|
||||
private var pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server
|
||||
private val pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server
|
||||
private var isPolling = false
|
||||
private val pollUpdaterLock = Any()
|
||||
|
||||
@ -41,11 +41,11 @@ object OpenGroupManager {
|
||||
isPolling = true
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val servers = storage.getAllOpenGroups().values.map { it.server }.toSet()
|
||||
servers.forEach { server ->
|
||||
pollers[server]?.stop() // Shouldn't be necessary
|
||||
val poller = OpenGroupPoller(server, executorService)
|
||||
poller.startIfNeeded()
|
||||
pollers[server] = poller
|
||||
synchronized(pollUpdaterLock) {
|
||||
servers.forEach { server ->
|
||||
pollers[server]?.stop() // Shouldn't be necessary
|
||||
pollers[server] = OpenGroupPoller(server, executorService).apply { startIfNeeded() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,14 +58,14 @@ object OpenGroupManager {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun add(server: String, room: String, publicKey: String, context: Context) {
|
||||
fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? {
|
||||
val openGroupID = "$server.$room"
|
||||
var threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
|
||||
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
|
||||
// Check it it's added already
|
||||
val existingOpenGroup = threadDB.getOpenGroupChat(threadID)
|
||||
if (existingOpenGroup != null) { return }
|
||||
if (existingOpenGroup != null) { return null }
|
||||
// Clear any existing data if needed
|
||||
storage.removeLastDeletionServerID(room, server)
|
||||
storage.removeLastMessageServerID(room, server)
|
||||
@ -73,18 +73,20 @@ object OpenGroupManager {
|
||||
storage.removeLastOutboxMessageId(server)
|
||||
// Store the public key
|
||||
storage.setOpenGroupPublicKey(server, publicKey)
|
||||
// Get capabilities
|
||||
val capabilities = OpenGroupApi.getCapabilities(server).get()
|
||||
// Get capabilities & room info
|
||||
val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(room, server).get()
|
||||
storage.setServerCapabilities(server, capabilities.capabilities)
|
||||
// Get room info
|
||||
val info = OpenGroupApi.getRoomInfo(room, server).get()
|
||||
storage.setUserCount(room, server, info.activeUsers)
|
||||
// Create the group locally if not available already
|
||||
if (threadID < 0) {
|
||||
threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId
|
||||
GroupManager.createOpenGroup(openGroupID, context, null, info.name)
|
||||
}
|
||||
val openGroup = OpenGroup(server, room, info.name, info.infoUpdates, publicKey)
|
||||
threadDB.setOpenGroupChat(openGroup, threadID)
|
||||
OpenGroupPoller.handleRoomPollInfo(
|
||||
server = server,
|
||||
roomToken = room,
|
||||
pollInfo = info.toPollInfo(),
|
||||
createGroupIfMissingWithPublicKey = publicKey
|
||||
)
|
||||
return info
|
||||
}
|
||||
|
||||
fun restartPollerForServer(server: String) {
|
||||
@ -130,12 +132,13 @@ object OpenGroupManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun addOpenGroup(urlAsString: String, context: Context) {
|
||||
val url = HttpUrl.parse(urlAsString) ?: return
|
||||
fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? {
|
||||
val url = HttpUrl.parse(urlAsString) ?: return null
|
||||
val server = OpenGroup.getServer(urlAsString)
|
||||
val room = url.pathSegments().firstOrNull() ?: return
|
||||
val publicKey = url.queryParameter("public_key") ?: return
|
||||
add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function
|
||||
val room = url.pathSegments().firstOrNull() ?: return null
|
||||
val publicKey = url.queryParameter("public_key") ?: return null
|
||||
|
||||
return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function
|
||||
}
|
||||
|
||||
fun updateOpenGroup(openGroup: OpenGroup, context: Context) {
|
||||
|
@ -1,139 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
|
||||
object OpenGroupMigrator {
|
||||
const val HTTP_PREFIX = "__loki_public_chat_group__!687474703a2f2f"
|
||||
private const val HTTPS_PREFIX = "__loki_public_chat_group__!68747470733a2f2f"
|
||||
const val OPEN_GET_SESSION_TRAILING_DOT_ENCODED = "6f70656e2e67657473657373696f6e2e6f72672e"
|
||||
const val LEGACY_GROUP_ENCODED_ID = "__loki_public_chat_group__!687474703a2f2f3131362e3230332e37302e33332e" // old IP based toByteArray()
|
||||
const val NEW_GROUP_ENCODED_ID = "__loki_public_chat_group__!68747470733a2f2f6f70656e2e67657473657373696f6e2e6f72672e" // new URL based toByteArray()
|
||||
|
||||
data class OpenGroupMapping(val stub: String, val legacyThreadId: Long, val newThreadId: Long?)
|
||||
|
||||
@VisibleForTesting
|
||||
fun Recipient.roomStub(): String? {
|
||||
if (!isOpenGroupRecipient) return null
|
||||
val serialized = address.serialize()
|
||||
if (serialized.startsWith(LEGACY_GROUP_ENCODED_ID)) {
|
||||
return serialized.replace(LEGACY_GROUP_ENCODED_ID,"")
|
||||
} else if (serialized.startsWith(NEW_GROUP_ENCODED_ID)) {
|
||||
return serialized.replace(NEW_GROUP_ENCODED_ID,"")
|
||||
} else if (serialized.startsWith(HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED)) {
|
||||
return serialized.replace(HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED, "")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun getExistingMappings(legacy: List<ThreadRecord>, new: List<ThreadRecord>): List<OpenGroupMapping> {
|
||||
val legacyStubsMapping = legacy.mapNotNull { thread ->
|
||||
val stub = thread.recipient.roomStub()
|
||||
stub?.let { it to thread.threadId }
|
||||
}
|
||||
val newStubsMapping = new.mapNotNull { thread ->
|
||||
val stub = thread.recipient.roomStub()
|
||||
stub?.let { it to thread.threadId }
|
||||
}
|
||||
return legacyStubsMapping.map { (legacyEncodedStub, legacyId) ->
|
||||
// get 'new' open group thread ID if stubs match
|
||||
OpenGroupMapping(
|
||||
legacyEncodedStub,
|
||||
legacyId,
|
||||
newStubsMapping.firstOrNull { (newEncodedStub, _) -> newEncodedStub == legacyEncodedStub }?.second
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(databaseComponent: DatabaseComponent) {
|
||||
// migrate thread db
|
||||
val threadDb = databaseComponent.threadDatabase()
|
||||
|
||||
val legacyOpenGroups = threadDb.legacyOxenOpenGroups
|
||||
val httpBasedNewGroups = threadDb.httpOxenOpenGroups
|
||||
if (legacyOpenGroups.isEmpty() && httpBasedNewGroups.isEmpty()) return // no need to migrate
|
||||
|
||||
val newOpenGroups = threadDb.httpsOxenOpenGroups
|
||||
val firstStepMigration = getExistingMappings(legacyOpenGroups, newOpenGroups)
|
||||
|
||||
val secondStepMigration = getExistingMappings(httpBasedNewGroups, newOpenGroups)
|
||||
|
||||
val groupDb = databaseComponent.groupDatabase()
|
||||
val lokiApiDb = databaseComponent.lokiAPIDatabase()
|
||||
val smsDb = databaseComponent.smsDatabase()
|
||||
val mmsDb = databaseComponent.mmsDatabase()
|
||||
val lokiMessageDatabase = databaseComponent.lokiMessageDatabase()
|
||||
val lokiThreadDatabase = databaseComponent.lokiThreadDatabase()
|
||||
|
||||
firstStepMigration.forEach { (stub, old, new) ->
|
||||
val legacyEncodedGroupId = LEGACY_GROUP_ENCODED_ID+stub
|
||||
if (new == null) {
|
||||
val newEncodedGroupId = NEW_GROUP_ENCODED_ID+stub
|
||||
// migrate thread and group encoded values
|
||||
threadDb.migrateEncodedGroup(old, newEncodedGroupId)
|
||||
groupDb.migrateEncodedGroup(legacyEncodedGroupId, newEncodedGroupId)
|
||||
// migrate Loki API DB values
|
||||
// decode the hex to bytes, decode byte array to string i.e. "oxen" or "session"
|
||||
val decodedStub = Hex.fromStringCondensed(stub).decodeToString()
|
||||
val legacyLokiServerId = "${OpenGroupApi.legacyDefaultServer}.$decodedStub"
|
||||
val newLokiServerId = "${OpenGroupApi.defaultServer}.$decodedStub"
|
||||
lokiApiDb.migrateLegacyOpenGroup(legacyLokiServerId, newLokiServerId)
|
||||
// migrate loki thread db server info
|
||||
val oldServerInfo = lokiThreadDatabase.getOpenGroupChat(old)
|
||||
val newServerInfo = oldServerInfo!!.copy(server = OpenGroupApi.defaultServer, id = newLokiServerId)
|
||||
lokiThreadDatabase.setOpenGroupChat(newServerInfo, old)
|
||||
} else {
|
||||
// has a legacy and a new one
|
||||
// migrate SMS and MMS tables
|
||||
smsDb.migrateThreadId(old, new)
|
||||
mmsDb.migrateThreadId(old, new)
|
||||
lokiMessageDatabase.migrateThreadId(old, new)
|
||||
// delete group for legacy ID
|
||||
groupDb.delete(legacyEncodedGroupId)
|
||||
// delete thread for legacy ID
|
||||
threadDb.deleteConversation(old)
|
||||
lokiThreadDatabase.removeOpenGroupChat(old)
|
||||
}
|
||||
// maybe migrate jobs here
|
||||
}
|
||||
|
||||
secondStepMigration.forEach { (stub, old, new) ->
|
||||
val legacyEncodedGroupId = HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED + stub
|
||||
if (new == null) {
|
||||
val newEncodedGroupId = NEW_GROUP_ENCODED_ID+stub
|
||||
// migrate thread and group encoded values
|
||||
threadDb.migrateEncodedGroup(old, newEncodedGroupId)
|
||||
groupDb.migrateEncodedGroup(legacyEncodedGroupId, newEncodedGroupId)
|
||||
// migrate Loki API DB values
|
||||
// decode the hex to bytes, decode byte array to string i.e. "oxen" or "session"
|
||||
val decodedStub = Hex.fromStringCondensed(stub).decodeToString()
|
||||
val legacyLokiServerId = "${OpenGroupApi.httpDefaultServer}.$decodedStub"
|
||||
val newLokiServerId = "${OpenGroupApi.defaultServer}.$decodedStub"
|
||||
lokiApiDb.migrateLegacyOpenGroup(legacyLokiServerId, newLokiServerId)
|
||||
// migrate loki thread db server info
|
||||
val oldServerInfo = lokiThreadDatabase.getOpenGroupChat(old)
|
||||
val newServerInfo = oldServerInfo!!.copy(server = OpenGroupApi.defaultServer, id = newLokiServerId)
|
||||
lokiThreadDatabase.setOpenGroupChat(newServerInfo, old)
|
||||
} else {
|
||||
// has a legacy and a new one
|
||||
// migrate SMS and MMS tables
|
||||
smsDb.migrateThreadId(old, new)
|
||||
mmsDb.migrateThreadId(old, new)
|
||||
lokiMessageDatabase.migrateThreadId(old, new)
|
||||
// delete group for legacy ID
|
||||
groupDb.delete(legacyEncodedGroupId)
|
||||
// delete thread for legacy ID
|
||||
threadDb.deleteConversation(old)
|
||||
lokiThreadDatabase.removeOpenGroupChat(old)
|
||||
}
|
||||
// maybe migrate jobs here
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
lateinit var thread: ThreadRecord
|
||||
|
||||
var onViewDetailsTapped: (() -> Unit?)? = null
|
||||
var onCopyConversationId: (() -> Unit?)? = null
|
||||
var onPinTapped: (() -> Unit)? = null
|
||||
var onUnpinTapped: (() -> Unit)? = null
|
||||
var onBlockTapped: (() -> Unit)? = null
|
||||
@ -37,6 +38,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
override fun onClick(v: View?) {
|
||||
when (v) {
|
||||
binding.detailsTextView -> onViewDetailsTapped?.invoke()
|
||||
binding.copyConversationId -> onCopyConversationId?.invoke()
|
||||
binding.copyCommunityUrl -> onCopyConversationId?.invoke()
|
||||
binding.pinTextView -> onPinTapped?.invoke()
|
||||
binding.unpinTextView -> onUnpinTapped?.invoke()
|
||||
binding.blockTextView -> onBlockTapped?.invoke()
|
||||
@ -63,6 +66,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
} else {
|
||||
binding.detailsTextView.visibility = View.GONE
|
||||
}
|
||||
binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE
|
||||
binding.copyConversationId.setOnClickListener(this)
|
||||
binding.copyCommunityUrl.visibility = if (recipient.isOpenGroupRecipient) View.VISIBLE else View.GONE
|
||||
binding.copyCommunityUrl.setOnClickListener(this)
|
||||
binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber
|
||||
binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber
|
||||
binding.unMuteNotificationsTextView.setOnClickListener(this)
|
||||
@ -81,7 +88,6 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
}
|
@ -25,17 +25,17 @@ import org.thoughtcrime.securesms.util.getAccentColor
|
||||
import java.util.Locale
|
||||
|
||||
class ConversationView : LinearLayout {
|
||||
private lateinit var binding: ViewConversationBinding
|
||||
private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) }
|
||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
||||
var thread: ThreadRecord? = 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() }
|
||||
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 fun initialize() {
|
||||
binding = ViewConversationBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
layoutParams = RecyclerView.LayoutParams(screenWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
// endregion
|
||||
@ -53,7 +53,7 @@ class ConversationView : LinearLayout {
|
||||
} else {
|
||||
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
background = if (thread.unreadCount > 0) {
|
||||
binding.root.background = if (thread.unreadCount > 0) {
|
||||
ContextCompat.getDrawable(context, R.drawable.conversation_unread_background)
|
||||
} else {
|
||||
ContextCompat.getDrawable(context, R.drawable.conversation_view_background)
|
||||
@ -79,8 +79,9 @@ class ConversationView : LinearLayout {
|
||||
binding.unreadCountTextView.text = formattedUnreadCount
|
||||
val textSize = if (unreadCount < 1000) 12.0f else 10.0f
|
||||
binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
|
||||
binding.unreadCountIndicator.background.setTint(context.getAccentColor())
|
||||
binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead)
|
||||
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
|
||||
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
|
||||
val senderDisplayName = getUserDisplayName(thread.recipient)
|
||||
?: thread.recipient.address.toString()
|
||||
binding.conversationViewDisplayNameTextView.text = senderDisplayName
|
||||
@ -99,11 +100,11 @@ class ConversationView : LinearLayout {
|
||||
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
|
||||
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
|
||||
if (isTyping) {
|
||||
binding.typingIndicatorView.startAnimation()
|
||||
binding.typingIndicatorView.root.startAnimation()
|
||||
} else {
|
||||
binding.typingIndicatorView.stopAnimation()
|
||||
binding.typingIndicatorView.root.stopAnimation()
|
||||
}
|
||||
binding.typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE
|
||||
binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE
|
||||
binding.statusIndicatorImageView.visibility = View.VISIBLE
|
||||
when {
|
||||
!thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE
|
||||
|
@ -4,6 +4,8 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.widget.Toast
|
||||
@ -202,7 +204,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
OpenGroupManager.startPolling()
|
||||
JobQueue.shared.resumePendingJobs()
|
||||
}
|
||||
// Set up typing observer
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateProfileButton()
|
||||
TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect {
|
||||
@ -365,6 +367,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
setupMessageRequestsBanner()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
|
||||
homeAdapter.typingThreadIDs = (threadIds ?: setOf())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEmptyState() {
|
||||
@ -422,6 +428,24 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
userDetailsBottomSheet.arguments = bundle
|
||||
userDetailsBottomSheet.show(supportFragmentManager, userDetailsBottomSheet.tag)
|
||||
}
|
||||
bottomSheet.onCopyConversationId = onCopyConversationId@{
|
||||
bottomSheet.dismiss()
|
||||
if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) {
|
||||
val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString())
|
||||
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
manager.setPrimaryClip(clip)
|
||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
else if (thread.recipient.isOpenGroupRecipient) {
|
||||
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit
|
||||
val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit
|
||||
|
||||
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
|
||||
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
manager.setPrimaryClip(clip)
|
||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
bottomSheet.onBlockTapped = {
|
||||
bottomSheet.dismiss()
|
||||
if (!thread.recipient.isBlocked) {
|
||||
@ -471,6 +495,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
recipientDatabase.setBlocked(thread.recipient, true)
|
||||
// TODO: Remove in UserConfig branch
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.recyclerView.adapter!!.notifyDataSetChanged()
|
||||
dialog.dismiss()
|
||||
@ -487,6 +513,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
recipientDatabase.setBlocked(thread.recipient, false)
|
||||
// TODO: Remove in UserConfig branch
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.recyclerView.adapter!!.notifyDataSetChanged()
|
||||
dialog.dismiss()
|
||||
|
@ -1,12 +1,14 @@
|
||||
package org.thoughtcrime.securesms.home
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
|
||||
@ -63,6 +65,8 @@ class HomeAdapter(
|
||||
lateinit var glide: GlideRequests
|
||||
var typingThreadIDs = setOf<Long>()
|
||||
set(value) {
|
||||
if (field == value) { return }
|
||||
|
||||
field = value
|
||||
// TODO: replace this with a diffed update or a partial change set with payloads
|
||||
notifyDataSetChanged()
|
||||
@ -74,19 +78,20 @@ class HomeAdapter(
|
||||
HeaderFooterViewHolder(header!!)
|
||||
}
|
||||
ITEM -> {
|
||||
val view = ConversationView(context)
|
||||
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
|
||||
view.setOnLongClickListener {
|
||||
view.thread?.let { listener.onLongConversationClick(it) }
|
||||
val conversationView = LayoutInflater.from(parent.context).inflate(R.layout.view_conversation, parent, false) as ConversationView
|
||||
val viewHolder = ConversationViewHolder(conversationView)
|
||||
viewHolder.view.setOnClickListener { viewHolder.view.thread?.let { listener.onConversationClick(it) } }
|
||||
viewHolder.view.setOnLongClickListener {
|
||||
viewHolder.view.thread?.let { listener.onLongConversationClick(it) }
|
||||
true
|
||||
}
|
||||
ViewHolder(view)
|
||||
viewHolder
|
||||
}
|
||||
else -> throw Exception("viewType $viewType isn't valid")
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (holder is ViewHolder) {
|
||||
if (holder is ConversationViewHolder) {
|
||||
val offset = if (hasHeaderView()) position - 1 else position
|
||||
val thread = data[offset]
|
||||
val isTyping = typingThreadIDs.contains(thread.threadId)
|
||||
@ -95,7 +100,7 @@ class HomeAdapter(
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
if (holder is ViewHolder) {
|
||||
if (holder is ConversationViewHolder) {
|
||||
holder.view.recycle()
|
||||
} else {
|
||||
super.onViewRecycled(holder)
|
||||
@ -108,7 +113,7 @@ class HomeAdapter(
|
||||
|
||||
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
|
||||
|
||||
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
|
||||
class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
|
||||
|
||||
class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user