Merge remote-tracking branch 'upstream/dev' into message-request-response

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
#	libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt
This commit is contained in:
Morgan Pretty 2023-01-24 14:44:56 +11:00
commit 251df065f8
534 changed files with 7258 additions and 5873 deletions

View File

@ -4,11 +4,11 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "com.android.tools.build:gradle:$gradlePluginVersion"
classpath files('libs/gradle-witness.jar')
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion"
classpath "com.google.gms:google-services:4.3.10"
classpath "com.google.gms:google-services:$googleServicesVersion"
classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion"
}
}
@ -27,26 +27,27 @@ configurations.all {
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.google.android.material:material:1.2.1'
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "com.google.android.material:material:$materialVersion"
implementation 'com.google.android:flexbox:2.0.1'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.exifinterface:exifinterface:1.3.3'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
implementation 'androidx.activity:activity-ktx:1.2.2'
implementation 'androidx.fragment:fragment-ktx:1.3.2'
implementation "androidx.core:core-ktx:1.3.2"
implementation "androidx.work:work-runtime-ktx:2.4.0"
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
@ -94,7 +95,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'
@ -119,7 +121,7 @@ dependencies {
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6"
testImplementation 'junit:junit:4.12'
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.0.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
@ -127,7 +129,7 @@ dependencies {
testImplementation 'org.powermock:powermock-module-junit4:1.6.1'
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
testImplementation 'androidx.test:core:1.3.0'
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
@ -141,7 +143,7 @@ dependencies {
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.ext:truth:1.4.0'
androidTestImplementation 'com.google.truth:truth:1.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
@ -151,14 +153,14 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0'
androidTestUtil 'androidx.test:orchestrator:1.4.0'
androidTestUtil 'androidx.test:orchestrator:1.4.1'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
}
def canonicalVersionCode = 299
def canonicalVersionName = "1.15.3"
def canonicalVersionCode = 323
def canonicalVersionName = "1.16.3"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@ -169,13 +171,9 @@ def abiPostFix = ['armeabi-v7a' : 1,
android {
compileSdkVersion androidCompileSdkVersion
buildToolsVersion '29.0.3'
namespace 'network.loki.messenger'
useLibrary 'org.apache.http.legacy'
dexOptions {
javaMaxHeapSize "4g"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
@ -209,7 +207,7 @@ android {
versionName canonicalVersionName
minSdkVersion androidMinimumSdkVersion
targetSdkVersion androidCompileSdkVersion
targetSdkVersion androidTargetSdkVersion
multiDexEnabled = true

View File

@ -2,6 +2,7 @@
-keepattributes SourceFile,LineNumberTable
-keep class org.whispersystems.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.thoughtcrime.securesms.components.menu.** { *; }
-keep class org.session.** { *; }
-keepclassmembers class ** {
public void onEvent*(**);

View File

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="network.loki.messenger">
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="com.amulyakhare.textdrawable,com.astuetz.pagerslidingtabstrip,pl.tajchert.waitingdots,com.h6ah4i.android.multiselectlistpreferencecompat,android.support.v13,com.davemorrissey.labs.subscaleview,com.tomergoldst.tooltips,com.klinker.android.send_message,com.takisoft.colorpicker,android.support.v14.preference" />
@ -31,6 +30,7 @@
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
@ -140,6 +140,12 @@
android:name="org.thoughtcrime.securesms.preferences.QRCodeActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.preferences.BlockedContactsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar"
android:label="@string/blocked_contacts_title"
/>
<activity
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
android:label="@string/activity_edit_closed_group_title"
@ -160,8 +166,15 @@
<activity
android:name="org.thoughtcrime.securesms.preferences.ChatSettingsActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.preferences.HelpSettingsActivity"
android:label="@string/activity_help_settings_title"
android:screenOrientation="portrait" />
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
android:screenOrientation="portrait"/>
<activity
android:exported="true"
android:name="org.thoughtcrime.securesms.ShareActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:excludeFromRecents="true"
@ -216,12 +229,12 @@
<activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight">
android:theme="@style/Theme.Session.DayNight">
</activity>
<activity
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight" />
android:theme="@style/Theme.Session.DayNight" />
<activity
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@ -235,14 +248,14 @@
<activity
android:name="org.thoughtcrime.securesms.giph.ui.GiphyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Theme.TextSecure.DayNight.NoActionBar"
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:screenOrientation="portrait"
android:windowSoftInputMode="stateHidden" />
<activity
android:name="org.thoughtcrime.securesms.mediasend.MediaSendActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:screenOrientation="portrait"
android:theme="@style/Theme.TextSecure.DayNight.NoActionBar"
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden" />
<activity
android:name="org.thoughtcrime.securesms.MediaPreviewActivity"
@ -309,6 +322,7 @@
android:exported="false" />
<service
android:name="org.thoughtcrime.securesms.service.DirectShareService"
android:exported="true"
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
<intent-filter>
<action android:name="android.service.chooser.ChooserTargetService" />
@ -381,43 +395,53 @@
android:name="org.thoughtcrime.securesms.database.DatabaseContentProviders$StickerPack"
android:authorities="network.loki.securesms.database.stickerpack"
android:exported="false" />
<provider
android:name="org.thoughtcrime.securesms.database.DatabaseContentProviders$Recipient"
android:authorities="network.loki.securesms.database.recipient"
android:exported="false" />
<receiver android:name="org.thoughtcrime.securesms.service.BootReceiver">
<receiver android:name="org.thoughtcrime.securesms.service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="network.loki.securesms.RESTART" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener">
<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">
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.notifications.LocaleChangedReceiver">
<receiver android:name="org.thoughtcrime.securesms.notifications.LocaleChangedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver">
<receiver android:name="org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver"
android:exported="true">
<intent-filter>
<action android:name="network.loki.securesms.DELETE_NOTIFICATION" />
</intent-filter>
</receiver>
<receiver
android:name="org.thoughtcrime.securesms.service.PanicResponderListener"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="info.guardianproject.panic.action.TRIGGER" />
</intent-filter>
</receiver>
<receiver
android:name="org.thoughtcrime.securesms.notifications.BackgroundPollWorker$BootBroadcastReceiver"
android:enabled="true">
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>

View File

@ -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;
@ -57,6 +58,7 @@ 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;
@ -66,6 +68,7 @@ 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.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.jobs.FastJobStorage;
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.logging.AndroidLogger;
@ -85,7 +88,6 @@ import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
import org.thoughtcrime.securesms.util.Broadcaster;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper;
import org.thoughtcrime.securesms.webrtc.CallMessageProcessor;
import org.webrtc.PeerConnectionFactory;
@ -165,6 +167,10 @@ 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);
}
@ -220,7 +226,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (userPublicKey != null) {
registerForFCMIfNeeded(false);
}
UiModeUtilities.setupUiModeToUserSelected(this);
initializeExpiringMessageManager();
initializeTypingStatusRepository();
initializeTypingStatusSender();
@ -234,6 +239,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
resubmitProfilePictureIfNeeded();
loadEmojiSearchIndexIfNeeded();
EmojiSource.refresh();
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
}
@Override
@ -242,6 +250,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);
@ -479,6 +493,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return;
ThreadUtils.queue(() -> {
// Don't generate a new profile key here; we do that when the user changes their profile picture
Log.d("Loki-Avatar", "Uploading Avatar Started");
String encodedProfileKey = TextSecurePreferences.getProfileKey(ApplicationContext.this);
try {
// Read the file into a byte array
@ -495,6 +510,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
ProfilePictureUtilities.INSTANCE.upload(profilePicture, encodedProfileKey, ApplicationContext.this).success(unit -> {
// Update the last profile picture upload date
TextSecurePreferences.setLastProfilePictureUpload(ApplicationContext.this, new Date().getTime());
Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE;
});
} catch (Exception exception) {
@ -533,7 +549,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));

View File

@ -1,44 +1,103 @@
package org.thoughtcrime.securesms;
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
import android.app.ActivityManager;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.view.WindowManager;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.appcompat.app.ActionBar;
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.util.ActivityUtilitiesKt;
import org.thoughtcrime.securesms.util.ThemeState;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import network.loki.messenger.R;
public abstract class BaseActionBarActivity extends AppCompatActivity {
private static final String TAG = BaseActionBarActivity.class.getSimpleName();
public ThemeState currentThemeState;
private TextSecurePreferences getPreferences() {
ApplicationContext appContext = (ApplicationContext) getApplicationContext();
return appContext.textSecurePreferences;
}
@StyleRes
public int getDesiredTheme() {
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
int userSelectedTheme = themeState.getTheme();
if (themeState.getFollowSystem()) {
// do light or dark based on the selected theme
boolean isDayUi = UiModeUtilities.isDayUiMode(this);
if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) {
return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark;
} else {
return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark;
}
} else {
return userSelectedTheme;
}
}
@StyleRes @Nullable
public Integer getAccentTheme() {
if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null;
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
return themeState.getAccentStyle();
}
@Override
public Resources.Theme getTheme() {
// New themes
Resources.Theme modifiedTheme = super.getTheme();
modifiedTheme.applyStyle(getDesiredTheme(), true);
Integer accentTheme = getAccentTheme();
if (accentTheme != null) {
modifiedTheme.applyStyle(accentTheme, true);
}
currentThemeState = ActivityUtilitiesKt.themeState(getPreferences());
return modifiedTheme;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setHomeButtonEnabled(true);
}
super.onCreate(savedInstanceState);
}
@Override
protected void onResume() {
super.onResume();
initializeScreenshotSecurity();
initializeScreenshotSecurity(true);
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
String name = getResources().getString(R.string.app_name);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
int color = getResources().getColor(R.color.app_icon_background);
setTaskDescription(new ActivityManager.TaskDescription(name, icon, color));
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
recreate();
}
}
@Override
protected void onPause() {
super.onPause();
initializeScreenshotSecurity(false);
}
@Override
@ -49,13 +108,17 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
return true;
}
private void initializeScreenshotSecurity() {
private void initializeScreenshotSecurity(boolean isResume) {
if (!isResume) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
} else {
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
}
}
}
@Override
protected void attachBaseContext(Context newBase) {

View File

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

View File

@ -148,12 +148,11 @@ public class PassphrasePromptActivity extends BaseActionBarActivity {
// Finish and proceed with the next intent.
Intent nextIntent = getIntent().getParcelableExtra("next_intent");
if (nextIntent != null) {
try {
startActivity(nextIntent);
// try {
// startActivity(nextIntent);
// } catch (java.lang.SecurityException e) {
// Log.w(TAG, "Access permission not passed from PassphraseActivity, retry sharing.");
// }
} catch (java.lang.SecurityException e) {
Log.w(TAG, "Access permission not passed from PassphraseActivity, retry sharing.", e);
}
}
finish();
}

View File

@ -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,6 +189,15 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
}
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) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author)

View File

@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.attachments
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.provider.MediaStore
import androidx.annotation.RequiresApi
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 {
queryDataColumn(uri)
}
}
private val cache = mutableSetOf<Int>()
private fun queryDataColumn(uri: Uri) {
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()
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun queryRelativeDataColumn(uri: Uri) {
val projection = arrayOf(
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()
}
}
}
}
}
}

View File

@ -1,104 +0,0 @@
package org.thoughtcrime.securesms.backup;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.util.BackupDirSelector;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.session.libsession.utilities.Util;
import java.io.IOException;
import network.loki.messenger.R;
public class BackupDialog {
private static final String TAG = "BackupDialog";
public static void showEnableBackupDialog(
@NonNull Context context,
@NonNull SwitchPreferenceCompat preference,
@NonNull BackupDirSelector backupDirSelector) {
String[] password = BackupUtil.generateBackupPassphrase();
String passwordSt = Util.join(password, "");
AlertDialog dialog = new AlertDialog.Builder(context)
.setTitle(R.string.BackupDialog_enable_local_backups)
.setView(R.layout.backup_enable_dialog)
.setPositiveButton(R.string.BackupDialog_enable_backups, null)
.setNegativeButton(android.R.string.cancel, null)
.create();
dialog.setOnShowListener(created -> {
Button button = ((AlertDialog) created).getButton(AlertDialog.BUTTON_POSITIVE);
button.setOnClickListener(v -> {
CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check);
if (confirmationCheckBox.isChecked()) {
backupDirSelector.selectBackupDir(true, uri -> {
try {
BackupUtil.enableBackups(context, passwordSt);
} catch (IOException e) {
Log.e(TAG, "Failed to activate backups.", e);
Toast.makeText(context,
context.getString(R.string.dialog_backup_activation_failed),
Toast.LENGTH_LONG)
.show();
return;
}
preference.setChecked(true);
created.dismiss();
});
} else {
Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show();
}
});
});
dialog.show();
CheckBox checkBox = dialog.findViewById(R.id.confirmation_check);
TextView textView = dialog.findViewById(R.id.confirmation_text);
((TextView)dialog.findViewById(R.id.code_first)).setText(password[0]);
((TextView)dialog.findViewById(R.id.code_second)).setText(password[1]);
((TextView)dialog.findViewById(R.id.code_third)).setText(password[2]);
((TextView)dialog.findViewById(R.id.code_fourth)).setText(password[3]);
((TextView)dialog.findViewById(R.id.code_fifth)).setText(password[4]);
((TextView)dialog.findViewById(R.id.code_sixth)).setText(password[5]);
textView.setOnClickListener(v -> checkBox.toggle());
dialog.findViewById(R.id.number_table).setOnClickListener(v -> {
((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", passwordSt));
Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_SHORT).show();
});
}
public static void showDisableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) {
new AlertDialog.Builder(context)
.setTitle(R.string.BackupDialog_delete_backups)
.setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> {
BackupUtil.disableBackups(context, true);
preference.setChecked(false);
})
.create()
.show();
}
}

View File

@ -1,206 +0,0 @@
package org.thoughtcrime.securesms.backup
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ClickableSpan
import android.text.style.StyleSpan
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.google.android.gms.common.util.Strings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.backup.FullBackupImporter.DatabaseDowngradeException
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.home.HomeActivity
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.show
class BackupRestoreActivity : BaseActionBarActivity() {
companion object {
private const val TAG = "BackupRestoreActivity"
}
private val viewModel by viewModels<BackupRestoreViewModel>()
private val fileSelectionResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK && result.data != null && result.data!!.data != null) {
viewModel.backupFile.value = result.data!!.data!!
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
// val viewBinding = DataBindingUtil.setContentView<ActivityBackupRestoreBinding>(this, R.layout.activity_backup_restore)
// viewBinding.lifecycleOwner = this
// viewBinding.viewModel = viewModel
// viewBinding.restoreButton.setOnClickListener { viewModel.tryRestoreBackup() }
// viewBinding.buttonSelectFile.setOnClickListener {
// fileSelectionResultLauncher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// //FIXME On some old APIs (tested on 21 & 23) the mime type doesn't filter properly
// // and the backup files are unavailable for selection.
//// type = BackupUtil.BACKUP_FILE_MIME_TYPE
// type = "*/*"
// })
// }
// viewBinding.backupCode.addTextChangedListener { text -> viewModel.backupPassphrase.value = text.toString() }
// Focus passphrase text edit when backup file is selected.
// viewModel.backupFile.observe(this, { backupFile ->
// if (backupFile != null) viewBinding.backupCode.post {
// viewBinding.backupCode.requestFocus()
// (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
// .showSoftInput(viewBinding.backupCode, InputMethodManager.SHOW_IMPLICIT)
// }
// })
// React to backup import result.
viewModel.backupImportResult.observe(this) { result ->
if (result != null) when (result) {
BackupRestoreViewModel.BackupRestoreResult.SUCCESS -> {
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
this.show(intent)
}
BackupRestoreViewModel.BackupRestoreResult.FAILURE_VERSION_DOWNGRADE ->
Toast.makeText(this, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show()
BackupRestoreViewModel.BackupRestoreResult.FAILURE_UNKNOWN ->
Toast.makeText(this, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show()
}
}
//region Legal info views
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy")
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
openURL("https://getsession.org/terms-of-service/")
}
}, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
openURL("https://getsession.org/privacy-policy/")
}
}, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// viewBinding.termsTextView.movementMethod = LinkMovementMethod.getInstance()
// viewBinding.termsTextView.text = termsExplanation
//endregion
}
private fun openURL(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show()
}
}
}
class BackupRestoreViewModel(application: Application): AndroidViewModel(application) {
companion object {
private const val TAG = "BackupRestoreViewModel"
@JvmStatic
fun uriToFileName(view: View, fileUri: Uri?): String? {
fileUri ?: return null
view.context.contentResolver.query(fileUri, null, null, null, null).use {
val nameIndex = it!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst()
return it.getString(nameIndex)
}
}
@JvmStatic
fun validateData(fileUri: Uri?, passphrase: String?): Boolean {
return fileUri != null &&
!Strings.isEmptyOrWhitespace(passphrase) &&
passphrase!!.length == BackupUtil.BACKUP_PASSPHRASE_LENGTH
}
}
val backupFile = MutableLiveData<Uri>(null)
val backupPassphrase = MutableLiveData<String>(null)
val processingBackupFile = MutableLiveData<Boolean>(false)
val backupImportResult = MutableLiveData<BackupRestoreResult>(null)
fun tryRestoreBackup() = viewModelScope.launch {
if (processingBackupFile.value == true) return@launch
if (backupImportResult.value == BackupRestoreResult.SUCCESS) return@launch
if (!validateData(backupFile.value, backupPassphrase.value)) return@launch
val context = getApplication<Application>()
val backupFile = backupFile.value!!
val passphrase = backupPassphrase.value!!
val result: BackupRestoreResult
processingBackupFile.value = true
withContext(Dispatchers.IO) {
result = try {
val database = DatabaseComponent.get(context).openHelper().readableDatabase
FullBackupImporter.importFromUri(
context,
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
database,
backupFile,
passphrase
)
DatabaseFactory.upgradeRestored(context, database)
NotificationChannels.restoreContactNotificationChannels(context)
TextSecurePreferences.setRestorationTime(context, System.currentTimeMillis())
TextSecurePreferences.setHasViewedSeed(context, true)
TextSecurePreferences.setHasSeenWelcomeScreen(context, true)
BackupRestoreResult.SUCCESS
} catch (e: DatabaseDowngradeException) {
Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e)
BackupRestoreResult.FAILURE_VERSION_DOWNGRADE
} catch (e: Exception) {
Log.w(TAG, e)
BackupRestoreResult.FAILURE_UNKNOWN
}
}
processingBackupFile.value = false
backupImportResult.value = result
}
enum class BackupRestoreResult {
SUCCESS, FAILURE_VERSION_DOWNGRADE, FAILURE_UNKNOWN
}
}

View File

@ -8,7 +8,7 @@ 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 net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId

View File

@ -5,7 +5,7 @@ import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import net.sqlcipher.database.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId

View File

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

View File

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

View File

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

View File

@ -1,304 +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 android.os.Build;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.ThemeUtil;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.recipients.RecipientModifiedListener;
import org.thoughtcrime.securesms.database.SessionContactDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.util.UiModeUtilities;
import java.util.List;
import network.loki.messenger.R;
public class QuoteView extends FrameLayout implements RecipientModifiedListener {
private static final String TAG = QuoteView.class.getSimpleName();
private static final int MESSAGE_TYPE_PREVIEW = 0;
private static final int MESSAGE_TYPE_OUTGOING = 1;
private static final int MESSAGE_TYPE_INCOMING = 2;
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private ImageView quoteBarView;
private ImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private TextView attachmentNameView;
private ImageView dismissView;
private long id;
private Recipient author;
private String body;
private Recipient conversationRecipient;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
private int messageType;
private int largeCornerRadius;
private int smallCornerRadius;
private CornerMask cornerMask;
public QuoteView(Context context) {
super(context);
initialize(null);
}
public QuoteView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.quote_view, this);
this.mainView = findViewById(R.id.quote_main);
this.footerView = findViewById(R.id.quote_missing_footer);
this.authorView = findViewById(R.id.quote_author);
this.bodyView = findViewById(R.id.quote_text);
this.quoteBarView = findViewById(R.id.quote_bar);
this.thumbnailView = findViewById(R.id.quote_thumbnail);
this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay);
this.attachmentContainerView = findViewById(R.id.quote_attachment_container);
this.attachmentNameView = findViewById(R.id.quote_attachment_name);
this.dismissView = findViewById(R.id.quote_dismiss);
this.mediaDescriptionText = findViewById(R.id.media_type);
this.missingLinkText = findViewById(R.id.quote_missing_text);
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
cornerMask = new CornerMask(this);
cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0);
int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK);
int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK);
messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0);
typedArray.recycle();
dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE);
authorView.setTextColor(primaryColor);
bodyView.setTextColor(primaryColor);
attachmentNameView.setTextColor(primaryColor);
mediaDescriptionText.setTextColor(secondaryColor);
missingLinkText.setTextColor(primaryColor);
if (messageType == MESSAGE_TYPE_PREVIEW) {
int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
cornerMask.setTopLeftRadius(radius);
cornerMask.setTopRightRadius(radius);
}
}
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
}
public void setQuote(GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@Nullable String body,
boolean originalMissing,
@NonNull SlideDeck attachments,
@NonNull Recipient conversationRecipient)
{
if (this.author != null) this.author.removeListener(this);
this.id = id;
this.author = author;
this.body = body;
this.attachments = attachments;
this.conversationRecipient = conversationRecipient;
author.addListener(this);
setQuoteAuthor(author);
setQuoteText(body, attachments);
setQuoteAttachment(glideRequests, attachments);
setQuoteMissingFooter(originalMissing);
}
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius);
cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius);
}
public void dismiss() {
if (this.author != null) this.author.removeListener(this);
this.id = 0;
this.author = null;
this.body = null;
setVisibility(GONE);
}
@Override
public void onModified(Recipient recipient) {
Util.runOnMain(() -> {
if (recipient == author) {
setQuoteAuthor(recipient);
}
});
}
private void setQuoteAuthor(@NonNull Recipient author) {
boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
boolean isOwnNumber = Util.isOwnNumber(getContext(), author.getAddress().serialize());
String quoteeDisplayName;
String senderHexEncodedPublicKey = author.getAddress().serialize();
if (senderHexEncodedPublicKey.equalsIgnoreCase(TextSecurePreferences.getLocalNumber(getContext()))) {
quoteeDisplayName = TextSecurePreferences.getProfileName(getContext());
} else {
SessionContactDatabase contactDB = DatabaseComponent.get(getContext()).sessionContactDatabase();
Contact contact = contactDB.getContactWithSessionID(senderHexEncodedPublicKey);
if (contact != null) {
Contact.ContactContext context = (this.conversationRecipient.isOpenGroupRecipient()) ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR;
quoteeDisplayName = contact.displayName(context);
} else {
quoteeDisplayName = senderHexEncodedPublicKey;
}
}
authorView.setText(isOwnNumber ? getContext().getString(R.string.QuoteView_you) : quoteeDisplayName);
// We use the raw color resource because Android 4.x was struggling with tints here
int colorID = UiModeUtilities.isDayUiMode(getContext()) ? R.color.black : R.color.accent;
quoteBarView.setImageResource(colorID);
mainView.setBackgroundColor(ThemeUtil.getThemedColor(getContext(),
outgoing ? R.attr.message_received_background_color : R.attr.message_sent_background_color));
}
private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments) {
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body);
mediaDescriptionText.setVisibility(GONE);
return;
}
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
List<Slide> audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
List<Slide> imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList();
List<Slide> videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList();
// Given that most types have images, we specifically check images last
if (!audioSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_audio);
} else if (!documentSlides.isEmpty()) {
mediaDescriptionText.setVisibility(GONE);
} else if (!videoSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_video);
} else if (!imageSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_photo);
}
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
List<Slide> imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo()).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
attachmentVideoOverlayView.setVisibility(GONE);
if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getThumbnailUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
if (imageVideoSlides.get(0).hasVideo()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
}
glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getThumbnailUri()))
.centerCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.into(thumbnailView);
} else if (!documentSlides.isEmpty()){
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
attachmentNameView.setText(documentSlides.get(0).getFileName().or(""));
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundDrawable(null);
}
if (ThemeUtil.isDarkTheme(getContext())) {
dismissView.setBackgroundResource(R.drawable.circle_alpha);
}
}
private void setQuoteMissingFooter(boolean missing) {
footerView.setVisibility(missing ? VISIBLE : GONE);
footerView.setBackgroundColor(getResources().getColor(R.color.quote_not_found_background));
}
public long getQuoteId() {
return id;
}
public Recipient getAuthor() {
return author;
}
public String getBody() {
return body;
}
public List<Attachment> getAttachments() {
return attachments.asAttachments();
}
}

View File

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

View File

@ -1,11 +1,10 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.util.AttributeSet;
import androidx.preference.CheckBoxPreference;
import androidx.preference.Preference;
import android.util.AttributeSet;
import network.loki.messenger.R;
@ -18,7 +17,6 @@ public class SwitchPreferenceCompat extends CheckBoxPreference {
setLayoutRes();
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
setLayoutRes();

View File

@ -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,8 +158,13 @@ public class EmojiTextView extends AppCompatTextView {
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) &&
Util.equals(previousOverflowText, overflowText) &&
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;

View File

@ -8,7 +8,6 @@ import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import network.loki.messenger.databinding.ContactSelectionListFragmentBinding
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
@ -58,7 +57,6 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.adapter = listAdapter
binding.swipeRefreshLayout.isEnabled = requireActivity().intent.getBooleanExtra(REFRESHABLE, true)
}
override fun onStop() {
@ -73,15 +71,6 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L
fun resetQueryFilter() {
setQueryFilter(null)
binding.swipeRefreshLayout.isRefreshing = false
}
fun setRefreshing(refreshing: Boolean) {
binding.swipeRefreshLayout.isRefreshing = refreshing
}
fun setOnRefreshListener(onRefreshListener: OnRefreshListener?) {
binding.swipeRefreshLayout.setOnRefreshListener(onRefreshListener)
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<List<ContactSelectionListItem>> {
@ -106,7 +95,7 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L
return
}
listAdapter.items = items
binding.mainContentContainer.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
binding.recyclerView.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
binding.emptyStateContainer.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE
}

View File

@ -3,12 +3,12 @@ package org.thoughtcrime.securesms.contacts
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySelectContactsBinding
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
@ -49,7 +49,7 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana
LoaderManager.getInstance(this).initLoader(0, null, this)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_done, menu)
return members.isNotEmpty()
}
@ -70,7 +70,7 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana
private fun update(members: List<String>) {
this.members = members
binding.mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE
binding.recyclerView.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE
binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE
invalidateOptionsMenu()
}

View File

@ -68,18 +68,22 @@ class UserView : LinearLayout {
}
ActionIndicator.Tick -> {
binding.actionIndicatorImageView.visibility = View.VISIBLE
binding.actionIndicatorImageView.setImageResource(
if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle
)
if (isSelected) {
binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent)
} else {
binding.actionIndicatorImageView.setImageDrawable(null)
}
}
}
}
fun toggleCheckbox(isSelected: Boolean = false) {
binding.actionIndicatorImageView.visibility = View.VISIBLE
binding.actionIndicatorImageView.setImageResource(
if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle
)
if (isSelected) {
binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent)
} else {
binding.actionIndicatorImageView.setImageDrawable(null)
}
}
fun unbind() {

View File

@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.conversation.paging
import androidx.annotation.WorkerThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.recyclerview.widget.DiffUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
private const val TIME_BUCKET = 600000L // bucket into 10 minute increments
private fun config() = PagingConfig(
pageSize = 25,
maxSize = 100,
enablePlaceholders = false
)
fun Long.bucketed(): Long = (TIME_BUCKET - this % TIME_BUCKET) + this
fun conversationPager(threadId: Long, initialKey: PageLoad? = null, db: MmsSmsDatabase, contactDb: SessionContactDatabase) = Pager(config(), initialKey = initialKey) {
ConversationPagingSource(threadId, db, contactDb)
}
class ConversationPagerDiffCallback: DiffUtil.ItemCallback<MessageAndContact>() {
override fun areItemsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean =
oldItem.message.id == newItem.message.id && oldItem.message.isMms == newItem.message.isMms
override fun areContentsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean =
oldItem == newItem
}
data class MessageAndContact(val message: MessageRecord,
val contact: Contact?)
data class PageLoad(val fromTime: Long, val toTime: Long? = null)
class ConversationPagingSource(
private val threadId: Long,
private val messageDb: MmsSmsDatabase,
private val contactDb: SessionContactDatabase
): PagingSource<PageLoad, MessageAndContact>() {
override fun getRefreshKey(state: PagingState<PageLoad, MessageAndContact>): PageLoad? {
val anchorPosition = state.anchorPosition ?: return null
val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null
val next = anchorPage.nextKey?.fromTime
val previous = anchorPage.prevKey?.fromTime ?: anchorPage.data.firstOrNull()?.message?.dateSent ?: return null
return PageLoad(previous, next)
}
private val contactCache = mutableMapOf<String, Contact>()
@WorkerThread
private fun getContact(sessionId: String): Contact? {
contactCache[sessionId]?.let { contact ->
return contact
} ?: run {
contactDb.getContactWithSessionID(sessionId)?.let { contact ->
contactCache[sessionId] = contact
return contact
}
}
return null
}
override suspend fun load(params: LoadParams<PageLoad>): LoadResult<PageLoad, MessageAndContact> {
val pageLoad = params.key ?: withContext(Dispatchers.IO) {
messageDb.getConversationSnippet(threadId).use {
val reader = messageDb.readerFor(it)
var record: MessageRecord? = null
if (reader != null) {
record = reader.next
while (record != null && record.isDeleted) {
record = reader.next
}
}
record?.dateSent?.let { fromTime ->
PageLoad(fromTime)
}
}
} ?: return LoadResult.Page(emptyList(), null, null)
val result = withContext(Dispatchers.IO) {
val cursor = messageDb.getConversationPage(
threadId,
pageLoad.fromTime,
pageLoad.toTime ?: -1L,
params.loadSize
)
val processedList = mutableListOf<MessageAndContact>()
val reader = messageDb.readerFor(cursor)
while (reader.next != null && !invalid) {
reader.current?.let { item ->
val contact = getContact(item.individualRecipient.address.serialize())
processedList += MessageAndContact(item, contact)
}
}
reader.close()
processedList.toMutableList()
}
val hasNext = withContext(Dispatchers.IO) {
if (result.isEmpty()) return@withContext false
val lastTime = result.last().message.dateSent
messageDb.hasNextPage(threadId, lastTime)
}
val nextCheckTime = if (hasNext) {
val lastSent = result.last().message.dateSent
if (lastSent == pageLoad.fromTime) null else lastSent
} else null
val hasPrevious = withContext(Dispatchers.IO) { messageDb.hasPreviousPage(threadId, pageLoad.fromTime) }
val nextKey = if (!hasNext) null else nextCheckTime
val prevKey = if (!hasPrevious) null else messageDb.getPreviousPage(threadId, pageLoad.fromTime, params.loadSize)
return LoadResult.Page(
data = result, // next check time is not null if drop is true
prevKey = prevKey?.let { PageLoad(it, pageLoad.fromTime) },
nextKey = nextKey?.let { PageLoad(it) }
)
}
}

View File

@ -41,7 +41,7 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = BottomSheetDialog(requireContext(), theme)
val dialog = BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet)
dialog.setOnShowListener {
val bottomSheetDialog = it as BottomSheetDialog
val parentLayout =

View File

@ -4,8 +4,11 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentNewConversationHomeBinding
@ -57,5 +60,11 @@ class NewConversationHomeFragment : Fragment() {
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }
binding.contactsRecyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
binding.contactsRecyclerView.addItemDecoration(divider)
}
}

View File

@ -3,30 +3,18 @@ package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.*
import android.content.res.Resources
import android.database.Cursor
import android.graphics.Rect
import android.graphics.Typeface
import android.net.Uri
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.*
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Pair
import android.util.TypedValue
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.*
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.Toast
@ -52,6 +40,8 @@ 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.control.DataExtractionNotification
@ -67,12 +57,8 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.*
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientModifiedListener
@ -85,6 +71,7 @@ import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.ExpirationDialog
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
@ -104,25 +91,10 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.*
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -135,25 +107,12 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.GifSlide
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.mms.*
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.toPx
import java.util.Locale
import org.thoughtcrime.securesms.util.*
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
@ -191,6 +150,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
// post screenshot message
sendScreenshotNotification()
}
}
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
@ -286,6 +252,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
)
@ -343,13 +315,26 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
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 {
// 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()
@ -379,12 +364,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId)
val recipient = viewModel.recipient ?: return
lifecycleScope.launch(Dispatchers.IO) {
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
}
contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true,
screenshotObserver
)
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
contentResolver.unregisterContentObserver(screenshotObserver)
}
override fun getSystemService(name: String): Any? {
@ -620,7 +615,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
this
) { onOptionsItemSelected(it) }
}
super.onPrepareOptionsMenu(menu)
return true
}
@ -645,7 +639,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updateSubtitle()
showOrHideInputIfNeeded()
binding?.toolbarContent?.profilePictureView?.root?.update(threadRecipient)
binding!!.toolbarContent.conversationTitleView.text = when {
binding?.toolbarContent?.conversationTitleView?.text = when {
threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
else -> threadRecipient.toShortString()
}
@ -923,10 +917,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else if (recipient.isGroupRecipient) {
viewModel.openGroup?.let { openGroup ->
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
} ?: run {
actionBarBinding.conversationSubtitleView.isVisible = false
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
}
viewModel
} else {
actionBarBinding.conversationSubtitleView.isVisible = false
}
@ -1317,6 +1313,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) {
if (!textSecurePreferences.autoplayAudioMessages()) return
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView
@ -1397,7 +1395,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else it.individualRecipient.address
QuoteModel(it.dateSent, sender, it.body, false, quotedAttachments)
}
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, quote, linkPreview)
val localQuote = quotedMessage?.let {
val sender =
if (it.isOutgoing) fromSerialized(textSecurePreferences.getLocalNumber()!!)
else it.individualRecipient.address
quote?.copy(author = sender)
}
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview)
// Clear the input bar
binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft()
@ -1713,7 +1717,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun resendMessage(messages: Set<MessageRecord>) {
messages.iterator().forEach { messageRecord ->
ResendMessageUtilities.resend(messageRecord)
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey)
}
endActionMode()
}
@ -1764,6 +1768,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode()
}
override fun destroyActionMode() {
this.actionMode = null
}
private fun sendScreenshotNotification() {
val recipient = viewModel.recipient ?: return
if (recipient.isGroupRecipient) return
val kind = DataExtractionNotification.Kind.Screenshot()
val message = DataExtractionNotification(kind)
MessageSender.send(message, recipient.address)
}
private fun sendMediaSavedNotification() {
val recipient = viewModel.recipient ?: return
if (recipient.isGroupRecipient) { return }
@ -1801,7 +1817,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer
if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs) {
jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) {
searchViewModel.onMissingResult() }
}
}
@ -1813,7 +1829,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
searchViewModel.onSearchOpened()
binding?.searchBottomBar?.visibility = View.VISIBLE
binding?.searchBottomBar?.setData(0, 0)
binding?.inputBar?.visibility = View.GONE
binding?.inputBar?.visibility = View.INVISIBLE
}
fun onSearchClosed() {
@ -1867,6 +1883,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems)
ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems)
ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems)
ConversationReactionOverlay.Action.COPY_SESSION_ID -> copySessionID(selectedItems)
}
}
}

View File

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

View File

@ -6,11 +6,14 @@ 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
import kotlinx.coroutines.launch
import org.session.libsession.messaging.open_groups.OpenGroup
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.utilities.recipients.Recipient
@ -41,17 +44,25 @@ class ConversationViewModel(
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null) null else {
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
}
fun saveDraft(text: String) {
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>) {
@ -181,7 +192,6 @@ class ConversationViewModel(
data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val isOxenHostedOpenGroup: Boolean = false,
val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null
)

View File

@ -2,24 +2,36 @@ package org.thoughtcrime.securesms.conversation.v2
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageDetailBinding
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.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.DateUtils
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
import javax.inject.Inject
@AndroidEntryPoint
class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
private lateinit var binding: ActivityMessageDetailBinding
var messageRecord: MessageRecord? = null
@Inject
lateinit var storage: Storage
// region Settings
companion object {
// Extras
@ -36,10 +48,23 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
// We only show this screen for messages fail to send,
// so the author of the messages must be the current user.
val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!)
messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author)
messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run {
finish()
return
}
val threadId = messageRecord!!.threadId
val openGroup = storage.getOpenGroup(threadId)
val blindedKey = openGroup?.let { group ->
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null
val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase())
if (blindingEnabled) {
SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
} else null
}
updateContent()
binding.resendButton.setOnClickListener {
ResendMessageUtilities.resend(messageRecord!!)
ResendMessageUtilities.resend(this, messageRecord!!, blindedKey)
finish()
}
}
@ -49,8 +74,15 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale)
binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent))
val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) ?: "Message failed to send."
val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId())
if (errorMessage != null) {
binding.errorMessage.text = errorMessage
binding.resendContainer.isVisible = true
binding.errorContainer.isVisible = true
} else {
binding.errorContainer.isVisible = false
binding.resendContainer.isVisible = false
}
if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) {
binding.expiresContainer.visibility = View.GONE

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,9 +7,12 @@ import android.net.Uri
import android.text.InputType
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.inputmethod.EditorInfo
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.view.isVisible
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewInputBarBinding
@ -27,7 +30,8 @@ import org.thoughtcrime.securesms.util.contains
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate {
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
TextView.OnEditorActionListener {
private lateinit var binding: ViewInputBarBinding
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val vMargin by lazy { toDp(4, resources) }
@ -85,11 +89,31 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
}
}
// Edit text
binding.inputBarEditText.setOnEditorActionListener(this)
if (TextSecurePreferences.isEnterSendsEnabled(context)) {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND
binding.inputBarEditText.inputType =
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} else {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
binding.inputBarEditText.inputType =
binding.inputBarEditText.inputType or
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
}
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled
binding.inputBarEditText.inputType = binding.inputBarEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
binding.inputBarEditText.delegate = this
}
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
if (v === binding.inputBarEditText && actionId == EditorInfo.IME_ACTION_SEND) {
// same as pressing send button
delegate?.sendMessage()
return true
}
return false
}
// endregion
// region Updating

View File

@ -16,8 +16,13 @@ import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.annotation.DrawableRes
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.*
import java.util.*
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.InputBarButtonImageViewContainer
import org.thoughtcrime.securesms.util.animateSizeChange
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.toPx
import java.util.Date
class InputBarButton : RelativeLayout {
private val gestureHandler = Handler(Looper.getMainLooper())
@ -43,11 +48,11 @@ class InputBarButton : RelativeLayout {
private val collapsedImageViewPosition by lazy { PointF((expandedSize - collapsedSize) / 2, (expandedSize - collapsedSize) / 2) }
private val colorID by lazy {
if (hasOpaqueBackground) {
R.color.input_bar_button_background_opaque
R.attr.input_bar_button_background_opaque
} else if (isSendButton) {
R.color.accent
R.attr.colorAccent
} else {
R.color.input_bar_button_background
R.attr.input_bar_button_background
}
}
@ -59,9 +64,9 @@ class InputBarButton : RelativeLayout {
val size = collapsedSize.toInt()
result.layoutParams = LayoutParams(size, size)
result.setBackgroundResource(R.drawable.input_bar_button_background)
result.mainColor = resources.getColorWithID(colorID, context.theme)
result.mainColor = context.getColorFromAttr(colorID)
if (hasOpaqueBackground) {
result.strokeColor = resources.getColorWithID(R.color.input_bar_button_background_opaque_border, context.theme)
result.strokeColor = context.getColorFromAttr(R.attr.input_bar_button_background_opaque_border)
}
result
}
@ -72,8 +77,7 @@ class InputBarButton : RelativeLayout {
result.layoutParams = LayoutParams(size, size)
result.scaleType = ImageView.ScaleType.CENTER_INSIDE
result.setImageResource(iconID)
val colorID = if (isSendButton) R.color.black else R.color.text
result.imageTintList = ColorStateList.valueOf(resources.getColorWithID(colorID, context.theme))
result.imageTintList = ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color))
result
}
@ -104,13 +108,18 @@ class InputBarButton : RelativeLayout {
fun getIconID() = iconID
fun expand() {
GlowViewUtilities.animateColorChange(context, imageViewContainer, colorID, R.color.accent)
val fromColor = context.getColorFromAttr(colorID)
val toColor = context.getAccentColor()
GlowViewUtilities.animateColorChange(imageViewContainer, fromColor, toColor)
imageViewContainer.animateSizeChange(R.dimen.input_bar_button_collapsed_size, R.dimen.input_bar_button_expanded_size, animationDuration)
animateImageViewContainerPositionChange(collapsedImageViewPosition, expandedImageViewPosition)
}
fun collapse() {
GlowViewUtilities.animateColorChange(context, imageViewContainer, R.color.accent, colorID)
val fromColor = context.getAccentColor()
val toColor = context.getColorFromAttr(colorID)
GlowViewUtilities.animateColorChange(imageViewContainer, fromColor, toColor)
imageViewContainer.animateSizeChange(R.dimen.input_bar_button_expanded_size, R.dimen.input_bar_button_collapsed_size, animationDuration)
animateImageViewContainerPositionChange(expandedImageViewPosition, collapsedImageViewPosition)
}

View File

@ -65,9 +65,9 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.recipient.address.toString() != userPublicKey)
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
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)
// Save media
@ -101,6 +101,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
override fun onDestroyActionMode(mode: ActionMode) {
adapter.selectedItems.clear()
adapter.notifyDataSetChanged()
delegate?.destroyActionMode()
}
}
@ -116,4 +117,5 @@ interface ConversationActionModeCallbackDelegate {
fun showMessageDetail(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>)
fun reply(messages: Set<MessageRecord>)
fun destroyActionMode()
}

View File

@ -16,6 +16,7 @@ import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.core.content.pm.ShortcutInfoCompat
@ -27,6 +28,7 @@ import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
@ -43,7 +45,6 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.group
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.getColorWithID
import java.io.IOException
object ConversationMenuHelper {
@ -69,7 +70,7 @@ object ConversationMenuHelper {
val actionView = item.actionView
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.resources.getColorWithID(R.color.text, context.theme)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) }
@ -328,7 +329,7 @@ object ConversationMenuHelper {
}
private fun mute(context: Context, thread: Recipient) {
MuteDialog.show(context) { until: Long ->
MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long ->
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
}
}

View File

@ -1,343 +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.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);
for (Reaction reaction : reactions) {
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
if (overflowContainer.getParent() == null) {
container.addView(overflowContainer);
ViewGroup.LayoutParams overflowParams = overflowContainer.getLayoutParams();
overflowParams.height = ViewUtil.dpToPx(26);
overflowContainer.setLayoutParams(overflowParams);
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_dialog_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);
container.addView(pill);
int pixelSize = ViewUtil.dpToPx(1);
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
params.setMargins(pixelSize, 0, pixelSize, 0);
pill.setLayoutParams(params);
}
}
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(ContextCompat.getColor(context, R.color.reactions_pill_selected_text_color));
} 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);
}
}
}
}

View File

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

View File

@ -4,35 +4,29 @@ 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
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities
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
@ -47,19 +41,18 @@ 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
val textColorID = if (message.isOutgoing && UiModeUtilities.isDayUiMode(context)) {
R.color.white
val textColorID = if (message.isOutgoing) {
R.attr.message_sent_text_color
} else {
if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.white
R.attr.message_received_text_color
}
binding.titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme))
binding.titleTextView.setTextColor(context.getColorFromAttr(textColorID))
// Body
binding.titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme))
// Corner radii
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
cornerMask.setTopLeftRadius(cornerRadii[0])
@ -80,7 +73,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

View File

@ -1,16 +1,19 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewOpenGroupInvitationBinding
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.OpenGroupUrlParser
import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.getAccentColor
class OpenGroupInvitationView : LinearLayout {
private val binding: ViewOpenGroupInvitationBinding by lazy { ViewOpenGroupInvitationBinding.bind(this) }
@ -26,8 +29,11 @@ class OpenGroupInvitationView : LinearLayout {
val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation
this.data = data
val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus
val backgroundColor = if (!message.isOutgoing) context.getAccentColor()
else ContextCompat.getColor(context, R.color.transparent_black_6)
with(binding){
openGroupInvitationIconImageView.setImageResource(iconID)
openGroupInvitationIconBackground.backgroundTintList = ColorStateList.valueOf(backgroundColor)
openGroupTitleTextView.text = data.groupName
openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl)
openGroupTitleTextView.setTextColor(textColor)

View File

@ -13,13 +13,15 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewQuoteBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.toPx
import javax.inject.Inject
@ -69,7 +71,12 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
isOriginalMissing: Boolean, glide: GlideRequests) {
// Author
val author = contactDb.getContactWithSessionID(authorPublicKey)
val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}"
val localNumber = TextSecurePreferences.getLocalNumber(context)
val quoteIsLocalUser = localNumber != null && localNumber == author?.sessionID
val authorDisplayName =
if (quoteIsLocalUser) context.getString(R.string.QuoteView_you)
else author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}"
binding.quoteViewAuthorTextView.text = authorDisplayName
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
// Body
@ -83,11 +90,10 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
binding.quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage))
} else if (attachments != null) {
binding.quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme))
val backgroundColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.accent
val backgroundColor = ResourcesCompat.getColor(resources, backgroundColorID, context.theme)
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)
@ -102,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)
}
}
@ -114,31 +120,19 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
// region Convenience
@ColorInt private fun getLineColor(isOutgoingMessage: Boolean): Int {
val isLightMode = UiModeUtilities.isDayUiMode(context)
return when {
mode == Mode.Regular && isLightMode || mode == Mode.Draft && isLightMode -> {
ResourcesCompat.getColor(resources, R.color.black, context.theme)
}
mode == Mode.Regular && !isLightMode -> {
if (isOutgoingMessage) {
ResourcesCompat.getColor(resources, R.color.black, context.theme)
} else {
ResourcesCompat.getColor(resources, R.color.accent, context.theme)
}
}
else -> { // Draft & dark mode
ResourcesCompat.getColor(resources, R.color.accent, context.theme)
}
mode == Mode.Regular && !isOutgoingMessage -> context.getColorFromAttr(R.attr.colorAccent)
mode == Mode.Regular -> context.getColorFromAttr(R.attr.message_sent_text_color)
else -> context.getColorFromAttr(R.attr.colorAccent)
}
}
@ColorInt private fun getTextColor(isOutgoingMessage: Boolean): Int {
if (mode == Mode.Draft) { return ResourcesCompat.getColor(resources, R.color.text, context.theme) }
val isLightMode = UiModeUtilities.isDayUiMode(context)
return if (!isOutgoingMessage && !isLightMode) {
ResourcesCompat.getColor(resources, R.color.white, context.theme)
if (mode == Mode.Draft) { return context.getColorFromAttr(android.R.attr.textColorPrimary) }
return if (!isOutgoingMessage) {
context.getColorFromAttr(R.attr.message_received_text_color)
} else {
ResourcesCompat.getColor(resources, R.color.black, context.theme)
context.getColorFromAttr(R.attr.message_sent_text_color)
}
}

View File

@ -15,7 +15,6 @@ import android.view.MotionEvent
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
@ -28,11 +27,9 @@ 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.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
@ -44,35 +41,38 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getColorWithID
import java.util.Locale
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, isStartOfMessageCluster, isEndOfMessageCluster)
val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color
val color = ThemeUtil.getThemedColor(context, colorID)
val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor()
else context.getColorFromAttr(R.attr.message_received_background_color)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN)
background.colorFilter = filter
binding.contentParent.background = background
@ -82,28 +82,31 @@ class VisibleMessageContentView : LinearLayout {
// reset visibilities / containers
onContentClick.clear()
binding.albumThumbnailView.clearViews()
binding.albumThumbnailView.root.clearViews()
onContentDoubleTap = null
if (message.isDeleted) {
binding.deletedMessageView.root.isVisible = true
binding.deletedMessageView.root.bind(message, getTextColor(context, message))
binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false
binding.linkPreviewView.root.isVisible = false
binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false
binding.albumThumbnailView.root.isVisible = false
binding.openGroupInvitationView.root.isVisible = false
return
} else {
binding.deletedMessageView.root.isVisible = false
}
// clear the
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
@ -135,8 +138,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 ->
@ -144,15 +146,15 @@ 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) }
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
// Body text view is inside the link preview for layout convenience
}
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
@ -189,21 +191,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) }
}
@ -235,24 +237,10 @@ 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, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean): Drawable {
val isSingleMessage = (isStartOfMessageCluster && isEndOfMessageCluster)
@DrawableRes val backgroundID = when {
isSingleMessage -> {
if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
}
isStartOfMessageCluster -> {
if (isOutgoing) R.drawable.message_bubble_background_sent_start else R.drawable.message_bubble_background_received_start
}
isEndOfMessageCluster -> {
if (isOutgoing) R.drawable.message_bubble_background_sent_end else R.drawable.message_bubble_background_received_end
}
else -> {
if (isOutgoing) R.drawable.message_bubble_background_sent_middle else R.drawable.message_bubble_background_received_middle
}
}
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
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
@ -264,8 +252,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 }
}
@ -307,13 +295,14 @@ class VisibleMessageContentView : LinearLayout {
@ColorInt
fun getTextColor(context: Context, message: MessageRecord): Int {
val isDayUiMode = UiModeUtilities.isDayUiMode(context)
val colorID = if (message.isOutgoing) {
R.color.black
val colorAttribute = if (message.isOutgoing) {
// sent
R.attr.message_sent_text_color
} else {
if (isDayUiMode) R.color.black else R.color.white
// received
R.attr.message_received_text_color
}
return context.resources.getColorWithID(colorID, context.theme)
return context.getColorFromAttr(colorAttribute)
}
}
// endregion

View File

@ -16,7 +16,6 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@ -29,6 +28,7 @@ import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
@ -45,7 +45,6 @@ import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
import java.util.Date
@ -86,7 +85,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
@ -109,7 +108,7 @@ class VisibleMessageView : LinearLayout {
isHapticFeedbackEnabled = true
setWillNotDraw(false)
binding.messageInnerContainer.disableClipping()
binding.messageContentView.disableClipping()
binding.messageContentView.root.disableClipping()
}
// endregion
@ -123,6 +122,7 @@ class VisibleMessageView : LinearLayout {
contact: Contact?,
senderSessionID: String,
delegate: VisibleMessageViewDelegate?,
onAttachmentNeedsDownload: (Long, Long) -> Unit
) {
val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
@ -191,7 +191,15 @@ 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 (message.isOutgoing) {
val (iconID, iconColor, textId) = 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) {
@ -199,42 +207,58 @@ class VisibleMessageView : LinearLayout {
}
binding.messageStatusImageView.setImageDrawable(drawable)
}
if (message.isOutgoing) {
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
binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
if (message.reactions.isNotEmpty()) {
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
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.isVisible = false
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 {
@ -257,19 +281,23 @@ class VisibleMessageView : LinearLayout {
}
}
private fun getMessageStatusImage(message: MessageRecord): Pair<Int?,Int?> {
private fun getMessageStatusImage(message: MessageRecord): Triple<Int?,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
!message.isOutgoing -> Triple(null, null, null)
message.isFailed ->
Triple(R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), R.string.delivery_status_failed)
message.isPending ->
Triple(R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending)
message.isRead ->
Triple(R.drawable.ic_delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read)
else ->
Triple(R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sent)
}
}
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()
@ -280,7 +308,7 @@ class VisibleMessageView : LinearLayout {
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
container.layoutParams = containerParams
if (message.expiresIn > 0 && !message.isPending) {
binding.expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme))
binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary))
binding.expirationTimerView.isInvisible = false
binding.expirationTimerView.setPercentComplete(0.0f)
if (message.expireStarted > 0) {
@ -311,7 +339,7 @@ class VisibleMessageView : LinearLayout {
private fun handleIsSelectedChanged() {
background = if (snIsSelected) {
ColorDrawable(context.resources.getColorWithID(R.color.message_selected, context.theme))
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
} else {
null
}
@ -320,7 +348,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
@ -342,7 +370,7 @@ class VisibleMessageView : LinearLayout {
fun recycle() {
binding.profilePictureView.root.recycle()
binding.messageContentView.recycle()
binding.messageContentView.root.recycle()
}
// endregion
@ -438,7 +466,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) {
@ -458,7 +486,7 @@ class VisibleMessageView : LinearLayout {
}
fun playVoiceMessage() {
binding.messageContentView.playVoiceMessage()
binding.messageContentView.root.playVoiceMessage()
}
// endregion
}

View File

@ -78,7 +78,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
binding.voiceMessageViewDurationTextView.visibility = View.VISIBLE
binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs) % 60)
}
}
}
@ -102,7 +102,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
this.progress = progress
binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()),
TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong()))
TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong()) % 60)
val layoutParams = binding.progressView.layoutParams as RelativeLayout.LayoutParams
layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt()
binding.progressView.layoutParams = layoutParams

View File

@ -15,6 +15,7 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getAccentColor
import java.util.regex.Pattern
object MentionUtilities {
@ -58,13 +59,12 @@ object MentionUtilities {
}
val result = SpannableString(text)
val isLightMode = UiModeUtilities.isDayUiMode(context)
for (mention in mentions) {
val colorID = if (isOutgoingMessage) {
if (isLightMode) R.color.white else R.color.black
val color = if (isOutgoingMessage) {
ResourcesCompat.getColor(context.resources, if (isLightMode) R.color.white else R.color.black, context.theme)
} else {
R.color.accent
context.getAccentColor()
}
val color = ResourcesCompat.getColor(context.resources, colorID, context.theme)
for (mention in mentions) {
result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.visible.LinkPreview
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
@ -7,13 +8,14 @@ import org.session.libsession.messaging.messages.visible.Quote
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
object ResendMessageUtilities {
fun resend(messageRecord: MessageRecord) {
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) {
val recipient: Recipient = messageRecord.recipient
val message = VisibleMessage()
message.id = messageRecord.getId()
@ -44,6 +46,9 @@ object ResendMessageUtilities {
}
if (mmsMessageRecord.quote != null) {
message.quote = Quote.from(mmsMessageRecord.quote!!.quoteModel)
if (userBlindedKey != null && messageRecord.quote!!.author.serialize() == TextSecurePreferences.getLocalNumber(context)) {
message.quote!!.publicKey = userBlindedKey
}
}
message.addSignalAttachments(mmsMessageRecord.slideDeck.asAttachments())
}

View File

@ -7,8 +7,7 @@ import android.graphics.Rect
import android.os.SystemClock
import android.util.AttributeSet
import android.view.View
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.getAccentColor
import kotlin.math.sin
class ThumbnailProgressBar: View {
@ -25,7 +24,7 @@ class ThumbnailProgressBar: View {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = ResourcesCompat.getColor(resources, R.color.accent, null)
color = context.getAccentColor()
}
private val objectRect = Rect()

View File

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

View File

@ -5,7 +5,6 @@ import android.graphics.Bitmap
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.view.isVisible
@ -27,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)
@ -86,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)) {
@ -112,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()
@ -122,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, result))
}
slide.hasPlaceholder() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result))
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, result))
}
else -> {
glide.clear(image)
glide.clear(binding.thumbnailImage)
result.set(false)
}
}
@ -172,7 +173,7 @@ open class KThumbnailView: FrameLayout {
}
open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(image)
glideRequests.clear(binding.thumbnailImage)
slide = null
}
@ -189,11 +190,8 @@ open class KThumbnailView: FrameLayout {
request.transforms(CenterCrop())
}
request.into(GlideDrawableListeningTarget(image, future))
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, future))
return future
}
// endregion
}

View File

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

View File

@ -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;
@ -72,6 +72,11 @@ public abstract class Database {
context.getContentResolver().notifyChange(DatabaseContentProviders.StickerPack.CONTENT_URI, null);
}
protected void notifyRecipientListeners() {
context.getContentResolver().notifyChange(DatabaseContentProviders.Recipient.CONTENT_URI, null);
notifyConversationListListeners();
}
protected void setNotifyConverationListeners(Cursor cursor, long threadId) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
}

View File

@ -38,6 +38,10 @@ public class DatabaseContentProviders {
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.stickerpack");
}
public static class Recipient extends NoopContentProvider {
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.recipient");
}
private static abstract class NoopContentProvider extends ContentProvider {
@Override

View File

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

View File

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

View File

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

View File

@ -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,19 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
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);

View File

@ -51,9 +51,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
return mappings.map { it.role }
}
fun addGroupMember(member: GroupMember) {
fun setGroupMembers(members: List<GroupMember>) {
writableDatabase.beginTransaction()
try {
val grouped = members.groupBy { it.role }
grouped.forEach { (role, members) ->
if (members.isEmpty()) return@forEach
val toDeleteQuery = "$GROUP_ID = ? AND $ROLE = ?"
val toDeleteArgs = arrayOf(members.first().groupId, role.name)
writableDatabase.delete(TABLE_NAME, toDeleteQuery, toDeleteArgs)
members.forEach { member ->
val values = ContentValues().apply {
put(GROUP_ID, member.groupId)
put(PROFILE_ID, member.profileId)
@ -63,19 +73,9 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val args = arrayOf(member.groupId, member.profileId)
writableDatabase.insertOrUpdate(TABLE_NAME, values, query, args)
writableDatabase.setTransactionSuccessful()
} finally {
writableDatabase.endTransaction()
}
}
fun clearGroupMemberRoles(groupId: String) {
writableDatabase.beginTransaction()
try {
val query = "$GROUP_ID = ?"
val args = arrayOf(groupId)
writableDatabase.delete(TABLE_NAME, query, args)
writableDatabase.setTransactionSuccessful()
}
} finally {
writableDatabase.endTransaction()
}

View File

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

View File

@ -5,7 +5,7 @@ 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.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;

View File

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

View File

@ -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)
@ -136,6 +186,11 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
}
fun clearErrorMessage(messageID: Long) {
val database = databaseHelper.writableDatabase
database.delete(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
}
fun deleteThread(threadId: Long) {
val database = databaseHelper.writableDatabase
try {
@ -178,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)

View File

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

View File

@ -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;
@ -42,6 +42,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markAsDeleted(long messageId, boolean read);
public abstract boolean deleteMessage(long messageId);
public abstract boolean deleteMessages(long[] messageId, long threadId);
public abstract void updateThreadId(long fromId, long toId);

View File

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

View File

@ -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;
@ -112,6 +112,64 @@ public class MmsSmsDatabase extends Database {
return getMessageFor(timestamp, author.serialize());
}
public long getPreviousPage(long threadId, long fromTime, int limit) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" ASC";
String selection = MmsSmsColumns.THREAD_ID+" = "+threadId
+ " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+fromTime;
String limitStr = ""+limit;
long sent = -1;
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
if (cursor == null) return sent;
Reader reader = readerFor(cursor);
if (!cursor.move(limit)) {
cursor.moveToLast();
}
MessageRecord record = reader.getCurrent();
sent = record.getDateSent();
reader.close();
return sent;
}
public Cursor getConversationPage(long threadId, long fromTime, long toTime, int limit) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC";
String selection = MmsSmsColumns.THREAD_ID + " = "+threadId
+ " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" <= " + fromTime;
String limitStr = null;
if (toTime != -1L) {
selection += " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+toTime;
} else {
limitStr = ""+limit;
}
return queryTables(PROJECTION, selection, order, limitStr);
}
public boolean hasNextPage(long threadId, long toTime) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC";
String selection = MmsSmsColumns.THREAD_ID + " = "+threadId
+ " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" < " + toTime; // check if there's at least one message before the `toTime`
Cursor cursor = queryTables(PROJECTION, selection, order, null);
boolean hasNext = false;
if (cursor != null) {
hasNext = cursor.getCount() > 0;
cursor.close();
}
return hasNext;
}
public boolean hasPreviousPage(long threadId, long fromTime) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC";
String selection = MmsSmsColumns.THREAD_ID + " = "+threadId
+ " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > " + fromTime; // check if there's at least one message after the `fromTime`
Cursor cursor = queryTables(PROJECTION, selection, order, null);
boolean hasNext = false;
if (cursor != null) {
hasNext = cursor.getCount() > 0;
cursor.close();
}
return hasNext;
}
public Cursor getConversation(long threadId, boolean reverse, long offset, long limit) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC");
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
@ -199,16 +257,16 @@ public class MmsSmsDatabase extends Database {
return -1;
}
public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull Address address) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.ADDRESS }, selection, order, null)) {
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
String serializedAddress = address.serialize();
boolean isOwnNumber = Util.isOwnNumber(context, address.serialize());
while (cursor != null && cursor.moveToNext()) {
boolean timestampMatches = cursor.getLong(0) == receivedTimestamp;
boolean timestampMatches = cursor.getLong(0) == sentTimestamp;
boolean addressMatches = serializedAddress.equals(cursor.getString(1));
if (timestampMatches && (addressMatches || isOwnNumber)) {

View File

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

View File

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

View File

@ -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;
@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class RecipientDatabase extends Database {
@ -232,6 +233,7 @@ public class RecipientDatabase extends Database {
values.put(COLOR, color.serialize());
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setColor(color);
notifyRecipientListeners();
}
public void setDefaultSubscriptionId(@NonNull Recipient recipient, int defaultSubscriptionId) {
@ -239,6 +241,7 @@ public class RecipientDatabase extends Database {
values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setDefaultSubscriptionId(Optional.of(defaultSubscriptionId));
notifyRecipientListeners();
}
public void setForceSmsSelection(@NonNull Recipient recipient, boolean forceSmsSelection) {
@ -246,6 +249,7 @@ public class RecipientDatabase extends Database {
contentValues.put(FORCE_SMS_SELECTION, forceSmsSelection ? 1 : 0);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.resolve().setForceSmsSelection(forceSmsSelection);
notifyRecipientListeners();
}
public void setApproved(@NonNull Recipient recipient, boolean approved) {
@ -253,6 +257,7 @@ public class RecipientDatabase extends Database {
values.put(APPROVED, approved ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setApproved(approved);
notifyRecipientListeners();
}
public void setApprovedMe(@NonNull Recipient recipient, boolean approvedMe) {
@ -260,6 +265,7 @@ public class RecipientDatabase extends Database {
values.put(APPROVED_ME, approvedMe ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setHasApprovedMe(approvedMe);
notifyRecipientListeners();
}
public void setBlocked(@NonNull Recipient recipient, boolean blocked) {
@ -267,6 +273,24 @@ public class RecipientDatabase extends Database {
values.put(BLOCK, blocked ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setBlocked(blocked);
notifyRecipientListeners();
}
public void setBlocked(@NonNull List<Recipient> recipients, boolean blocked) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
ContentValues values = new ContentValues();
values.put(BLOCK, blocked ? 1 : 0);
for (Recipient recipient : recipients) {
db.update(TABLE_NAME, values, ADDRESS + " = ?", new String[]{recipient.getAddress().serialize()});
recipient.resolve().setBlocked(blocked);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyRecipientListeners();
}
public void setMuted(@NonNull Recipient recipient, long until) {
@ -274,6 +298,7 @@ public class RecipientDatabase extends Database {
values.put(MUTE_UNTIL, until);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setMuted(until);
notifyRecipientListeners();
}
/**
@ -287,6 +312,7 @@ public class RecipientDatabase extends Database {
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setNotifyType(notifyType);
notifyConversationListListeners();
notifyRecipientListeners();
}
public void setExpireMessages(@NonNull Recipient recipient, int expiration) {
@ -296,6 +322,7 @@ public class RecipientDatabase extends Database {
values.put(EXPIRE_MESSAGES, expiration);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setExpireMessages(expiration);
notifyRecipientListeners();
}
public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
@ -303,6 +330,7 @@ public class RecipientDatabase extends Database {
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setUnidentifiedAccessMode(unidentifiedAccessMode);
notifyRecipientListeners();
}
public void setProfileKey(@NonNull Recipient recipient, @Nullable byte[] profileKey) {
@ -310,6 +338,7 @@ public class RecipientDatabase extends Database {
values.put(PROFILE_KEY, profileKey == null ? null : Base64.encodeBytes(profileKey));
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setProfileKey(profileKey);
notifyRecipientListeners();
}
public void setProfileAvatar(@NonNull Recipient recipient, @Nullable String profileAvatar) {
@ -317,6 +346,7 @@ public class RecipientDatabase extends Database {
contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.resolve().setProfileAvatar(profileAvatar);
notifyRecipientListeners();
}
public void setProfileName(@NonNull Recipient recipient, @Nullable String profileName) {
@ -325,6 +355,7 @@ public class RecipientDatabase extends Database {
updateOrInsert(recipient.getAddress(), contentValues);
recipient.resolve().setName(profileName);
recipient.resolve().setProfileName(profileName);
notifyRecipientListeners();
}
public void setProfileSharing(@NonNull Recipient recipient, boolean enabled) {
@ -332,6 +363,7 @@ public class RecipientDatabase extends Database {
contentValues.put(PROFILE_SHARING, enabled ? 1 : 0);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.setProfileSharing(enabled);
notifyRecipientListeners();
}
public void setNotificationChannel(@NonNull Recipient recipient, @Nullable String notificationChannel) {
@ -339,6 +371,7 @@ public class RecipientDatabase extends Database {
contentValues.put(NOTIFICATION_CHANNEL, notificationChannel);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.setNotificationChannel(notificationChannel);
notifyRecipientListeners();
}
public void setRegistered(@NonNull Recipient recipient, RegisteredState registeredState) {
@ -346,6 +379,7 @@ public class RecipientDatabase extends Database {
contentValues.put(REGISTERED, registeredState.getId());
updateOrInsert(recipient.getAddress(), contentValues);
recipient.setRegistered(registeredState);
notifyRecipientListeners();
}
private void updateOrInsert(Address address, ContentValues contentValues) {
@ -365,6 +399,22 @@ public class RecipientDatabase extends Database {
database.endTransaction();
}
public List<Recipient> getBlockedContacts() {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, BLOCK + " = 1",
null, null, null, null, null);
RecipientReader reader = new RecipientReader(context, cursor);
List<Recipient> returnList = new ArrayList<>();
Recipient current;
while ((current = reader.getNext()) != null) {
returnList.add(current);
}
reader.close();
return returnList;
}
public static class RecipientReader implements Closeable {
private final Context context;

View File

@ -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;
@ -63,7 +63,7 @@ public class SearchDatabase extends Database {
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " +
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + SmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
@ -74,13 +74,13 @@ public class SearchDatabase extends Database {
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " +
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + MmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " +
"LIMIT ?";
private static final String MESSAGES_FOR_THREAD_QUERY =
@ -88,7 +88,7 @@ public class SearchDatabase extends Database {
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " +
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + SmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
@ -99,13 +99,13 @@ public class SearchDatabase extends Database {
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " +
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + MmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " +
"LIMIT 500";
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {

View File

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

View File

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

View File

@ -28,9 +28,10 @@ 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;
@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@ -596,6 +598,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);

View File

@ -7,28 +7,18 @@ import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.jobs.*
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.IncomingTextMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.signal.*
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
@ -38,7 +28,7 @@ import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.*
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
@ -318,12 +308,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return getAllOpenGroups().values.firstOrNull { it.server == server && it.room == room }
}
override fun clearGroupMemberRoles(groupId: String) {
DatabaseComponent.get(context).groupMemberDatabase().clearGroupMemberRoles(groupId)
}
override fun addGroupMemberRole(member: GroupMember) {
DatabaseComponent.get(context).groupMemberDatabase().addGroupMember(member)
override fun setGroupMemberRoles(members: List<GroupMember>) {
DatabaseComponent.get(context).groupMemberDatabase().setGroupMembers(members)
}
override fun isDuplicateMessage(timestamp: Long): Boolean {
@ -338,6 +324,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue)
}
override fun hasDownloadedProfilePicture(groupID: String): Boolean {
return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID)
}
override fun getReceivedMessageTimestamps(): Set<Long> {
return SessionMetaProtocol.getTimestamps()
}
@ -431,6 +421,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
}
}
override fun clearErrorMessage(messageID: Long) {
val db = DatabaseComponent.get(context).lokiMessageDatabase()
db.clearErrorMessage(messageID)
}
override fun setMessageServerHash(messageID: Long, serverHash: String) {
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash)
}
@ -565,8 +560,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) {
@ -673,7 +668,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
if (contact.didApproveMe == true) {
recipientDatabase.setApprovedMe(recipient, true)
threadDatabase.setHasSent(threadId, true)
}
if (contact.isApproved == true) {
recipientDatabase.setApproved(recipient, true)
@ -974,4 +968,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
}
override fun unblock(toUnblock: List<Recipient>) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setBlocked(toUnblock, false)
}
override fun blockedContacts(): List<Recipient> {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
return recipientDb.blockedContacts
}
}

View File

@ -32,7 +32,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;
@ -502,15 +502,23 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null);
}
public void setLastSeen(long threadId) {
public void setLastSeen(long threadId, long timestamp) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
if (timestamp == -1) {
contentValues.put(LAST_SEEN, System.currentTimeMillis());
} else {
contentValues.put(LAST_SEEN, timestamp);
}
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)});
notifyConversationListListeners();
}
public void setLastSeen(long threadId) {
setLastSeen(threadId, -1);
}
public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);

View File

@ -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;
@ -35,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 {
@ -75,40 +84,157 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV36 = 57;
private static final int lokiV37 = 58;
private static final int lokiV38 = 59;
private static final int lokiV39 = 60;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV38;
private static final String DATABASE_NAME = "signal.db";
private static final int DATABASE_VERSION = lokiV39;
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() {
super(context, DATABASE_NAME, databaseSecret.asString(), null, DATABASE_VERSION, MIN_DATABASE_VERSION, null, new SQLiteDatabaseHook() {
@Override
public void preKey(SQLiteDatabase db) {
db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;");
db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;");
public void preKey(SQLiteConnection connection) {
SQLCipherOpenHelper.applySQLCipherPragmas(connection, true);
}
@Override
public void postKey(SQLiteDatabase db) {
db.rawExecSQL("PRAGMA kdf_iter = '1';");
db.rawExecSQL("PRAGMA cipher_page_size = 4096;");
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) {
db.rawExecSQL("VACUUM;");
connection.execute("VACUUM;", null, null);
TextSecurePreferences.setLastVacuumNow(context);
}
}
});
}, true);
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; }
try {
// Define the location for the new database
String newDbPath = context.getDatabasePath(DATABASE_NAME).getPath();
File newDbFile = new File(newDbPath);
// 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()) {
// TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past
// //noinspection ResultOfMethodCallIgnored
// oldDbFile.delete();
return;
}
// 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);
// 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);
@ -188,6 +314,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);
}
@ -195,9 +322,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");
}
@ -414,20 +539,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
}
if (oldVersion < lokiV39) {
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public SQLiteDatabase getReadableDatabase() {
return getReadableDatabase(databaseSecret.asString());
}
public SQLiteDatabase getWritableDatabase() {
return getWritableDatabase(databaseSecret.asString());
}
public void markCurrent(SQLiteDatabase db) {
db.setVersion(DATABASE_VERSION);
}

View File

@ -33,6 +33,7 @@ import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient;
import java.util.List;
import java.util.Objects;
/**
* The base class for message record models that are displayed in
@ -140,14 +141,16 @@ public abstract class MessageRecord extends DisplayRecord {
return spannable;
}
@Override
public boolean equals(Object other) {
return other instanceof MessageRecord
&& ((MessageRecord) other).getId() == getId()
&& ((MessageRecord) other).isMms() == isMms();
}
@Override
public int hashCode() {
return (int)getId();
return Objects.hash(id, isMms());
}
public @NonNull List<ReactionRecord> getReactions() {

View File

@ -2,13 +2,15 @@ package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.utilities.Contact;
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import java.util.LinkedList;
import java.util.List;

View File

@ -8,6 +8,8 @@ import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel;
import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.mms.SlideDeck;
import java.util.Objects;
public class Quote {
private final long id;
@ -47,4 +49,17 @@ public class Quote {
public QuoteModel getQuoteModel() {
return new QuoteModel(id, author, text, missing, attachment.asAttachments());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Quote quote = (Quote) o;
return id == quote.id && missing == quote.missing && Objects.equals(author, quote.author) && Objects.equals(text, quote.text) && Objects.equals(attachment, quote.attachment);
}
@Override
public int hashCode() {
return Objects.hash(id, author, text, missing, attachment);
}
}

View File

@ -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.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret
@ -22,7 +22,7 @@ object DatabaseModule {
@JvmStatic
fun init(context: Context) {
SQLiteDatabase.loadLibs(context)
System.loadLibrary("sqlcipher")
}
@Provides
@ -33,6 +33,7 @@ object DatabaseModule {
@Singleton
fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper {
val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret
SQLCipherOpenHelper.migrateSqlCipher3To4IfNeeded(context, dbSecret)
return SQLCipherOpenHelper(context, dbSecret)
}

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.glide
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
@ -9,7 +8,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import org.session.libsession.avatars.PlaceholderAvatarPhoto
class PlaceholderAvatarLoader(private val context: Context): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
class PlaceholderAvatarLoader(): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
override fun buildLoadData(
model: PlaceholderAvatarPhoto,
@ -17,14 +16,14 @@ class PlaceholderAvatarLoader(private val context: Context): ModelLoader<Placeho
height: Int,
options: Options
): LoadData<BitmapDrawable> {
return LoadData(model, PlaceholderAvatarFetcher(context, model))
return LoadData(model, PlaceholderAvatarFetcher(model.context, model))
}
override fun handles(model: PlaceholderAvatarPhoto): Boolean = true
class Factory(private val context: Context) : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> {
class Factory() : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
return PlaceholderAvatarLoader(context)
return PlaceholderAvatarLoader()
}
override fun teardown() {}
}

View File

@ -7,9 +7,12 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentCreateGroupBinding
@ -57,6 +60,12 @@ class CreateGroupFragment : Fragment() {
}
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
binding.recyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
binding.recyclerView.addItemDecoration(divider)
var isLoading = false
binding.createClosedGroupButton.setOnClickListener {
if (isLoading) return@setOnClickListener

View File

@ -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 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,17 @@ 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
}
val openGroup = OpenGroup(server, room, info.name, info.infoUpdates, publicKey)
val openGroup = OpenGroup(server = server, room = room, publicKey = publicKey, name = info.name, imageId = info.imageId, infoUpdates = info.infoUpdates)
threadDB.setOpenGroupChat(openGroup, threadID)
return info
}
fun restartPollerForServer(server: String) {
@ -130,12 +129,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) {

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.home
import org.thoughtcrime.securesms.database.model.ThreadRecord
interface ConversationClickListener {
fun onConversationClick(thread: ThreadRecord)
fun onLongConversationClick(thread: ThreadRecord)
}

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -10,7 +11,7 @@ import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.util.UiModeUtilities
class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener {
class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener {
private lateinit var binding: FragmentConversationBottomSheetBinding
//FIXME AC: Supplying a threadRecord directly into the field from an activity
// is not the best idea. It doesn't survive configuration change.
@ -28,8 +29,8 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick
var onNotificationTapped: (() -> Unit)? = null
var onSetMuteTapped: ((Boolean) -> Unit)? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = FragmentConversationBottomSheetBinding.inflate(inflater, container, false)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentConversationBottomSheetBinding.inflate(LayoutInflater.from(parentContext), container, false)
return binding.root
}

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.home
import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
@ -20,6 +21,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor
import java.util.Locale
class ConversationView : LinearLayout {
@ -41,11 +43,19 @@ class ConversationView : LinearLayout {
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) {
this.thread = thread
background = if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_pin, 0)
ContextCompat.getDrawable(context, R.drawable.conversation_pinned_background)
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
0,
0,
R.drawable.ic_pin,
0
)
} else {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
background = if (thread.unreadCount > 0) {
ContextCompat.getDrawable(context, R.drawable.conversation_unread_background)
} else {
ContextCompat.getDrawable(context, R.drawable.conversation_view_background)
}
binding.profilePictureView.root.glide = glide
@ -54,7 +64,9 @@ class ConversationView : LinearLayout {
binding.accentView.setBackgroundResource(R.color.destructive)
binding.accentView.visibility = View.VISIBLE
} else {
binding.accentView.setBackgroundResource(R.color.accent)
val accentColor = context.getAccentColor()
val background = ColorDrawable(accentColor)
binding.accentView.background = background
// Using thread.isRead we can determine if the last message was our own, and display it as 'read' even though previous messages may not be
// This would also not trigger the disappearing message timer which may or may not be desirable
binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE
@ -65,9 +77,9 @@ class ConversationView : LinearLayout {
if (unreadCount < 10000) unreadCount.toString() else "9999+"
}
binding.unreadCountTextView.text = formattedUnreadCount
val textSize = if (unreadCount < 10000) 12.0f else 9.0f
val textSize = if (unreadCount < 1000) 12.0f else 10.0f
binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL)
binding.unreadCountIndicator.background.setTint(context.getAccentColor())
binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead)
val senderDisplayName = getUserDisplayName(thread.recipient)
?: thread.recipient.address.toString()
@ -87,11 +99,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

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.home
import android.app.AlertDialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -9,6 +8,7 @@ import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
@ -64,10 +64,10 @@ import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.themeState
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
@ -94,15 +94,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val publicKey: String
get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: NewHomeAdapter by lazy {
NewHomeAdapter(context = this, listener = this)
private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, listener = this)
}
private val globalSearchAdapter = GlobalSearchAdapter { model ->
when (model) {
is GlobalSearchAdapter.Model.Message -> {
val threadId = model.messageResult.threadId
val timestamp = model.messageResult.receivedTimestampMs
val timestamp = model.messageResult.sentTimestampMs
val author = model.messageResult.messageRecipient.address
val intent = Intent(this, ConversationActivityV2::class.java)
@ -167,30 +167,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.seedReminderView.isVisible = false
}
setupMessageRequestsBanner()
setupHeaderImage()
// Set up recycler view
binding.globalSearchInputLayout.listener = this
homeAdapter.setHasStableIds(true)
homeAdapter.glide = glide
binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity)
homeViewModel.getObservable(this).observe(this) { newData ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = newData
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
homeViewModel.tryUpdateChannel()
startObservingUpdates()
// Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() }
// Observe blocked contacts changed events
@ -214,7 +202,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 {
@ -276,12 +264,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
EventBus.getDefault().register(this@HomeActivity)
}
private fun setupHeaderImage() {
val isDayUiMode = UiModeUtilities.isDayUiMode(this)
val headerTint = if (isDayUiMode) R.color.black else R.color.white
binding.sessionHeaderImage.setColorFilter(getColor(headerTint))
}
override fun onInputFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
setSearchShown(true)
@ -294,9 +276,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.searchToolbar.isVisible = isShown
binding.sessionToolbar.isVisible = !isShown
binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as NewHomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.gradientView.isVisible = !isShown
binding.globalSearchRecycler.isVisible = isShown
binding.newConversationButton.isVisible = !isShown
}
@ -344,11 +325,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
}
}
// If the theme hasn't changed then start observing updates again (if it does change then we
// will recreate the activity resulting in it responding to changes multiple times)
if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) {
startObservingUpdates()
}
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false)
homeViewModel.getObservable(this).removeObservers(this)
}
override fun onDestroy() {
@ -362,6 +351,26 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// endregion
// region Updating
private fun startObservingUpdates() {
homeViewModel.getObservable(this).observe(this) { newData ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = newData
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
homeAdapter.typingThreadIDs = (threadIds ?: setOf())
}
}
private fun updateEmptyState() {
val threadCount = (binding.recyclerView.adapter)!!.itemCount
binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible
@ -405,7 +414,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
override fun onLongConversationClick(thread: ThreadRecord) {
val bottomSheet = ConversationOptionsBottomSheet()
val bottomSheet = ConversationOptionsBottomSheet(this)
bottomSheet.thread = thread
bottomSheet.onViewDetailsTapped = {
bottomSheet.dismiss()

View File

@ -1,8 +1,117 @@
package org.thoughtcrime.securesms.home
import android.content.Context
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 org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter(
private val context: Context,
private val listener: ConversationClickListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback {
companion object {
private const val HEADER = 0
private const val ITEM = 1
}
var header: View? = null
private var _data: List<ThreadRecord> = emptyList()
var data: List<ThreadRecord>
get() = _data.toList()
set(newData) {
val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context)
val diffResult = DiffUtil.calculateDiff(diff)
_data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
}
fun hasHeaderView(): Boolean = header != null
private val headerCount: Int
get() = if (header == null) 0 else 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position + headerCount, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position + headerCount, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition + headerCount, toPosition + headerCount)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position + headerCount, count, payload)
}
override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position
return _data[offsetPosition].threadId
}
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()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
HEADER -> {
HeaderFooterViewHolder(header!!)
}
ITEM -> {
val view = ConversationView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
view.thread?.let { listener.onLongConversationClick(it) }
true
}
ViewHolder(view)
}
else -> throw Exception("viewType $viewType isn't valid")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val offset = if (hasHeaderView()) position - 1 else position
val thread = data[offset]
val isTyping = typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide)
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ViewHolder) {
holder.view.recycle()
} else {
super.onViewRecycled(holder)
}
}
override fun getItemViewType(position: Int): Int =
if (hasHeaderView() && position == 0) HEADER
else ITEM
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
interface ConversationClickListener {
fun onConversationClick(thread: ThreadRecord)
fun onLongConversationClick(thread: ThreadRecord)
}

View File

@ -22,24 +22,28 @@ class HomeDiffUtil(
val newItem = new[newItemPosition]
// return early to save getDisplayBody or expensive calls
val sameCount = oldItem.count == newItem.count
if (!sameCount) return false
val sameUnreads = oldItem.unreadCount == newItem.unreadCount
if (!sameUnreads) return false
val samePinned = oldItem.isPinned == newItem.isPinned
if (!samePinned) return false
val sameAvatar = oldItem.recipient.profileAvatar == newItem.recipient.profileAvatar
if (!sameAvatar) return false
val sameUsername = oldItem.recipient.name == newItem.recipient.name
if (!sameUsername) return false
val sameSnippet = oldItem.getDisplayBody(context) == newItem.getDisplayBody(context)
if (!sameSnippet) return false
val sameSendStatus = oldItem.isFailed == newItem.isFailed && oldItem.isDelivered == newItem.isDelivered
&& oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending
if (!sameSendStatus) return false
var isSameItem = true
// all same
return true
if (isSameItem) { isSameItem = (oldItem.count == newItem.count) }
if (isSameItem) { isSameItem = (oldItem.unreadCount == newItem.unreadCount) }
if (isSameItem) { isSameItem = (oldItem.isPinned == newItem.isPinned) }
// Note: For some reason the 'hashCode' value can change after initialisation so we can't cache it
if (isSameItem) { isSameItem = (oldItem.recipient.hashCode() == newItem.recipient.hashCode()) }
// Note: Two instances of 'SpannableString' may not equate even though their content matches
if (isSameItem) { isSameItem = (oldItem.getDisplayBody(context).toString() == newItem.getDisplayBody(context).toString()) }
if (isSameItem) {
isSameItem = (
oldItem.isFailed == newItem.isFailed &&
oldItem.isDelivered == newItem.isDelivered &&
oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending
)
}
return isSameItem
}
}

View File

@ -7,23 +7,22 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import java.lang.ref.WeakReference
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() {
private val executor = viewModelScope + SupervisorJob()
private var lastContext: WeakReference<Context>? = null
private var updateJobs: MutableList<Job> = mutableListOf()
private val _conversations = MutableLiveData<List<ThreadRecord>>()
val conversations: LiveData<List<ThreadRecord>> = _conversations
@ -33,12 +32,23 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V
fun tryUpdateChannel() = listUpdateChannel.trySend(Unit)
fun getObservable(context: Context): LiveData<List<ThreadRecord>> {
// If the context has changed (eg. the activity gets recreated) then
// we need to cancel the old executors and recreate them to prevent
// the app from triggering extra updates when data changes
if (context != lastContext?.get()) {
lastContext = WeakReference(context)
updateJobs.forEach { it.cancel() }
updateJobs.clear()
updateJobs.add(
executor.launch(Dispatchers.IO) {
context.contentResolver
.observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
.onEach { listUpdateChannel.trySend(Unit) }
.collect()
}
)
updateJobs.add(
executor.launch(Dispatchers.IO) {
for (update in listUpdateChannel) {
threadDb.approvedConversationList.use { openCursor ->
@ -53,6 +63,8 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V
}
}
}
)
}
return conversations
}

View File

@ -1,114 +0,0 @@
package org.thoughtcrime.securesms.home
import android.content.Context
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 org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
class NewHomeAdapter(private val context: Context, private val listener: ConversationClickListener):
RecyclerView.Adapter<RecyclerView.ViewHolder>(),
ListUpdateCallback {
companion object {
private const val HEADER = 0
private const val ITEM = 1
}
var header: View? = null
private var _data: List<ThreadRecord> = emptyList()
var data: List<ThreadRecord>
get() = _data.toList()
set(newData) {
val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context)
val diffResult = DiffUtil.calculateDiff(diff)
_data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
}
fun hasHeaderView(): Boolean = header != null
private val headerCount: Int
get() = if (header == null) 0 else 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position + headerCount, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position + headerCount, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition + headerCount, toPosition + headerCount)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position + headerCount, count, payload)
}
override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position
return _data[offsetPosition].threadId
}
lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>()
set(value) {
field = value
// TODO: replace this with a diffed update or a partial change set with payloads
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
HEADER -> {
HeaderFooterViewHolder(header!!)
}
ITEM -> {
val view = ConversationView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
view.thread?.let { listener.onLongConversationClick(it) }
true
}
ViewHolder(view)
}
else -> throw Exception("viewType $viewType isn't valid")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val offset = if (hasHeaderView()) position - 1 else position
val thread = data[offset]
val isTyping = typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide)
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ViewHolder) {
holder.view.recycle()
} else {
super.onViewRecycled(holder)
}
}
override fun getItemViewType(position: Int): Int =
if (hasHeaderView() && position == 0) HEADER
else ITEM
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}

View File

@ -20,6 +20,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPathBinding
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.utilities.Snode
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.util.GlowViewUtilities
@ -30,6 +31,7 @@ import org.thoughtcrime.securesms.util.animateSizeChange
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getColorWithID
class PathActivity : PassphraseRequiredActionBarActivity() {
@ -131,7 +133,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
lineView.layoutParams = lineViewLayoutParams
mainContainer.addView(lineView)
val titleTextView = TextView(this)
titleTextView.setTextColor(resources.getColorWithID(R.color.text, theme))
titleTextView.setTextColor(getColorFromAttr(android.R.attr.textColorPrimary))
titleTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.medium_font_size))
titleTextView.text = title
titleTextView.textAlignment = TextView.TEXT_ALIGNMENT_VIEW_START
@ -144,7 +146,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
mainContainer.addView(titleContainer)
if (subtitle != null) {
val subtitleTextView = TextView(this)
subtitleTextView.setTextColor(resources.getColorWithID(R.color.text, theme))
subtitleTextView.setTextColor(getColorFromAttr(android.R.attr.textColorPrimary))
subtitleTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.small_font_size))
subtitleTextView.text = subtitle
subtitleTextView.textAlignment = TextView.TEXT_ALIGNMENT_VIEW_START
@ -185,7 +187,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private val dotView by lazy {
val result = PathDotView(context)
result.setBackgroundResource(R.drawable.accent_dot)
result.mainColor = resources.getColorWithID(R.color.accent, context.theme)
result.mainColor = context.getAccentColor()
result
}
@ -219,7 +221,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private fun setUpViewHierarchy() {
disableClipping()
val lineView = View(context)
lineView.setBackgroundColor(resources.getColorWithID(R.color.text, context.theme))
lineView.setBackgroundColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
val lineViewHeight = when (location) {
Location.Top, Location.Bottom -> resources.getDimensionPixelSize(R.dimen.path_row_height) / 2
Location.Middle -> resources.getDimensionPixelSize(R.dimen.path_row_height)
@ -255,13 +257,17 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private fun expand() {
dotView.animateSizeChange(R.dimen.path_row_dot_size, R.dimen.path_row_expanded_dot_size)
@ColorRes val startColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black
GlowViewUtilities.animateShadowColorChange(context, dotView, startColorID, R.color.accent)
val startColor = context.resources.getColorWithID(startColorID, context.theme)
val endColor = context.getAccentColor()
GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor)
}
private fun collapse() {
dotView.animateSizeChange(R.dimen.path_row_expanded_dot_size, R.dimen.path_row_dot_size)
@ColorRes val endColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black
GlowViewUtilities.animateShadowColorChange(context, dotView, R.color.accent, endColorID)
val startColor = context.getAccentColor()
val endColor = context.resources.getColorWithID(endColorID, context.theme)
GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor)
}
}
// endregion

View File

@ -6,10 +6,10 @@ import android.content.Intent
import android.content.IntentFilter
import android.graphics.Canvas
import android.graphics.Paint
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.util.getColorWithID
@ -46,7 +46,9 @@ class PathStatusView : View {
}
private fun initialize() {
if (!isInEditMode) {
update()
}
setWillNotDraw(false)
}
@ -87,12 +89,14 @@ class PathStatusView : View {
private fun update() {
if (OnionRequestAPI.paths.isNotEmpty()) {
setBackgroundResource(R.drawable.accent_dot)
mainColor = resources.getColorWithID(R.color.accent, context.theme)
sessionShadowColor = resources.getColorWithID(R.color.accent, context.theme)
val hasPathsColor = context.getColor(R.color.accent_green)
mainColor = hasPathsColor
sessionShadowColor = hasPathsColor
} else {
setBackgroundResource(R.drawable.paths_building_dot)
mainColor = resources.getColorWithID(R.color.paths_building, context.theme)
sessionShadowColor = resources.getColorWithID(R.color.paths_building, context.theme)
val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme)
mainColor = pathsBuildingColor
sessionShadowColor = pathsBuildingColor
}
}

View File

@ -6,6 +6,7 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -41,7 +42,9 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentUserDetailsBottomSheetBinding.inflate(inflater, container, false)
val wrappedContext = ContextThemeWrapper(requireActivity(), requireActivity().theme)
val themedInflater = inflater.cloneInContext(wrappedContext)
binding = FragmentUserDetailsBottomSheetBinding.inflate(themedInflater, container, false)
return binding.root
}

View File

@ -134,7 +134,7 @@ fun ContentView.bindModel(query: String?, model: Message) {
// if (hasUnreads) {
// binding.unreadCountTextView.text = model.unread.toString()
// }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.receivedTimestampMs)
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.root.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {

View File

@ -6,17 +6,19 @@ import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.ApplicationContext;
import network.loki.messenger.BuildConfig;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import java.util.List;
import java.util.UUID;
import network.loki.messenger.BuildConfig;
/**
* Schedules tasks using the {@link AlarmManager}.
*
@ -51,7 +53,7 @@ public class AlarmManagerScheduler implements Scheduler {
Intent intent = new Intent(context, RetryReceiver.class);
intent.setAction(BuildConfig.APPLICATION_ID + UUID.randomUUID().toString());
alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, 0));
alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE));
Log.i(TAG, "Set an alarm to retry a job in " + (time - System.currentTimeMillis()) + " ms.");
}

View File

@ -95,7 +95,6 @@ class KeyboardPageSearchView @JvmOverloads constructor(
val iconTint = typedArray.getColorStateList(R.styleable.KeyboardPageSearchView_search_icon_tint) ?: ContextCompat.getColorStateList(context, R.color.signal_icon_tint_tab_selected)
ImageViewCompat.setImageTintList(navButton, iconTint)
ImageViewCompat.setImageTintList(clearButton, iconTint)
input.setHintTextColor(iconTint)
val clickOnly: Boolean = typedArray.getBoolean(R.styleable.KeyboardPageSearchView_click_only, false)
if (clickOnly) {

View File

@ -5,16 +5,15 @@ import android.content.res.ColorStateList
import android.database.Cursor
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.forceShowIcon
class MessageRequestsAdapter(
context: Context,
@ -47,7 +46,7 @@ class MessageRequestsAdapter(
}
private fun showPopupMenu(view: MessageRequestView) {
val popupMenu = PopupMenu(context, view)
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) {
@ -64,7 +63,7 @@ class MessageRequestsAdapter(
item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
item.title = s
}
popupMenu.forceShowIcon()
popupMenu.setForceShowIcon(true)
popupMenu.show()
}

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