Merge branch 'dev' into comp

This commit is contained in:
andrew 2023-07-18 12:04:56 +09:30
commit 9a84f6c67b
210 changed files with 9010 additions and 2879 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "libsession-util/libsession-util"]
path = libsession-util/libsession-util
url = https://github.com/oxen-io/libsession-util.git

View file

@ -32,6 +32,7 @@ Setting up a development environment and building from Android Studio
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes".
5. Default config options should be good enough.
6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive`
Contributing code
-----------------

View file

@ -10,7 +10,7 @@ Add the [F-Droid repo](https://fdroid.getsession.org/)
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
<img src="https://i.imgur.com/dO9f7Hg.jpg" width="320" />
<img src="https://i.imgur.com/wcdAGBh.png" width="320" />
## Want to contribute? Found a bug or have a feature request?

View file

@ -103,17 +103,13 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation 'com.annimon:stream:1.1.8'
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 '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'
}
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
implementation project(":libsignal")
implementation project(":libsession")
implementation project(":libsession-util")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation project(":liblazysodium")
@ -126,45 +122,45 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.github.lelloman:android-identicons:v11"
implementation "com.prof.rssparser:rssparser:2.0.4"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
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:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.0.0"
testImplementation "org.mockito:mockito-inline:4.10.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation 'org.powermock:powermock-api-mockito:1.6.1'
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'
androidTestImplementation "org.mockito:mockito-android:4.10.0"
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library
androidTestImplementation 'androidx.test:core:1.4.0'
androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.ext:truth:1.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0'
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.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
@ -180,8 +176,8 @@ dependencies {
implementation 'androidx.compose.material:material:1.5.0-alpha02'
}
def canonicalVersionCode = 338
def canonicalVersionName = "1.16.9"
def canonicalVersionCode = 354
def canonicalVersionName = "1.17.0"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,

View file

@ -1,5 +1,6 @@
package network.loki.messenger
import android.Manifest
import android.app.Instrumentation
import android.content.ClipboardManager
import android.content.Context
@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
@ -85,6 +87,8 @@ class HomeActivityTests {
}
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
private fun goToMyChat() {
@ -100,6 +104,7 @@ class HomeActivityTests {
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
}
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
}

View file

@ -0,0 +1,102 @@
package network.loki.messenger
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@SmallTest
class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
get() = "fakehash${fakeHashI++}"
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key)
contactList.forEach { contact ->
contacts.set(contact)
}
return contacts.push().config
}
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
configBase.merge(nextFakeHash to toMerge)
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
}
@Before
fun setupUser() {
PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext).edit {
putBoolean(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, true).apply()
}
val newBytes = randomSeedBytes().toByteArray()
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val kp = KeyPairUtilities.generate(newBytes)
KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair)
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(context, registrationID)
TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(context, 0)
TextSecurePreferences.setHasViewedSeed(context, false)
}
@Test
fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val newContactId = randomSessionId()
val singleContact = Contact(
id = newContactId,
approved = true,
expiryMode = ExpiryMode.NONE
)
val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1
})
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
}

View file

@ -29,12 +29,16 @@
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<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" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

View file

@ -40,6 +40,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences;
@ -59,6 +60,8 @@ 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.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.emoji.EmojiSource;
@ -106,6 +109,8 @@ import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import kotlinx.coroutines.Job;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
/**
* Will be called once when the TextSecure process is created.
@ -116,7 +121,7 @@ import kotlinx.coroutines.Job;
* @author Moxie Marlinspike
*/
@HiltAndroidApp
public class ApplicationContext extends Application implements DefaultLifecycleObserver {
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener {
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
@ -137,9 +142,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private PersistentLogger persistentLogger;
@Inject LokiAPIDatabase lokiAPIDatabase;
@Inject Storage storage;
@Inject public Storage storage;
@Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences;
@Inject ConfigFactory configFactory;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@ -157,6 +163,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return (ApplicationContext) context.getApplicationContext();
}
public TextSecurePreferences getPrefs() {
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
}
public DatabaseComponent getDatabaseComponent() {
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
}
@ -183,6 +193,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return this.persistentLogger;
}
@Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
}
storage.notifyConfigUpdates(forConfigObject);
}
@Override
public void onCreate() {
DatabaseModule.init(this);
@ -191,7 +210,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
messagingModuleConfiguration = new MessagingModuleConfiguration(this,
storage,
messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
startKovenant();
@ -347,7 +368,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private void initializeProfileManager() {
this.profileManager = new ProfileManager();
this.profileManager = new ProfileManager(this, configFactory);
}
private void initializeTypingStatusSender() {
@ -440,7 +461,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey);
return;
}
poller = new Poller();
poller = new Poller(configFactory, new Timer());
}
public void startPollingIfNeeded() {
@ -483,6 +504,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
});
} catch (Exception exception) {
// Do nothing
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
}
});
}
@ -520,6 +542,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database.");
}
configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
}

View file

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaDialog {
companion object {
@JvmStatic
fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(
context.resources.getQuantityString(
R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount
)
)
text(
context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount
)
)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}

View file

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaPreviewDialog {
companion object {
@JvmStatic
fun show(context: Context, doDelete: Runnable) {
context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(R.string.MediaPreviewActivity_media_delete_confirmation_title)
text(R.string.MediaPreviewActivity_media_delete_confirmation_message)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}
}

View file

@ -1,88 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import org.session.libsession.utilities.ExpirationUtil;
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
import network.loki.messenger.R;
public class ExpirationDialog extends AlertDialog {
protected ExpirationDialog(Context context) {
super(context);
}
protected ExpirationDialog(Context context, int theme) {
super(context, theme);
}
protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
public static void show(final Context context,
final int currentExpiration,
final @NonNull OnClickListener listener)
{
final View view = createNumberPickerView(context, currentExpiration);
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
builder.setView(view);
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]);
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private static View createNumberPickerView(final Context context, final int currentExpiration) {
final LayoutInflater inflater = LayoutInflater.from(context);
final View view = inflater.inflate(R.layout.expiration_dialog, null);
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
final TextView textView = view.findViewById(R.id.expiration_details);
final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
final String[] expirationDisplayValues = new String[expirationTimes.length];
int selectedIndex = expirationTimes.length - 1;
for (int i=0;i<expirationTimes.length;i++) {
expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]);
if ((currentExpiration >= expirationTimes[i]) &&
(i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
selectedIndex = i;
}
}
numberPickerView.setDisplayedValues(expirationDisplayValues);
numberPickerView.setMinValue(0);
numberPickerView.setMaxValue(expirationTimes.length-1);
NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
if (newVal == 0) {
textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
} else {
textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
}
};
numberPickerView.setOnValueChangedListener(listener);
numberPickerView.setValue(selectedIndex);
listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
return view;
}
public interface OnClickListener {
public void onClick(int expirationTime);
}
}

View file

@ -0,0 +1,51 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import cn.carbswang.android.numberpickerview.library.NumberPickerView
import network.loki.messenger.R
import org.session.libsession.utilities.ExpirationUtil
fun Context.showExpirationDialog(
expiration: Int,
onExpirationTime: (Int) -> Unit
): AlertDialog {
val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker)
fun updateText(index: Int) {
view.findViewById<TextView>(R.id.expiration_details).text = when (index) {
0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire)
else -> getString(
R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen,
numberPickerView.displayedValues[index]
)
}
}
val expirationTimes = resources.getIntArray(R.array.expiration_times)
val expirationDisplayValues = expirationTimes
.map { ExpirationUtil.getExpirationDisplayValue(this, it) }
.toTypedArray()
val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) }
numberPickerView.apply {
displayedValues = expirationDisplayValues
minValue = 0
maxValue = expirationTimes.lastIndex
setOnValueChangedListener { _, _, index -> updateText(index) }
value = selectedIndex
}
updateText(selectedIndex)
return showSessionDialog {
title(getString(R.string.ExpirationDialog_disappearing_messages))
view(view)
okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) }
cancelButton()
}
}

View file

@ -76,6 +76,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
/**
@ -318,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
final Context context = getContext();
final Context context = requireContext();
SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> {
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
@ -362,7 +363,8 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
}.execute();
})
.execute();
}, mediaRecords.size());
return Unit.INSTANCE;
});
}
private void sendMediaSavedNotificationIfNeeded() {
@ -374,41 +376,26 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressLint("StaticFieldLeak")
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size();
Resources res = getContext().getResources();
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount);
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount);
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setTitle(confirmTitle);
builder.setMessage(confirmMessage);
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> {
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message)
{
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
DeleteMediaDialog.show(
requireContext(),
recordCount,
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
requireContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message) {
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]));
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
}
private void handleSelectAllMedia() {

View file

@ -85,6 +85,7 @@ import java.io.IOException;
import java.util.Locale;
import java.util.WeakHashMap;
import kotlin.Unit;
import network.loki.messenger.R;
/**
@ -420,7 +421,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return;
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
SaveAttachmentTask.showWarningDialog(this, 1, () -> {
Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
@ -437,6 +438,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
})
.execute();
return Unit.INSTANCE;
});
}
@ -453,29 +455,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
if (mediaItem.attachment == null) {
return null;
}
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
mediaItem.attachment);
return null;
}
}.execute();
DeleteMediaPreviewDialog.show(this, () -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
DatabaseAttachment attachment = mediaItem.attachment;
if (attachment != null) {
AttachmentUtil.deleteAttachment(getApplicationContext(), attachment);
}
return null;
}
}.execute();
finish();
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
@Override

View file

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.content.DialogInterface;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class MuteDialog extends AlertDialog {
protected MuteDialog(Context context) {
super(context);
}
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
protected MuteDialog(Context context, int theme) {
super(context, theme);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, final int which) {
final long muteUntil;
switch (which) {
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
}
});
builder.show();
}
public interface MuteSelectionListener {
public void onMuted(long until);
}
}

View file

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms
import android.content.Context
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R
import java.util.concurrent.TimeUnit
fun showMuteDialog(
context: Context,
onMuteDuration: (Long) -> Unit
): AlertDialog = context.showSessionDialog {
title(R.string.MuteDialog_mute_notifications)
items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) {
onMuteDuration(Option.values()[it].getTime())
}
}
private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) {
ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)),
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)),
ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)),
SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)),
FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE });
constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration })
}

View file

@ -0,0 +1,146 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.core.view.setPadding
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl
@DialogDsl
class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss()
private val topView = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context)
private val root = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setView)
.apply {
addView(contentView)
addView(buttonLayout)
}
fun title(@StringRes id: Int) = title(context.getString(id))
fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) {
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
}
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
.apply { updateMargins(dp40, 0, dp40, dp20) }
}
}
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return
TextView(context, null, 0, style)
.apply {
setText(text)
textAlignment = View.TEXT_ALIGNMENT_CENTER
modify()
}.let(topView::addView)
}
fun view(view: View) = contentView.addView(view)
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
fun iconAttribute(@AttrRes icon: Int): AlertDialog.Builder = dialogBuilder.setIconAttribute(icon)
fun singleChoiceItems(
options: Collection<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect)
fun singleChoiceItems(
options: Array<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems(
options,
currentSelected
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun items(
options: Array<String>,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setItems(
options,
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun destructiveButton(
@StringRes text: Int,
@StringRes contentDescription: Int,
listener: () -> Unit = {}
) = button(
text,
contentDescription,
R.style.Widget_Session_Button_Dialog_DestructiveText,
listener
)
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok, listener = listener)
fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button, listener = listener)
fun button(
@StringRes text: Int,
@StringRes contentDescriptionRes: Int = text,
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
listener: (() -> Unit) = {}
) = Button(context, null, 0, style).apply {
setText(text)
contentDescription = resources.getString(contentDescriptionRes)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
.apply { setMargins(toPx(20, resources)) }
setOnClickListener {
listener.invoke()
dismiss()
}
}.let(buttonLayout::addView)
fun create(): AlertDialog = dialogBuilder.create().also { dialog = it }
fun show(): AlertDialog = dialogBuilder.show().also { dialog = it }
}
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply { build() }.show()
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.show()
fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.create()

View file

@ -35,10 +35,10 @@ class ProfilePictureView @JvmOverloads constructor(
var isLarge = false
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)
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion
@ -60,12 +60,19 @@ class ProfilePictureView @JvmOverloads constructor(
.sorted()
.take(2)
.toMutableList()
val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk
displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
if (members.size <= 1) {
publicKey = ""
displayName = ""
additionalPublicKey = ""
additionalDisplayName = ""
} else {
val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk
displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
this.publicKey = publicKey
@ -115,30 +122,36 @@ class ProfilePictureView @JvmOverloads constructor(
val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView)
glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable)
.centerCrop()
.error(unknownRecipientDrawable)
.error(glide.load(placeholder))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop()
.into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView)
imageView.setImageDrawable(unknownOpenGroupDrawable)
glide.load(unknownOpenGroupDrawable)
.centerCrop()
.circleCrop()
.into(imageView)
} else {
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
glide.clear(imageView)
glide.load(placeholder)
.placeholder(unknownRecipientDrawable)
.centerCrop()
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
}
profilePicturesCache[publicKey] = recipient.profileAvatar
} else {
imageView.setImageDrawable(null)
glide.load(unknownRecipientDrawable)
.centerCrop()
.into(imageView)
}
}

View file

@ -7,6 +7,7 @@ import android.view.View
import android.widget.LinearLayout
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
@ -47,12 +48,12 @@ class UserView : LinearLayout {
// region Updating
fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) {
val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user)
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this
val address = user.address.serialize()
binding.profilePictureView.update(user)
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)

View file

@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() {
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
ContactListItem.Contact(it, displayName)
}.sortedBy { it.displayName }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
.toMutableMap()
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }

View file

@ -3,18 +3,33 @@ package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.*
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.database.Cursor
import android.graphics.Rect
import android.graphics.Typeface
import android.net.Uri
import android.os.*
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils
import android.text.style.StyleSpan
import android.util.Pair
import android.util.TypedValue
import android.view.*
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.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.Toast
@ -22,11 +37,16 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.DimenRes
import androidx.appcompat.app.AlertDialog
import androidx.core.text.set
import androidx.core.text.toSpannable
import androidx.core.view.drawToBitmap
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
@ -34,6 +54,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
@ -60,8 +85,12 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.*
import org.session.libsession.utilities.Address
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
@ -72,7 +101,6 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
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
@ -98,10 +126,24 @@ 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.*
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
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.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.*
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.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -114,14 +156,31 @@ 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.*
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.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.util.*
import org.thoughtcrime.securesms.showExpirationDialog
import org.thoughtcrime.securesms.showSessionDialog
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.isScrolledToBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference
import java.util.*
import java.util.Locale
import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@ -190,11 +249,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
it
}
val recipient = Recipient.from(this, address, false)
threadId = threadDb.getOrCreateThreadIdFor(recipient)
threadId = storage.getOrCreateThreadIdFor(recipient.address)
}
} ?: finish()
}
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver)
}
private var actionMode: ActionMode? = null
private var unreadCount = 0
@ -215,6 +274,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null
private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var emojiPickerVisible = false
private val isScrolledToBottom: Boolean
@ -234,11 +294,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
}
// There is a bug when initially joining a community where all messages will immediately be marked
// as read if we reverse the message list so this is now hard-coded to false
private val reverseMessageList = false
private val adapter by lazy {
val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread())
val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList)
val adapter = ConversationAdapter(
this,
cursor,
storage.getLastSeen(viewModel.threadId),
reverseMessageList,
onItemPress = { message, position, view, event ->
handlePress(message, position, view, event)
},
@ -280,6 +346,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) }
private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null)
private val firstLoad = AtomicBoolean(true)
private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1
@ -324,28 +391,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
if (layoutManager.isSmoothScrolling) {
binding?.conversationRecyclerView?.scrollToPosition(0)
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
} 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)
}
// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
// if (position > targetBuffer) {
// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer)
// }
binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition)
}
}
}
updateUnreadCountIndicator()
updateSubtitle()
updatePlaceholder()
setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText()
@ -355,20 +425,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) {
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Note: We are accessing the `adapter` property because we want it to be loaded on
// the background thread to avoid blocking the UI thread and potentially hanging when
// transitioning to the activity
weakActivity.get()?.adapter ?: return@launch
// 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad'
// by triggering 'jumpToMessage' using these values
val messageTimestamp = messageToScrollTimestamp.get()
val author = messageToScrollAuthor.get()
val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1
withContext(Dispatchers.Main) {
setUpRecyclerView()
setUpTypingObserver()
setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
}
else {
scrollToFirstUnreadMessageIfNeeded(true)
}
}
}
@ -376,16 +456,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub)
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow()
bufferedFlow.filter {
it > storage.getLastSeen(viewModel.threadId)
}.collectLatest { latestMessageRead ->
withContext(Dispatchers.IO) {
storage.markConversationAsRead(viewModel.threadId, latestMessageRead)
}
}
}
}
}
override fun onResume() {
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,
@ -412,23 +501,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
push(intent, false)
}
override fun showDialog(baseDialog: BaseDialog, tag: String?) {
baseDialog.show(supportFragmentManager, tag)
override fun showDialog(dialogFragment: DialogFragment, tag: String?) {
dialogFragment.show(supportFragmentManager, tag)
}
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2)
return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
val oldCount = adapter.itemCount
val newCount = cursor?.count ?: 0
adapter.changeCursor(cursor)
if (cursor != null) {
val messageTimestamp = messageToScrollTimestamp.getAndSet(-1)
val author = messageToScrollAuthor.getAndSet(null)
val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Update the unreadCount value to be loaded from the database since we got a new message
if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) {
// Update the unreadCount value to be loaded from the database since we got a new
// message (we need to store it in a local variable as it can get overwritten on
// another thread before the 'firstLoad.getAndSet(false)' case below)
unreadCount = initialUnreadCount
updateUnreadCountIndicator()
}
if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, null)
jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
}
else if (firstLoad.getAndSet(false)) {
scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled()
}
else if (oldCount != newCount) {
handleRecyclerViewScrolled()
}
}
updatePlaceholder()
}
override fun onLoaderReset(cursor: Loader<Cursor>) {
@ -438,7 +549,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate
private fun setUpRecyclerView() {
binding!!.conversationRecyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread())
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
binding!!.conversationRecyclerView.layoutManager = layoutManager
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, this)
@ -447,6 +558,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
handleRecyclerViewScrolled()
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
}
})
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
@ -581,7 +696,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) }
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
}
private fun setUpLinkPreviewObserver() {
@ -614,15 +729,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (uiState.isMessageRequestAccepted == true) {
binding?.messageRequestBar?.visibility = View.GONE
}
if (!uiState.conversationExists && !isFinishing) {
// Conversation should be deleted now, just go back
finish()
}
}
}
}
private fun scrollToFirstUnreadMessageIfNeeded() {
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first()
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return
if (lastSeenItemPosition <= 3) { return }
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1
// If this is triggered when first opening a conversation then we want to position the top
// of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition
}
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition
}
private fun highlightViewAtPosition(position: Int) {
binding?.conversationRecyclerView?.post {
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
}
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
@ -706,11 +843,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun acceptMessageRequest() {
binding?.messageRequestBar?.isVisible = false
binding?.conversationRecyclerView?.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true)
adapter.notifyDataSetChanged()
viewModel.acceptMessageRequest()
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
}
@ -908,17 +1042,60 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun handleRecyclerViewScrolled() {
// FIXME: Checking isScrolledToBottom is a quick fix for an issue where the
// typing indicator overlays the recycler view when scrolled up
val binding = binding ?: return
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable()
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition)
if (visibleItemTimestamp != null) {
bufferedLastSeenChannel.trySend(visibleItemTimestamp)
}
}
if (reverseMessageList) {
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
}
else {
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
?: RecyclerView.NO_POSITION
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
}
updateUnreadCountIndicator()
}
private fun updatePlaceholder() {
val recipient = viewModel.recipient
?: return Log.w("Loki", "recipient was null in placeholder update")
val binding = binding ?: return
val openGroup = viewModel.openGroup
val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
}
val showPlaceholder = adapter.itemCount == 0
binding.placeholderText.isVisible = showPlaceholder
if (showPlaceholder) {
if (insertParam != null) {
val span = getText(textResource) as SpannedString
val annotations = span.getSpans(0, span.length, StyleSpan::class.java)
val boldSpan = annotations.first()
val spannedParam = insertParam.toSpannable()
spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style)
val originalStart = span.getSpanStart(boldSpan)
val originalEnd = span.getSpanEnd(boldSpan)
val newString = SpannableStringBuilder(span)
.replace(originalStart, originalEnd, spannedParam)
binding.placeholderText.text = newString
} else {
binding.placeholderText.setText(textResource)
}
}
}
private fun showScrollToBottomButtonIfApplicable() {
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
}
@ -970,21 +1147,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun block(deleteThread: Boolean) {
val title = R.string.RecipientPreferenceActivity_block_this_contact_question
val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact
val dialog = AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ ->
viewModel.block(this@ConversationActivityV2)
showSessionDialog {
title(R.string.RecipientPreferenceActivity_block_this_contact_question)
text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) {
viewModel.block()
if (deleteThread) {
viewModel.deleteThread()
finish()
}
}.show()
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
button.setContentDescription("Confirm block")
}
cancelButton()
}
}
override fun copySessionID(sessionId: String) {
@ -1011,28 +1185,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
if (group?.isActive == false) { return }
}
ExpirationDialog.show(this, thread.expireMessages) { expirationTime: Int ->
recipientDb.setExpireMessages(thread, expirationTime)
showExpirationDialog(thread.expireMessages) { expirationTime ->
storage.setExpirationTimer(thread.address.serialize(), expirationTime)
val message = ExpirationTimerUpdate(expirationTime)
message.recipient = thread.address.serialize()
message.sentTimestamp = SnodeAPI.nowWithOffset
val expiringMessageManager = ApplicationContext.getInstance(this).expiringMessageManager
expiringMessageManager.setExpirationTimer(message)
ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message)
MessageSender.send(message, thread.address)
invalidateOptionsMenu()
}
}
override fun unblock() {
val title = R.string.ConversationActivity_unblock_this_contact_question
val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.ConversationActivity_unblock) { _, _ ->
viewModel.unblock(this@ConversationActivityV2)
}.show()
showSessionDialog {
title(R.string.ConversationActivity_unblock_this_contact_question)
text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
destructiveButton(
R.string.ConversationActivity_unblock,
R.string.AccessibilityId_block_confirm
) { viewModel.unblock() }
cancelButton()
}
}
// `position` is the adapter position; not the visual position
@ -1376,11 +1549,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return
}
val binding = binding ?: return
if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
} else {
sendTextOnlyMessage()
}
// Jump to the newly sent message once it gets added
if (sentMessageInfo != null) {
messageToScrollAuthor.set(sentMessageInfo.first)
messageToScrollTimestamp.set(sentMessageInfo.second)
}
}
override fun commitInputContent(contentUri: Uri) {
@ -1398,19 +1577,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) {
val recipient = viewModel.recipient ?: return
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval()
val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) {
val dialog = SendSeedDialog { sendTextOnlyMessage(true) }
return dialog.show(supportFragmentManager, "Send Seed Dialog")
dialog.show(supportFragmentManager, "Send Seed Dialog")
return null
}
// Create the message
val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset
message.sentTimestamp = sentTimestamp
message.text = text
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient)
// Clear the input bar
@ -1427,14 +1608,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address)
// Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
}
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) {
val recipient = viewModel.recipient ?: return
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval()
// Create the message
val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset
message.sentTimestamp = sentTimestamp
message.text = body
val quote = quotedMessage?.let {
val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf()
@ -1468,28 +1651,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
// Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
}
private fun showGIFPicker() {
val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning()
if (!hasSeenGIFMetaDataWarning) {
val builder = AlertDialog.Builder(this)
builder.setTitle(R.string.giphy_permission_title)
builder.setMessage(R.string.giphy_permission_message)
builder.setPositiveButton(R.string.continue_2) { dialog: DialogInterface, _: Int ->
textSecurePreferences.setHasSeenGIFMetaDataWarning()
AttachmentManager.selectGif(this, PICK_GIF)
dialog.dismiss()
showSessionDialog {
title(R.string.giphy_permission_title)
text(R.string.giphy_permission_message)
button(R.string.continue_2) {
textSecurePreferences.setHasSeenGIFMetaDataWarning()
selectGif()
}
cancelButton()
}
builder.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
}
builder.create().show()
} else {
AttachmentManager.selectGif(this, PICK_GIF)
selectGif()
}
}
private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF)
private fun showDocumentPicker() {
AttachmentManager.selectDocument(this, PICK_DOCUMENT)
}
@ -1589,7 +1772,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showVoiceMessageUI()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
audioRecorder.startRecording()
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
} else {
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
@ -1636,35 +1819,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
if (recipient.isOpenGroupRecipient) {
val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setCancelable(true)
builder.setPositiveButton(R.string.delete) { _, _ ->
for (message in messages) {
viewModel.deleteForEveryone(message)
}
endActionMode()
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
cancelButton { endActionMode() }
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
} else if (allSentByCurrentUser && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient
bottomSheet.onDeleteForMeTapped = {
for (message in messages) {
viewModel.deleteLocally(message)
}
messages.forEach(viewModel::deleteLocally)
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.onDeleteForEveryoneTapped = {
for (message in messages) {
viewModel.deleteForEveryone(message)
}
messages.forEach(viewModel::deleteForEveryone)
bottomSheet.dismiss()
endActionMode()
}
@ -1675,54 +1846,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else {
val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setCancelable(true)
builder.setPositiveButton(R.string.delete) { _, _ ->
for (message in messages) {
viewModel.deleteLocally(message)
}
endActionMode()
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
cancelButton(::endActionMode)
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
}
}
override fun banUser(messages: Set<MessageRecord>) {
val builder = AlertDialog.Builder(this)
builder.setTitle(R.string.ConversationFragment_ban_selected_user)
builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms.")
builder.setCancelable(true)
builder.setPositiveButton(R.string.ban) { _, _ ->
viewModel.banUser(messages.first().individualRecipient)
endActionMode()
showSessionDialog {
title(R.string.ConversationFragment_ban_selected_user)
text("This will ban the selected user from this room. It won't ban them from other rooms.")
button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
cancelButton(::endActionMode)
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
}
override fun banAndDeleteAll(messages: Set<MessageRecord>) {
val builder = AlertDialog.Builder(this)
builder.setTitle(R.string.ConversationFragment_ban_selected_user)
builder.setMessage("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
builder.setCancelable(true)
builder.setPositiveButton(R.string.ban) { _, _ ->
viewModel.banAndDeleteAll(messages.first().individualRecipient)
endActionMode()
showSessionDialog {
title(R.string.ConversationFragment_ban_selected_user)
text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() }
cancelButton(::endActionMode)
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
}
override fun copyMessages(messages: Set<MessageRecord>) {
@ -1800,7 +1949,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun saveAttachment(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord
SaveAttachmentTask.showWarningDialog(this, { _, _ ->
SaveAttachmentTask.showWarningDialog(this) {
Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
@ -1828,7 +1977,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.LENGTH_LONG).show()
}
.execute()
})
}
}
override fun reply(messages: Set<MessageRecord>) {
@ -1886,7 +2035,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer
if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) {
jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) {
searchViewModel.onMissingResult() }
}
}
@ -1923,15 +2072,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
this.searchViewModel.onMoveDown()
}
private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) {
private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) {
SimpleTask.run(lifecycle, {
mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author)
}) { p: Int -> moveToMessagePosition(p, onMessageNotFound) }
mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList)
}) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) }
}
private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) {
private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
if (position >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(position)
if (highlight) {
runOnUiThread {
highlightViewAtPosition(position)
}
}
} else {
onMessageNotFound?.run()
}

View file

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.database.Cursor
@ -31,10 +30,15 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.min
class ConversationAdapter(
context: Context,
cursor: Cursor,
originalLastSeen: Long,
private val isReversed: Boolean,
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
@ -52,6 +56,8 @@ class ConversationAdapter(
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val contactCache = SparseArray<Contact>(100)
private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen)
init {
lifecycleCoroutineScope.launch(IO) {
while (isActive) {
@ -128,6 +134,7 @@ class ConversationAdapter(
searchQuery,
contact,
senderId,
lastSeen.get(),
visibleMessageViewDelegate,
onAttachmentNeedsDownload
)
@ -146,17 +153,15 @@ class ConversationAdapter(
viewHolder.view.bind(message, messageBefore)
if (message.isCallLog && message.isFirstMissedCall) {
viewHolder.view.setOnClickListener {
AlertDialog.Builder(context)
.setTitle(R.string.CallNotificationBuilder_first_call_title)
.setMessage(R.string.CallNotificationBuilder_first_call_message)
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
val intent = Intent(context, PrivacySettingsActivity::class.java)
context.startActivity(intent)
context.showSessionDialog {
title(R.string.CallNotificationBuilder_first_call_title)
text(R.string.CallNotificationBuilder_first_call_message)
button(R.string.activity_settings_title) {
Intent(context, PrivacySettingsActivity::class.java)
.let(context::startActivity)
}
.setNeutralButton(R.string.cancel) { d, _ ->
d.dismiss()
}
.show()
cancelButton()
}
}
} else {
viewHolder.view.setOnClickListener(null)
@ -185,14 +190,18 @@ class ConversationAdapter(
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually before the current one is actually after the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position + 1)) { return null }
if (isReversed && !cursor.moveToPosition(position + 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position - 1)) { return null }
return messageDB.readerFor(cursor).current
}
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually after the current one is actually before the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position - 1)) { return null }
if (isReversed && !cursor.moveToPosition(position - 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position + 1)) { return null }
return messageDB.readerFor(cursor).current
}
@ -219,11 +228,30 @@ class ConversationAdapter(
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
val cursor = this.cursor
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null
if (cursor == null || !isActiveCursor) return null
if (lastSeenTimestamp == 0L) {
if (isReversed && cursor.moveToLast()) { return cursor.position }
if (!isReversed && cursor.moveToFirst()) { return cursor.position }
}
// Loop from the newest message to the oldest until we find one older (or equal to)
// the lastSeenTimestamp, then return that message index
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i }
if (isReversed) {
cursor.moveToPosition(i)
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return i
}
}
else {
val index = ((itemCount - 1) - i)
cursor.moveToPosition(index)
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return min(itemCount - 1, (index + 1))
}
}
}
return null
}
@ -233,8 +261,8 @@ class ConversationAdapter(
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.dateSent == timestamp) { return i }
val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (dateSent == timestamp) { return i }
}
return null
}
@ -243,4 +271,11 @@ class ConversationAdapter(
this.searchQuery = query
notifyDataSetChanged()
}
fun getTimestampForItemAt(firstVisiblePosition: Int): Long? {
val cursor = this.cursor ?: return null
if (!cursor.moveToPosition(firstVisiblePosition)) return null
val message = messageDB.readerFor(cursor).current ?: return null
return message.timestamp
}
}

View file

@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.content.ContentResolver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -21,15 +21,16 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
private val contentResolver: ContentResolver,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModel() {
@ -37,7 +38,7 @@ class ConversationViewModel(
val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState())
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
@ -61,6 +62,18 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
}
init {
viewModelScope.launch(Dispatchers.IO) {
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
.collect {
val recipientExists = storage.getRecipientForThread(threadId) != null
if (!recipientExists && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) }
}
}
}
}
fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text)
@ -81,27 +94,17 @@ class ConversationViewModel(
repository.inviteContacts(threadId, contacts)
}
fun block(context: Context) {
fun block() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
if (recipient.isContactRecipient) {
repository.setBlocked(recipient, true)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
fun unblock(context: Context) {
fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
if (recipient.isContactRecipient) {
repository.setBlocked(recipient, false)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
@ -198,19 +201,20 @@ class ConversationViewModel(
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
@Assisted private val contentResolver: ContentResolver,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
}
}
}
@ -219,7 +223,8 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null
val isMessageRequestAccepted: Boolean? = null,
val conversationExists: Boolean
)
data class RetrieveOnce<T>(val retrieval: () -> T?) {

View file

@ -1,51 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogBlockedBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon sending a message to a user that's blocked. */
class BlockedDialog(private val recipient: Recipient, private val context: Context) : BaseDialog() {
class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_blocked_title, name)
binding.blockedTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.blockedExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.unblockButton.setOnClickListener { unblock() }
builder.setView(binding.root)
title(resources.getString(R.string.dialog_blocked_title, name))
text(spannable)
button(R.string.ConversationActivity_unblock) { unblock() }
cancelButton { dismiss() }
}
private fun unblock() {
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false)
MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false)
dismiss()
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
}

View file

@ -1,19 +1,19 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogDownloadBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject
@ -21,25 +21,24 @@ import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint
class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext()))
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_download_title, name)
binding.downloadTitleTextView.text = title
title(resources.getString(R.string.dialog_download_title, name))
val explanation = resources.getString(R.string.dialog_download_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.downloadExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.downloadButton.setOnClickListener { trust() }
builder.setView(binding.root)
text(spannable)
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
cancelButton { dismiss() }
}
private fun trust() {
@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss()
}
}
}

View file

@ -1,46 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogJoinOpenGroupBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon tapping an open group invitation. */
class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() {
class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext()))
val title = resources.getString(R.string.dialog_join_open_group_title, name)
binding.joinOpenGroupTitleTextView.text = title
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(resources.getString(R.string.dialog_join_open_group_title, name))
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.joinOpenGroupExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.joinButton.setOnClickListener { join() }
builder.setView(binding.root)
text(spannable)
cancelButton { dismiss() }
button(R.string.open_group_invitation_view__join_accessibility_description) { join() }
}
private fun join() {
val openGroup = OpenGroupUrlParser.parseUrl(url)
val activity = requireContext() as AppCompatActivity
val activity = requireActivity()
ThreadUtils.queue {
try {
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server)
openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) }
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
} catch (e: Exception) {
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
@ -48,4 +44,4 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
}
dismiss()
}
}
}

View file

@ -1,20 +1,21 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.databinding.DialogLinkPreviewBinding
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
/** Shown the first time the user inputs a URL that could generate a link preview, to
* let them know that Session offers the ability to send and receive link previews. */
class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext()))
binding.cancelButton.setOnClickListener { dismiss() }
binding.enableLinkPreviewsButton.setOnClickListener { enable() }
builder.setView(binding.root)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_link_preview_title)
text(R.string.dialog_link_preview_explanation)
button(R.string.dialog_link_preview_enable_button_title) { enable() }
cancelButton { dismiss() }
}
private fun enable() {
@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
dismiss()
onEnabled()
}
}
}

View file

@ -1,22 +1,23 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.databinding.DialogSendSeedBinding
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/** Shown if the user is about to send their recovery phrase to someone. */
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() {
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext()))
binding.cancelButton.setOnClickListener { dismiss() }
binding.sendSeedButton.setOnClickListener { send() }
builder.setView(binding.root)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_send_seed_title)
text(R.string.dialog_send_seed_explanation)
button(R.string.dialog_send_seed_send_button_title) { send() }
cancelButton()
}
private fun send() {
proceed?.invoke()
dismiss()
}
}
}

View file

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.menus
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.PorterDuff
@ -15,7 +14,6 @@ import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView
@ -34,7 +32,6 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MediaOverviewActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
@ -45,6 +42,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException
@ -64,17 +63,18 @@ object ConversationMenuHelper {
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) {
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) {
if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages)
val actionView = item.actionView!!
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) }
item.actionView?.let { actionView ->
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@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) }
}
} else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
}
@ -87,7 +87,7 @@ object ConversationMenuHelper {
if (thread.isContactRecipient) {
if (thread.isBlocked) {
inflater.inflate(R.menu.menu_conversation_unblock, menu)
} else {
} else if (!thread.isLocalNumber) {
inflater.inflate(R.menu.menu_conversation_block, menu)
}
}
@ -186,29 +186,23 @@ object ConversationMenuHelper {
private fun call(context: Context, thread: Recipient) {
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
val dialog = AlertDialog.Builder(context)
.setTitle(R.string.ConversationActivity_call_title)
.setMessage(R.string.ConversationActivity_call_prompt)
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
val intent = Intent(context, PrivacySettingsActivity::class.java)
context.startActivity(intent)
context.showSessionDialog {
title(R.string.ConversationActivity_call_title)
text(R.string.ConversationActivity_call_prompt)
button(R.string.activity_settings_title, R.string.AccessibilityId_settings) {
Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity)
}
.setNeutralButton(R.string.cancel) { d, _ ->
d.dismiss()
}.create()
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.contentDescription = context.getString(R.string.AccessibilityId_settings)
dialog.getButton(DialogInterface.BUTTON_NEGATIVE)?.contentDescription = context.getString(R.string.AccessibilityId_cancel_button)
dialog.show()
cancelButton()
}
return
}
val service = WebRtcCallService.createCall(context, thread)
context.startService(service)
WebRtcCallService.createCall(context, thread)
.let(context::startService)
val activity = Intent(context, WebRtcCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(activity)
Intent(context, WebRtcCallActivity::class.java)
.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
.let(context::startActivity)
}
@ -295,9 +289,7 @@ object ConversationMenuHelper {
private fun leaveClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return }
val builder = AlertDialog.Builder(context)
builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group))
builder.setCancelable(true)
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context)
@ -307,29 +299,25 @@ object ConversationMenuHelper {
} else {
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
}
builder.setMessage(message)
builder.setPositiveButton(R.string.yes) { _, _ ->
var groupPublicKey: String?
var isClosedGroup: Boolean
try {
groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
} catch (e: IOException) {
groupPublicKey = null
isClosedGroup = false
}
try {
if (isClosedGroup) {
MessageSender.leave(groupPublicKey!!, true)
} else {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
context.showSessionDialog {
title(R.string.ConversationActivity_leave_group)
text(message)
button(R.string.yes) {
try {
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
else onLeaveFailed()
} catch (e: Exception) {
onLeaveFailed()
}
} catch (e: Exception) {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
}
button(R.string.no)
}
builder.setNegativeButton(R.string.no, null)
builder.show()
}
private fun inviteContacts(context: Context, thread: Recipient) {
@ -344,7 +332,7 @@ object ConversationMenuHelper {
}
private fun mute(context: Context, thread: Recipient) {
MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long ->
showMuteDialog(ContextThemeWrapper(context, context.theme)) { until ->
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
}
}

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
@ -15,9 +14,7 @@ import android.view.View
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.ColorUtils
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import androidx.core.view.children
@ -28,6 +25,7 @@ import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
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
@ -40,9 +38,10 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor
import java.util.*
import java.util.Locale
import kotlin.math.roundToInt
class VisibleMessageContentView : ConstraintLayout {
@ -70,12 +69,10 @@ class VisibleMessageContentView : ConstraintLayout {
suppressThumbnails: Boolean = false
) {
// Background
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
binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -132,7 +129,6 @@ class VisibleMessageContentView : ConstraintLayout {
delegate?.scrollToMessageIfPossible(quote.id)
}
}
val hasMedia = message.slideDeck.asAttachments().isNotEmpty()
}
if (message is MmsMessageRecord) {
@ -251,11 +247,6 @@ class VisibleMessageContentView : ConstraintLayout {
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() {
arrayOf(
binding.deletedMessageView.root,
@ -273,6 +264,15 @@ class VisibleMessageContentView : ConstraintLayout {
fun playVoiceMessage() {
binding.voiceMessageView.root.togglePlayback()
}
fun playHighlight() {
// Show the highlight colour immediately then slowly fade out
val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme)
val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0)
binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1
binding.contentParent.sessionShadowColor = targetColor
GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600)
}
// endregion
// region Convenience

View file

@ -110,6 +110,8 @@ class VisibleMessageView : LinearLayout {
private fun initialize() {
isHapticFeedbackEnabled = true
setWillNotDraw(false)
binding.root.disableClipping()
binding.mainContainer.disableClipping()
binding.messageInnerContainer.disableClipping()
binding.messageContentView.root.disableClipping()
}
@ -124,6 +126,7 @@ class VisibleMessageView : LinearLayout {
searchQuery: String? = null,
contact: Contact? = null,
senderSessionID: String,
lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit
) {
@ -162,6 +165,7 @@ class VisibleMessageView : LinearLayout {
if (thread.isOpenGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon
val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
@ -175,7 +179,7 @@ class VisibleMessageView : LinearLayout {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = ""
var blindedPublicKey: String? = null
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) {
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID
} else {
standardPublicKey = senderSessionID
@ -189,6 +193,8 @@ class VisibleMessageView : LinearLayout {
val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
// Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
@ -407,6 +413,10 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.recycle()
binding.messageContentView.root.recycle()
}
fun playHighlight() {
binding.messageContentView.root.playHighlight()
}
// endregion
// region Interaction

View file

@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
if (progress == 1.0) {
togglePlayback()
handleProgressChanged(0.0)
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1)
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1)
} else {
handleProgressChanged(progress)
}

View file

@ -25,6 +25,7 @@ import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.OpenableColumns;
import android.text.TextUtils;
import android.util.Pair;
@ -244,12 +245,17 @@ public class AttachmentManager {
}
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
Permissions.with(activity)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
Permissions.PermissionsBuilder builder = Permissions.with(activity);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.request(Manifest.permission.READ_MEDIA_IMAGES);
} else {
builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
}
public static void selectAudio(Activity activity, int requestCode) {

View file

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

View file

@ -1,21 +1,18 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.showSessionDialog
object NotificationUtils {
fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) {
val notifyTypes = context.resources.getStringArray(R.array.notify_types)
val currentSelected = thread.notifyType
AlertDialog.Builder(context)
.setSingleChoiceItems(notifyTypes,currentSelected) { d, newSelection ->
notifyTypeHandler(newSelection)
d.dismiss()
}
.setTitle(R.string.RecipientPreferenceActivity_notification_settings)
.show()
context.showSessionDialog {
title(R.string.RecipientPreferenceActivity_notification_settings)
singleChoiceItems(
context.resources.getStringArray(R.array.notify_types),
thread.notifyType
) { notifyTypeHandler(it) }
}
}
}
}

View file

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
companion object {
private const val VARIANT = "variant"
private const val PUBKEY = "publicKey"
private const val DATA = "data"
private const val TIMESTAMP = "timestamp" // Milliseconds
private const val TABLE_NAME = "configs_table"
const val CREATE_CONFIG_TABLE_COMMAND =
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
}
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
val db = writableDatabase
val contentValues = contentValuesOf(
VARIANT to variant,
PUBKEY to publicKey,
DATA to data,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
}
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
return query?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val bytes = cursor.getBlobOrNull(cursor.getColumnIndex(DATA)) ?: return@use null
bytes
}
}
fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long {
val db = readableDatabase
val cursor = db.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
if (cursor == null) return 0
if (!cursor.moveToFirst()) return 0
return (cursor.getLongOrNull(cursor.getColumnIndex(TIMESTAMP)) ?: 0)
}
}

View file

@ -36,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
@SuppressWarnings("unused")
private static final String TAG = GroupDatabase.class.getSimpleName();
static final String TABLE_NAME = "groups";
public static final String TABLE_NAME = "groups";
private static final String ID = "_id";
static final String GROUP_ID = "group_id";
public static final String GROUP_ID = "group_id";
private static final String TITLE = "title";
private static final String MEMBERS = "members";
private static final String ZOMBIE_MEMBERS = "zombie_members";
@ -133,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
return new Reader(cursor);
}
public List<GroupRecord> getAllGroups() {
public List<GroupRecord> getAllGroups(boolean includeInactive) {
Reader reader = getGroups();
GroupRecord record;
List<GroupRecord> groups = new LinkedList<>();
while ((record = reader.getNext()) != null) {
if (record.isActive()) { groups.add(record); }
if (record.isActive() || includeInactive) { groups.add(record); }
}
reader.close();
return groups;

View file

@ -458,9 +458,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize()))
}
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) {
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
val database = databaseHelper.writableDatabase
val timestamp = Date().time.toString()
val index = "$groupPublicKey-$timestamp"
val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded()
val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString()

View file

@ -4,11 +4,8 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.JsonUtil
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -24,12 +21,6 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);"
}
fun getThreadID(hexEncodedPublicKey: String): Long {
val address = Address.fromSerialized(hexEncodedPublicKey)
val recipient = Recipient.from(context, address, false)
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
}
fun getAllOpenGroups(): Map<Long, OpenGroup> {
val database = databaseHelper.readableDatabase
var cursor: Cursor? = null
@ -61,6 +52,13 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
}
}
fun getThreadId(openGroup: OpenGroup): Long? {
val database = databaseHelper.readableDatabase
return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor ->
cursor.getLong(threadID)
}
}
fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) {
if (threadID < 0) {
return

View file

@ -20,13 +20,11 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.NotificationInd
import com.google.android.mms.pdu_alt.PduHeaders
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage
@ -41,16 +39,13 @@ import org.session.libsession.utilities.Address.Companion.UNKNOWN
import org.session.libsession.utilities.Address.Companion.fromExternal
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.Contact
import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID
import org.session.libsession.utilities.IdentityKeyMismatch
import org.session.libsession.utilities.IdentityKeyMismatchList
import org.session.libsession.utilities.NetworkFailure
import org.session.libsession.utilities.NetworkFailureList
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
import org.session.libsession.utilities.Util.toIsoBytes
import org.session.libsession.utilities.Util.toIsoString
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientFormattingException
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils.queue
@ -162,7 +157,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp)
get(context).threadDatabase().update(threadId, false)
get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId)
}
}
@ -205,25 +200,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
}
@Throws(RecipientFormattingException::class, MmsException::class)
private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long {
return if (retrieved.groupId != null) {
val groupRecipients = Recipient.from(
context,
retrieved.groupId,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients)
} else {
val sender = Recipient.from(
context,
retrieved.from,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(sender)
}
}
private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
val database = databaseHelper.readableDatabase
return database.rawQuery(
@ -259,7 +235,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "")
)
if (threadId.isPresent) {
get(context).threadDatabase().update(threadId.get(), false)
get(context).threadDatabase().update(threadId.get(), false, true)
}
}
@ -316,10 +292,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val attachmentDatabase = get(context).attachmentDatabase()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
val threadId = getThreadIdForMessage(messageId)
if (!read) {
val mentionChange = if (hasMention) { 1 } else { 0 }
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
}
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
}
@ -343,6 +316,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString()))
}
fun setMessagesRead(threadId: Long, beforeTime: Long): List<MarkedMessageInfo> {
return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?",
arrayOf(threadId.toString(), beforeTime.toString())
)
}
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
@ -567,18 +547,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentLocation: String,
threadId: Long, mailbox: Long,
serverTimestamp: Long,
runIncrement: Boolean,
runThreadUpdate: Boolean
): Optional<InsertResult> {
var threadId = threadId
if (threadId == -1L || retrieved.isGroupMessage) {
try {
threadId = getThreadIdFor(retrieved)
} catch (e: RecipientFormattingException) {
Log.w("MmsDatabase", e)
if (threadId == -1L) throw MmsException(e)
}
}
if (threadId < 0 ) throw MmsException("No thread ID supplied!")
val contentValues = ContentValues()
contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
contentValues.put(ADDRESS, retrieved.from.serialize())
@ -632,12 +603,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
null,
)
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runIncrement) {
val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 }
get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount)
}
if (runThreadUpdate) {
get(context).threadDatabase().update(threadId, true)
get(context).threadDatabase().update(threadId, true, true)
}
}
notifyConversationListeners(threadId)
@ -651,27 +618,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
serverTimestamp: Long,
runThreadUpdate: Boolean
): Optional<InsertResult> {
var threadId = threadId
if (threadId == -1L) {
if (retrieved.isGroup) {
val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) {
retrieved.groupId
} else {
(retrieved as OutgoingGroupMediaMessage).groupId
}
val groupId: String
groupId = try {
doubleEncodeGroupID(decodedGroupId)
} catch (e: IOException) {
Log.e(TAG, "Couldn't encrypt group ID")
throw MmsException(e)
}
val group = Recipient.from(context, fromSerialized(groupId), false)
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group)
} else {
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient)
}
}
if (threadId < 0 ) throw MmsException("No thread ID supplied!")
val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) {
return Optional.absent()
@ -686,7 +633,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
retrieved: IncomingMediaMessage,
threadId: Long,
serverTimestamp: Long = 0,
runIncrement: Boolean,
runThreadUpdate: Boolean
): Optional<InsertResult> {
var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT
@ -705,7 +651,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (retrieved.isMessageRequestResponse) {
type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT
}
return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate)
return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate)
}
@JvmOverloads
@ -794,10 +740,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
}
with (get(context).threadDatabase()) {
setLastSeen(threadId)
val lastSeen = getLastSeenAndHasSent(threadId).first()
if (lastSeen < message.sentTimeMillis) {
setLastSeen(threadId, message.sentTimeMillis)
}
setHasSent(threadId, true)
if (runThreadUpdate) {
update(threadId, true)
update(threadId, true, true)
}
}
return messageId
@ -932,7 +881,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false)
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@ -949,7 +898,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
val threadDeleted = get(context).threadDatabase().update(threadId, false)
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@ -1147,7 +1096,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val threadDb = get(context).threadDatabase()
for (threadId in threadIds) {
val threadDeleted = threadDb.update(threadId, false)
val threadDeleted = threadDb.update(threadId, false, true)
notifyConversationListeners(threadId)
}
notifyStickerListeners()

View file

@ -16,6 +16,8 @@
*/
package org.thoughtcrime.securesms.database;
import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX;
import android.content.Context;
import android.database.Cursor;
@ -25,6 +27,7 @@ import androidx.annotation.Nullable;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
@ -36,6 +39,8 @@ import java.io.Closeable;
import java.util.HashSet;
import java.util.Set;
import kotlin.Pair;
public class MmsSmsDatabase extends Database {
@SuppressWarnings("unused")
@ -259,8 +264,8 @@ public class MmsSmsDatabase extends Database {
return -1;
}
public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC");
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
@ -512,6 +517,23 @@ public class MmsSmsDatabase extends Database {
return new Reader(cursor);
}
@NotNull
public Pair<Boolean, Long> timestampAndDirectionForCurrent(@NotNull Cursor cursor) {
int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT);
String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT));
long sentTime = cursor.getLong(sentColumn);
long type = 0;
if (MmsSmsDatabase.MMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(MESSAGE_BOX);
type = cursor.getLong(typeIndex);
} else if (MmsSmsDatabase.SMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(SmsDatabase.TYPE);
type = cursor.getLong(typeIndex);
}
return new Pair<Boolean, Long>(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime);
}
public class Reader implements Closeable {
private final Cursor cursor;

View file

@ -62,13 +62,14 @@ public class RecipientDatabase extends Database {
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash";
private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH
};
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@ -136,6 +137,11 @@ public class RecipientDatabase extends Database {
"OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
}
public static String getAddWrapperHash() {
return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;";
}
public static final int NOTIFY_TYPE_ALL = 0;
public static final int NOTIFY_TYPE_MENTIONS = 1;
public static final int NOTIFY_TYPE_NONE = 2;
@ -154,18 +160,14 @@ public class RecipientDatabase extends Database {
public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null);
try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return getRecipientSettings(cursor);
}
return Optional.absent();
} finally {
if (cursor != null) cursor.close();
}
}
@ -194,6 +196,7 @@ public class RecipientDatabase extends Database {
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
MaterialColor color;
byte[] profileKey = null;
@ -225,7 +228,7 @@ public class RecipientDatabase extends Database {
systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection));
forceSmsSelection, wrapperHash));
}
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
@ -252,6 +255,24 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners();
}
public boolean getApproved(@NonNull Address address) {
SQLiteDatabase db = getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
}
}
return false;
}
public void setRecipientHash(@NonNull Recipient recipient, String recipientHash) {
ContentValues values = new ContentValues();
values.put(WRAPPER_HASH, recipientHash);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setWrapperHash(recipientHash);
notifyRecipientListeners();
}
public void setApproved(@NonNull Recipient recipient, boolean approved) {
ContentValues values = new ContentValues();
values.put(APPROVED, approved ? 1 : 0);
@ -268,14 +289,6 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners();
}
public void setBlocked(@NonNull Recipient recipient, boolean blocked) {
ContentValues values = new ContentValues();
values.put(BLOCK, blocked ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setBlocked(blocked);
notifyRecipientListeners();
}
public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();

View file

@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import androidx.core.database.getStringOrNull
import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -43,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor)
}.filter { contact ->
val sessionId = SessionId(contact.sessionID)
sessionId.prefix == IdPrefix.STANDARD
}.toSet()
}

View file

@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
fun cancelPendingMessageSendJobs(threadID: Long) {
val database = databaseHelper.writableDatabase
val attachmentUploadJobKeys = mutableListOf<String>()
database.beginTransaction()
database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor ->
val job = jobFromCursor(cursor) as AttachmentUploadJob?
if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) }
@ -103,15 +104,19 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) }
}
if (attachmentUploadJobKeys.isNotEmpty()) {
val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ")
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)",
arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString ))
attachmentUploadJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( AttachmentUploadJob.KEY, it ))
}
}
if (messageSendJobKeys.isNotEmpty()) {
val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ")
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)",
arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString ))
messageSendJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( MessageSendJob.KEY, it ))
}
}
database.setTransactionSuccessful()
database.endTransaction()
}
fun isJobCanceled(job: Job): Boolean {

View file

@ -148,7 +148,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
}
@ -234,10 +234,6 @@ public class SmsDatabase extends MessagingDatabase {
contentValues.put(BODY, "");
contentValues.put(HAS_MENTION, 0);
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)});
long threadId = getThreadIdForMessage(messageId);
if (!read) {
DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1, (hasMention ? 1 : 0));
}
updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
}
@ -256,7 +252,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
}
@ -319,7 +315,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " = ?",
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
foundMessage = true;
}
@ -337,6 +333,9 @@ public class SmsDatabase extends MessagingDatabase {
}
}
public List<MarkedMessageInfo> setMessagesRead(long threadId, long beforeTime) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""});
}
public List<MarkedMessageInfo> setMessagesRead(long threadId) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)});
}
@ -400,14 +399,14 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId);
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
notifyConversationListeners(threadId);
notifyConversationListListeners();
return new Pair<>(messageId, threadId);
}
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) {
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) {
if (message.isSecureMessage()) {
type |= Types.SECURE_MESSAGE_BIT;
} else if (message.isGroup()) {
@ -486,12 +485,8 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long messageId = db.insert(TABLE_NAME, null, values);
if (unread && runIncrement) {
DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0));
}
if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
}
if (message.getSubscriptionId() != -1) {
@ -504,16 +499,16 @@ public class SmsDatabase extends MessagingDatabase {
}
}
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate);
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate);
}
public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) {
return insertMessageInbox(message, 0, 0, true, true);
return insertMessageInbox(message, 0, 0, true);
}
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate);
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate);
}
public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
@ -567,9 +562,12 @@ public class SmsDatabase extends MessagingDatabase {
}
if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
}
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
if (lastSeen < message.getSentTimestampMillis()) {
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId, message.getSentTimestampMillis());
}
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId);
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true);
@ -616,7 +614,7 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
return threadDeleted;
}
@ -640,7 +638,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues
);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
return threadDeleted;
}

View file

@ -2,16 +2,43 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.*
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
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.RetrieveProfileAvatarJob
import org.session.libsession.messaging.messages.Destination
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.*
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.visible.Attachment
import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.messages.visible.Reaction
@ -23,12 +50,15 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
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.*
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
@ -36,24 +66,104 @@ import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return
if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
lastRead = SnodeAPI.nowWithOffset,
)
volatile.set(newVolatileParams)
}
} else if (address.isOpenGroup) {
// these should be added on the group join / group info fetch
Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
volatile.set(newVolatileParams)
}
}
override fun threadDeleted(address: Address, threadId: Long) {
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
} else if (address.isOpenGroup) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = PRIORITY_HIDDEN
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(PRIORITY_HIDDEN)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol {
override fun getUserPublicKey(): String? {
return TextSecurePreferences.getLocalNumber(context)
}
@ -74,6 +184,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
database.setProfileAvatar(recipient, profileAvatar)
}
override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) {
val db = DatabaseComponent.get(context).recipientDatabase()
db.setProfileAvatar(recipient, newProfilePicture)
db.setProfileKey(recipient, newProfileKey)
}
override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) {
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
Recipient.from(context, it, false)
}
ourRecipient.resolve().profileKey = newProfileKey
TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) })
TextSecurePreferences.setProfilePictureURL(context, newProfilePicture)
if (newProfileKey != null) {
JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address))
}
}
override fun getOrGenerateRegistrationID(): Int {
var registrationID = TextSecurePreferences.getLocalRegistrationId(context)
if (registrationID == 0) {
@ -94,19 +223,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return database.getAttachmentsForMessage(messageID)
}
override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) {
override fun getLastSeen(threadId: Long): Long {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setRead(threadId, updateLastSeen)
return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L
}
override fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) {
override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.incrementUnread(threadId, amount, unreadMentionAmount)
getRecipientForThread(threadId)?.let { recipient ->
val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first()
// don't set the last read in the volatile if we didn't set it in the DB
if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return
// don't process configs for inbox recipients
if (recipient.isOpenGroupInboxRecipient) return
configFactory.convoVolatile?.let { config ->
val convo = when {
// recipient closed group
recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
// recipient is open group
recipient.isOpenGroupRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey)
} ?: return
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = SessionId(recipient.address.serialize())
if (sessionId.prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(recipient.address.serialize())
}
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
}
convo.lastRead = lastSeenTime
if (convo.unread) {
convo.unread = lastSeenTime <= currentLastRead
notifyConversationListListeners()
}
config.set(convo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
override fun updateThread(threadId: Long, unarchive: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.update(threadId, unarchive)
threadDb.update(threadId, unarchive, false)
}
override fun persist(message: VisibleMessage,
@ -115,7 +281,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
groupPublicKey: String?,
openGroupID: String?,
attachments: List<Attachment>,
runIncrement: Boolean,
runThreadUpdate: Boolean): Long? {
var messageID: Long? = null
val senderAddress = fromSerialized(message.sender!!)
@ -142,13 +307,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
}
val targetRecipient = Recipient.from(context, targetAddress, false)
if (!targetRecipient.isGroupRecipient) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
if (isUserSender || isUserBlindedSender) {
recipientDb.setApproved(targetRecipient, true)
setRecipientApproved(targetRecipient, true)
} else {
recipientDb.setApprovedMe(targetRecipient, true)
setRecipientApprovedMe(targetRecipient, true)
}
}
if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) {
// open group recipients should explicitly create threads
message.threadID = getOrCreateThreadIdFor(targetAddress)
}
if (message.isMediaMessage() || attachments.isNotEmpty()) {
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
@ -162,7 +330,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
it.toSignalPointer()
}
val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews)
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate)
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate)
}
if (insertResult.isPresent) {
messageID = insertResult.get().messageId
@ -179,7 +347,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp)
else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L)
val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody)
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate)
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate)
}
insertResult.orNull()?.let { result ->
messageID = result.messageId
@ -225,6 +393,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId)
}
override fun getConfigSyncJob(destination: Destination): Job? {
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull {
(it as? ConfigurationSyncJob)?.destination == destination
}
}
override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) {
val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return
JobQueue.shared.resumePendingSendMessage(job)
@ -234,11 +408,201 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job)
}
override fun cancelPendingMessageSendJobs(threadID: Long) {
val jobDb = DatabaseComponent.get(context).sessionJobDatabase()
jobDb.cancelPendingMessageSendJobs(threadID)
}
override fun getAuthToken(room: String, server: String): String? {
val id = "$server.$room"
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
}
override fun notifyConfigUpdates(forConfigObject: ConfigBase) {
notifyUpdates(forConfigObject)
}
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean {
return configFactory.conversationInConfig(publicKey, groupPublicKey, openGroupId, visibleOnly)
}
override fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
return configFactory.canPerformChange(variant, publicKey, changeTimestampMs)
}
fun notifyUpdates(forConfigObject: ConfigBase) {
when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject)
is Contacts -> updateContacts(forConfigObject)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject)
is UserGroupsConfig -> updateUserGroups(forConfigObject)
}
}
private fun updateUser(userProfile: UserProfile) {
val userPublicKey = getUserPublicKey() ?: return
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// update name
val name = userProfile.getName() ?: return
val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name)
profileManager.setName(context, recipient, name)
}
// update pfp
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
&& TextSecurePreferences.getProfilePictureURL(context) != userPic.url) {
setUserProfilePicture(userPic.url, userPic.key)
}
if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) {
// delete nts thread if needed
val ourThread = getThreadId(recipient) ?: return
deleteConversation(ourThread)
} else {
// create note to self thread if needed (?)
val ourThread = getOrCreateThreadIdFor(recipient.address)
DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
setPinned(ourThread, userProfile.getNtsPriority() > 0)
}
}
private fun updateContacts(contacts: Contacts) {
val extracted = contacts.all().toList()
addLibSessionContacts(extracted)
}
override fun clearUserPic() {
val userPublicKey = getUserPublicKey() ?: return
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// clear picture if userPic is null
TextSecurePreferences.setProfileKey(context, null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
TextSecurePreferences.setProfileAvatarId(context, 0)
TextSecurePreferences.setProfilePictureURL(context, null)
Recipient.removeCached(fromSerialized(userPublicKey))
configFactory.user?.setPic(UserPic.DEFAULT)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
private fun updateConvoVolatile(convos: ConversationVolatileConfig) {
val extracted = convos.all()
for (conversation in extracted) {
val threadId = when (conversation) {
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
}
if (threadId != null) {
if (conversation.lastRead > getLastSeen(threadId)) {
markConversationAsRead(threadId, conversation.lastRead, force = true)
}
updateThread(threadId, false)
}
}
}
private fun updateUserGroups(userGroups: UserGroupsConfig) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
val localUserPublicKey = getUserPublicKey() ?: return Log.w(
"Loki",
"No user public key when trying to update user groups from config"
)
val communities = userGroups.allCommunityInfo()
val lgc = userGroups.allLegacyGroupInfo()
val allOpenGroups = getAllOpenGroups()
val toDeleteCommunities = allOpenGroups.filter {
Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() }
}
val existingCommunities: Map<Long, OpenGroup> = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys }
val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } }
val existingJoinUrls = existingCommunities.values.map { it.joinURL }
val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId }
val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
// delete the ones which are not listed in the config
toDeleteCommunities.values.forEach { openGroup ->
OpenGroupManager.delete(openGroup.server, openGroup.room, context)
}
toDeleteClosedGroups.forEach { deleteGroup ->
val threadId = getThreadId(deleteGroup.encodedId)
if (threadId != null) {
ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true)
}
}
toAddCommunities.forEach { toAddCommunity ->
val joinUrl = toAddCommunity.community.fullUrl()
if (!hasBackgroundGroupAddJob(joinUrl)) {
JobQueue.shared.add(BackgroundGroupAddJob(joinUrl))
}
}
for (groupInfo in communities) {
val groupBaseCommunity = groupInfo.community
if (groupBaseCommunity.fullUrl() in existingJoinUrls) {
// add it
val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() }
threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED)
}
}
for (group in lgc) {
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true)
} else if (existingThread == null) {
Log.w("Loki-DBG", "Existing group had no thread to hide")
} else {
Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}")
threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED)
}
} else {
val members = group.members.keys.map { Address.fromSerialized(it) }
val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) }
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val title = group.name
val formationTimestamp = (group.joinedAt * 1000L)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId)
// Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
// Set expiration timer
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
}
}
}
override fun setAuthToken(room: String, server: String, newValue: String) {
val id = "$server.$room"
DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue)
@ -474,6 +838,59 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
}
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return
val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
name = name,
members = members,
priority = ConfigBase.PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = 0L,
joinedAt = (formationTimestamp / 1000L)
)
// shouldn't exist, don't use getOrConstruct + copy
userGroups.set(groupInfo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun updateGroupConfig(groupPublicKey: String) {
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val groupAddress = fromSerialized(groupID)
// TODO: probably add a check in here for isActive?
// TODO: also check if local user is a member / maybe run delete otherwise?
val existingGroup = getGroup(groupID)
?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config")
val userGroups = configFactory.userGroups ?: return
if (!existingGroup.isActive) {
userGroups.eraseLegacyGroup(groupPublicKey)
return
}
val name = existingGroup.title
val admins = existingGroup.admins.map { it.serialize() }
val members = existingGroup.members.map { it.serialize() }
val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members)
val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config")
val recipientSettings = getRecipientSettings(groupAddress) ?: return
val threadID = getThreadId(groupAddress) ?: return
val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy(
name = name,
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
disappearingTimer = recipientSettings.expireMessages.toLong(),
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
userGroups.set(groupInfo)
}
override fun isGroupActive(groupPublicKey: String): Boolean {
return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true
}
@ -504,7 +921,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase()
smsDB.insertMessageInbox(infoMessage, true, true)
smsDB.insertMessageInbox(infoMessage, true)
}
override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
@ -552,8 +969,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey)
}
override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) {
DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey)
override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp)
}
override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) {
@ -570,9 +987,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
.updateTimestampUpdated(groupID, updatedTimestamp)
}
override fun setExpirationTimer(groupID: String, duration: Int) {
val recipient = Recipient.from(context, fromSerialized(groupID), false)
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration);
override fun setExpirationTimer(address: String, duration: Int) {
val recipient = Recipient.from(context, fromSerialized(address), false)
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration)
if (recipient.isContactRecipient && !recipient.isLocalNumber) {
configFactory.contacts?.upsertContact(address) {
this.expiryMode = if (duration != 0) {
ExpiryMode.AfterRead(duration.toLong())
} else { // = 0 / delete
ExpiryMode.NONE
}
}
if (configFactory.contacts?.needsPush() == true) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
override fun setServerCapabilities(server: String, capabilities: List<String>) {
@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
OpenGroupManager.updateOpenGroup(openGroup, context)
}
override fun getAllGroups(): List<GroupRecord> {
return DatabaseComponent.get(context).groupDatabase().allGroups
override fun getAllGroups(includeInactive: Boolean): List<GroupRecord> {
return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive)
}
override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
return OpenGroupManager.addOpenGroup(urlAsString, context)
}
override fun onOpenGroupAdded(server: String) {
override fun onOpenGroupAdded(server: String, room: String) {
OpenGroupManager.restartPollerForServer(server.removeSuffix("/"))
val groups = configFactory.userGroups ?: return
val volatileConfig = configFactory.convoVolatile ?: return
val openGroup = getOpenGroup(room, server) ?: return
val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val pubKeyHex = Hex.toStringCondensed(pubKey)
val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex)
groups.set(communityInfo)
val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey)
if (volatile.lastRead != 0L) {
val threadId = getThreadId(openGroup) ?: return
markConversationAsRead(threadId, volatile.lastRead, force = true)
}
volatileConfig.set(volatile)
}
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
@ -618,17 +1060,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
}
override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long {
override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? {
val database = DatabaseComponent.get(context).threadDatabase()
return if (!openGroupID.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false)
database.getThreadIdIfExistsFor(recipient)
database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else if (!groupPublicKey.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false)
database.getOrCreateThreadIdFor(recipient)
if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else {
val recipient = Recipient.from(context, fromSerialized(publicKey), false)
database.getOrCreateThreadIdFor(recipient)
if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
}
}
@ -637,6 +1081,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return getThreadId(address)
}
override fun getThreadId(openGroup: OpenGroup): Long? {
return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context)
}
override fun getThreadId(address: Address): Long? {
val recipient = Recipient.from(context, address, false)
return getThreadId(recipient)
@ -666,6 +1114,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID)
if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
setRecipientHash(recipient, recipientHash)
}
override fun getRecipientForThread(threadId: Long): Recipient? {
@ -677,6 +1130,51 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return if (recipientSettings.isPresent) { recipientSettings.get() } else null
}
override fun addLibSessionContacts(contacts: List<LibSessionContact>) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
val address = fromSerialized(contact.id)
val recipient = Recipient.from(context, address, false)
setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true)
setRecipientApproved(recipient, contact.approved)
setRecipientApprovedMe(recipient, contact.approvedMe)
if (contact.name.isNotEmpty()) {
profileManager.setName(context, recipient, contact.name)
} else {
profileManager.setName(context, recipient, null)
}
if (contact.nickname.isNotEmpty()) {
profileManager.setNickname(context, recipient, contact.nickname)
} else {
profileManager.setNickname(context, recipient, null)
}
if (contact.profilePicture != UserPic.DEFAULT) {
val (url, key) = contact.profilePicture
if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach
profileManager.setProfilePicture(context, recipient, url, key)
profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN)
} else {
profileManager.setProfilePicture(context, recipient, null, null)
}
if (contact.priority == PRIORITY_HIDDEN) {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
deleteConversation(conversationThreadId)
}
} else {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED)
}
}
setRecipientHash(recipient, contact.hashCode().toString())
}
}
override fun addContacts(contacts: List<ConfigurationMessage.Contact>) {
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
val threadDatabase = DatabaseComponent.get(context).threadDatabase()
@ -700,19 +1198,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
recipientDatabase.setProfileSharing(recipient, true)
recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED)
// create Thread if needed
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
val threadId = threadDatabase.getThreadIdIfExistsFor(recipient)
if (contact.didApproveMe == true) {
recipientDatabase.setApprovedMe(recipient, true)
}
if (contact.isApproved == true) {
recipientDatabase.setApproved(recipient, true)
if (contact.isApproved == true && threadId != -1L) {
setRecipientApproved(recipient, true)
threadDatabase.setHasSent(threadId, true)
}
val contactIsBlocked: Boolean? = contact.isBlocked
if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) {
recipientDatabase.setBlocked(recipient, contactIsBlocked)
threadDatabase.deleteConversation(threadId)
setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true)
}
}
if (contacts.isNotEmpty()) {
@ -720,6 +1217,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
}
}
override fun setRecipientHash(recipient: Recipient, recipientHash: String?) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setRecipientHash(recipient, recipientHash)
}
override fun getLastUpdated(threadID: Long): Long {
val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.getLastUpdated(threadID)
@ -740,12 +1242,78 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return mmsSmsDb.getConversationCount(threadID)
}
override fun deleteConversation(threadId: Long) {
override fun setPinned(threadID: Long, isPinned: Boolean) {
val threadDB = DatabaseComponent.get(context).threadDatabase()
threadDB.deleteConversation(threadId)
threadDB.setPinned(threadID, isPinned)
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
}
} else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
} else if (threadRecipient.isOpenGroupRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun isPinned(threadID: Long): Boolean {
val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.isPinned(threadID)
}
override fun setThreadDate(threadId: Long, newDate: Long) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setDate(threadId, newDate)
}
override fun deleteConversation(threadID: Long) {
val recipient = getRecipientForThread(threadID)
val threadDB = DatabaseComponent.get(context).threadDatabase()
val groupDB = DatabaseComponent.get(context).groupDatabase()
threadDB.deleteConversation(threadID)
if (recipient != null) {
if (recipient.isContactRecipient) {
if (recipient.isLocalNumber) return
val contacts = configFactory.contacts ?: return
contacts.upsertContact(recipient.address.serialize()) {
this.priority = PRIORITY_HIDDEN
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} else if (recipient.isClosedGroupRecipient) {
// TODO: handle closed group
val volatile = configFactory.convoVolatile ?: return
val groups = configFactory.userGroups ?: return
val groupID = recipient.address.toGroupString()
val closedGroup = getGroup(groupID)
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
if (closedGroup != null) {
groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it)
volatile.eraseLegacyClosedGroup(groupPublicKey)
groups.eraseLegacyGroup(groupPublicKey)
} else {
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
}
}
}
}
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
return PartAuthority.getAttachmentDataUri(attachmentId)
@ -762,6 +1330,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
if (recipient.isBlocked) return
val threadId = getThreadId(recipient) ?: return
val mediaMessage = IncomingMediaMessage(
address,
sentTimestamp,
@ -780,14 +1350,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.of(message)
)
database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true)
database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
}
override fun insertMessageRequestResponse(response: MessageRequestResponse) {
val userPublicKey = getUserPublicKey()
val senderPublicKey = response.sender!!
val recipientPublicKey = response.recipient!!
if (userPublicKey == null || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)) return
if (
userPublicKey == null
|| (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)
// this is true if it is a sync message
|| (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey)
) return
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
val threadDB = DatabaseComponent.get(context).threadDatabase()
if (userPublicKey == senderPublicKey) {
@ -799,7 +1376,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val smsDb = DatabaseComponent.get(context).smsDatabase()
val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
val threadId = threadDB.getOrCreateThreadIdFor(sender)
val threadId = getOrCreateThreadIdFor(sender.address)
val profile = response.profile
if (profile != null) {
val profileManager = SSKEnvironment.shared.profileManager
@ -814,9 +1391,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey))
if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) {
profileManager.setProfileKey(context, sender, newProfileKey!!)
profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!)
profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN)
profileManager.setProfilePictureURL(context, sender, profile.profilePictureURL!!)
}
}
threadDB.setHasSent(threadId, true)
@ -873,16 +1449,28 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.absent(),
Optional.absent()
)
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true)
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true)
}
}
override fun getRecipientApproved(address: Address): Boolean {
return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approved = approved
}
}
override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approvedMe = approvedMe
}
}
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
@ -1012,9 +1600,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
}
override fun unblock(toUnblock: Iterable<Recipient>) {
override fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setBlocked(toUnblock, false)
recipientDb.setBlocked(recipients, isBlocked)
recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient ->
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.blocked = isBlocked
}
}
val contactsConfig = configFactory.contacts ?: return
if (contactsConfig.needsPush() && !fromConfigUpdate) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
override fun blockedContacts(): List<Recipient> {

View file

@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
@ -74,6 +73,11 @@ import java.util.Set;
public class ThreadDatabase extends Database {
public interface ConversationThreadUpdateListener {
void threadCreated(@NonNull Address address, long threadId);
void threadDeleted(@NonNull Address address, long threadId);
}
private static final String TAG = ThreadDatabase.class.getSimpleName();
private final Map<Long, Address> addressCache = new HashMap<>();
@ -141,10 +145,16 @@ public class ThreadDatabase extends Database {
"ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;";
}
private ConversationThreadUpdateListener updateListener;
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public void setUpdateListener(ConversationThreadUpdateListener updateListener) {
this.updateListener = updateListener;
}
private long createThreadForRecipient(Address address, boolean group, int distributionType) {
ContentValues contentValues = new ContentValues(4);
long date = SnodeAPI.getNowWithOffset();
@ -207,10 +217,14 @@ public class ThreadDatabase extends Database {
}
private void deleteThread(long threadId) {
Recipient recipient = getRecipientForThreadId(threadId);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""});
int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""});
addressCache.remove(threadId);
notifyConversationListListeners();
if (updateListener != null && numberRemoved > 0 && recipient != null) {
updateListener.threadDeleted(recipient.getAddress(), threadId);
}
}
private void deleteThreads(Set<Long> threadIds) {
@ -278,7 +292,7 @@ public class ThreadDatabase extends Database {
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
update(threadId, false);
update(threadId, false, true);
notifyConversationListeners(threadId);
}
} finally {
@ -291,10 +305,34 @@ public class ThreadDatabase extends Database {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
update(threadId, false);
update(threadId, false, true);
notifyConversationListeners(threadId);
}
public List<MarkedMessageInfo> setRead(long threadId, long lastReadTime) {
final List<MarkedMessageInfo> smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime);
final List<MarkedMessageInfo> mmsRecords = DatabaseComponent.get(context).mmsDatabase().setMessagesRead(threadId, lastReadTime);
if (smsRecords.isEmpty() && mmsRecords.isEmpty()) {
return Collections.emptyList();
}
ContentValues contentValues = new ContentValues(2);
contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty());
contentValues.put(LAST_SEEN, lastReadTime);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
notifyConversationListListeners();
return new LinkedList<MarkedMessageInfo>() {{
addAll(smsRecords);
addAll(mmsRecords);
}};
}
public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(READ, 1);
@ -319,30 +357,6 @@ public class ThreadDatabase extends Database {
}};
}
public void incrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " + ? WHERE " + ID + " = ?",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void decrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " - ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void setDistributionType(long threadId, int distributionType) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(TYPE, distributionType);
@ -352,6 +366,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners();
}
public void setDate(long threadId, long date) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(DATE, date);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
if (updated > 0) notifyConversationListListeners();
}
public int getDistributionType(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
@ -427,9 +449,9 @@ public class ThreadDatabase extends Database {
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + MESSAGE_COUNT + " = " + UNREAD_COUNT + " AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
cursor = db.rawQuery(query, null);
@ -481,7 +503,7 @@ public class ThreadDatabase extends Database {
}
public Cursor getApprovedConversationList() {
String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 ";
return getConversationList(where);
}
@ -517,21 +539,50 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null);
}
public void setLastSeen(long threadId, long timestamp) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
if (timestamp == -1) {
contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset());
} else {
contentValues.put(LAST_SEEN, timestamp);
}
/**
* @param threadId
* @param timestamp
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean setLastSeen(long threadId, long timestamp) {
// edge case where we set the last seen time for a conversation before it loads messages (joining community for example)
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
Recipient forThreadId = getRecipientForThreadId(threadId);
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false;
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp;
contentValues.put(LAST_SEEN, lastSeenTime);
db.beginTransaction();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)});
String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0";
String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1";
String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1";
String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0";
String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1";
String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1";
String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))";
String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))";
String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))";
String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))";
String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?";
db.execSQL(reflectUpdates, new Object[]{threadId});
db.setTransactionSuccessful();
db.endTransaction();
notifyConversationListeners(threadId);
notifyConversationListListeners();
return true;
}
public void setLastSeen(long threadId) {
setLastSeen(threadId, -1);
/**
* @param threadId
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean setLastSeen(long threadId) {
return setLastSeen(threadId, -1);
}
public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) {
@ -634,13 +685,19 @@ public class ThreadDatabase extends Database {
try {
cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null);
long threadId;
boolean created = false;
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(ID));
threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
} else {
DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true);
return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
created = true;
}
if (created && updateListener != null) {
updateListener.threadCreated(recipient.getAddress(), threadId);
}
return threadId;
} finally {
if (cursor != null)
cursor.close();
@ -679,13 +736,14 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId);
notifyConversationListListeners();
}
public boolean update(long threadId, boolean unarchive) {
public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId);
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId);
if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId);
@ -708,12 +766,10 @@ public class ThreadDatabase extends Database {
updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record),
record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(),
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
notifyConversationListListeners();
return false;
} else {
if (shouldDeleteEmptyThread) {
deleteThread(threadId);
notifyConversationListListeners();
return true;
}
return false;
@ -721,6 +777,8 @@ public class ThreadDatabase extends Database {
} finally {
if (reader != null)
reader.close();
notifyConversationListListeners();
notifyConversationListeners(threadId);
}
}
@ -732,10 +790,32 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId);
notifyConversationListListeners();
}
public void markAllAsRead(long threadId, boolean isGroupRecipient) {
List<MarkedMessageInfo> messages = setRead(threadId, true);
public boolean isPinned(long threadId) {
SQLiteDatabase db = getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{IS_PINNED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0) == 1;
}
return false;
} finally {
if (cursor != null) cursor.close();
}
}
/**
* @param threadId
* @param isGroupRecipient
* @param lastSeenTime
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastSeenTime, boolean force) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime);
if (isGroupRecipient) {
for (MarkedMessageInfo message: messages) {
MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo());
@ -743,7 +823,8 @@ public class ThreadDatabase extends Database {
} else {
MarkReadReceiver.process(context, messages);
}
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0);
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
return setLastSeen(threadId, lastSeenTime);
}
private boolean deleteThreadOnEmpty(long threadId) {

View file

@ -11,7 +11,6 @@ import androidx.core.app.NotificationCompat;
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;
@ -19,6 +18,7 @@ import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.ConfigDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase;
@ -39,6 +39,7 @@ 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 org.thoughtcrime.securesms.util.ConfigurationMessageUtilities;
import java.io.File;
@ -85,9 +86,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV38 = 59;
private static final int lokiV39 = 60;
private static final int lokiV40 = 61;
private static final int lokiV41 = 62;
private static final int lokiV42 = 63;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV40;
private static final int DATABASE_VERSION = lokiV42;
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";
@ -147,7 +150,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
connection.execute("PRAGMA cipher_page_size = 4096;", null, null);
}
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException {
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) {
return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() {
@Override
public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
@ -340,6 +343,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(ThreadDatabase.getUnreadMentionCountCommand());
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -351,6 +355,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash());
}
@Override
@ -583,6 +588,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
}
if (oldVersion < lokiV41) {
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_GROUPS);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_ONE_TO_ONES);
}
if (oldVersion < lokiV42) {
db.execSQL(RecipientDatabase.getAddWrapperHash());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies
import dagger.Binds
import dagger.Module
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences
@ -19,4 +20,10 @@ abstract class AppModule {
@Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppComponent {
fun getPrefs(): TextSecurePreferences
}

View file

@ -0,0 +1,251 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.os.Trace
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory(
private val context: Context,
private val configDatabase: ConfigDatabase,
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
) :
ConfigFactoryProtocol {
companion object {
// This is a buffer period within which we will process messages which would result in a
// config change, any message which would normally result in a config change which was sent
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
// it's changes applied (control text will still be added though)
val configChangeBufferPeriod: Long = (2 * 60 * 1000)
}
fun keyPairChanged() { // this should only happen restoring or clearing data
_userConfig?.free()
_contacts?.free()
_convoVolatileConfig?.free()
_userConfig = null
_contacts = null
_convoVolatileConfig = null
}
private val userLock = Object()
private var _userConfig: UserProfile? = null
private val contactsLock = Object()
private var _contacts: Contacts? = null
private val convoVolatileLock = Object()
private var _convoVolatileConfig: ConversationVolatileConfig? = null
private val userGroupsLock = Object()
private var _userGroups: UserGroupsConfig? = null
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
fun registerListener(listener: ConfigFactoryUpdateListener) {
listeners += listener
}
fun unregisterListener(listener: ConfigFactoryUpdateListener) {
listeners -= listener
}
private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T {
Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) {
body()
}
Trace.endSection()
return result
}
override val user: UserProfile?
get() = synchronizedWithLog(userLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.USER_PROFILE.name,
publicKey
)
_userConfig = if (userDump != null) {
UserProfile.newInstance(secretKey, userDump)
} else {
ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump ->
UserProfile.newInstance(secretKey, dump)
} ?: UserProfile.newInstance(secretKey)
}
}
_userConfig
}
override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONTACTS.name,
publicKey
)
_contacts = if (contactsDump != null) {
Contacts.newInstance(secretKey, contactsDump)
} else {
ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump ->
Contacts.newInstance(secretKey, dump)
} ?: Contacts.newInstance(secretKey)
}
}
_contacts
}
override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey
)
_convoVolatileConfig = if (convoDump != null) {
ConversationVolatileConfig.newInstance(secretKey, convoDump)
} else {
ConfigurationMessageUtilities.generateConversationVolatileDump(context)
?.let { dump ->
ConversationVolatileConfig.newInstance(secretKey, dump)
} ?: ConversationVolatileConfig.newInstance(secretKey)
}
}
_convoVolatileConfig
}
override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.GROUPS.name,
publicKey
)
_userGroups = if (userGroupsDump != null) {
UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump)
} else {
ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump ->
UserGroupsConfig.Companion.newInstance(secretKey, dump)
} ?: UserGroupsConfig.newInstance(secretKey)
}
}
_userGroups
}
override fun getUserConfigs(): List<ConfigBase> =
listOfNotNull(user, contacts, convoVolatile, userGroups)
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
}
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
}
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
val dumped = convoVolatile?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey,
dumped,
timestamp
)
}
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
}
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
try {
listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject)
}
when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp)
is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
}
} catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
}
}
override fun conversationInConfig(
publicKey: String?,
groupPublicKey: String?,
openGroupId: String?,
visibleOnly: Boolean
): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val (_, userPublicKey) = maybeGetUserInfo() ?: return true
if (openGroupId != null) {
val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
// Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
}
else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
}
else if (publicKey == userPublicKey) {
val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
}
else if (publicKey != null) {
val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false
return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN)
}
return false
}
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
// Ensure the change occurred after the last config message was handled (minus the buffer period)
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
}
}

View file

@ -45,4 +45,5 @@ interface DatabaseComponent {
fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
fun groupMemberDatabase(): GroupMemberDatabase
fun configDatabase(): ConfigDatabase
}

View file

@ -6,7 +6,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.session.libsession.database.MessageDataProvider
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret
@ -132,10 +131,18 @@ object DatabaseModule {
@Provides
@Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper)
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
val storage = Storage(context,openHelper, configFactory)
threadDatabase.setUpdateListener(storage)
return storage
}
@Provides
@Singleton
fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper)
@Provides
@Singleton
fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper)
}

View file

@ -1,4 +0,0 @@
package org.thoughtcrime.securesms.dependencies;
public interface InjectableType {
}

View file

@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes
}
@Provides
@Singleton
fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory =
ConfigFactory(context, configDatabase) {
val localUserPublicKey = TextSecurePreferences.getLocalNumber(context)
val secretKey = maybeUserEdSecretKey(context)
if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}.apply {
registerListener(context as ConfigFactoryUpdateListener)
}
}

View file

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory
object ClosedGroupManager {
fun silentlyRemoveGroup(context: Context, threadId: Long, groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean = true) {
val storage = MessagingModuleConfiguration.shared.storage
// Mark the group as inactive
storage.setActive(groupID, false)
storage.removeClosedGroupPublicKey(groupPublicKey)
// Remove the key pairs
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) {
storage.deleteConversation(threadId)
}
}
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
val groups = userGroups ?: return false
if (!group.isClosedGroup) return false
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
return groups.eraseLegacyGroup(groupPublicKey)
}
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
val groups = userGroups ?: return
if (!group.isClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return
val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey)
val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize))
val toSet = legacyInfo.copy(
members = latestMemberMap,
name = group.title,
disappearingTimer = groupRecipientSettings.expireMessages.toLong(),
priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize()
)
groups.set(toSet)
}
}

View file

@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task
@ -28,16 +29,28 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var groupConfigFactory: ConfigFactory
@Inject
lateinit var storage: Storage
private val originalMembers = HashSet<String>()
private val zombies = HashSet<String>()
private val members = HashSet<String>()
@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
isLoading = true
loaderContainer.fadeIn()
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
MessageSender.explicitLeave(groupPublicKey!!, true)
MessageSender.explicitLeave(groupPublicKey!!, false)
} else {
task {
if (hasNameChanged) {
@ -306,6 +319,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
promise.successUi {
loaderContainer.fadeOut()
isLoading = false
updateGroupConfig()
finish()
}.failUi { exception ->
val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
@ -316,5 +330,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
}
class GroupMembers(val members: List<String>, val zombieMembers: List<String>) { }
private fun updateGroupConfig() {
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
}
class GroupMembers(val members: List<String>, val zombieMembers: List<String>)
}

View file

@ -6,6 +6,7 @@ import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.DistributionTypes;
import org.session.libsession.utilities.GroupUtil;
@ -16,11 +17,14 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.BitmapUtil;
import java.io.IOException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Objects;
import java.util.Set;
import network.loki.messenger.libsession_util.UserGroupsConfig;
public class GroupManager {
public static long getOpenGroupThreadID(String id, @NonNull Context context) {

View file

@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() {
val openGroupID = "$sanitizedServer.${openGroup.room}"
OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext())
val storage = MessagingModuleConfiguration.shared.storage
storage.onOpenGroupAdded(sanitizedServer)
storage.onOpenGroupAdded(sanitizedServer, openGroup.room)
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext())
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())

View file

@ -9,8 +9,8 @@ import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.concurrent.Executors
object OpenGroupManager {
@ -40,7 +40,13 @@ object OpenGroupManager {
if (isPolling) { return }
isPolling = true
val storage = MessagingModuleConfiguration.shared.storage
val servers = storage.getAllOpenGroups().values.map { it.server }.toSet()
val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null }
toDelete.forEach { openGroup ->
Log.w("Loki", "Need to delete a group")
delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context)
}
val servers = serverGroups.map { it.server }.toSet()
synchronized(pollUpdaterLock) {
servers.forEach { server ->
pollers[server]?.stop() // Shouldn't be necessary
@ -58,14 +64,14 @@ object OpenGroupManager {
}
@WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? {
fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
val openGroupID = "$server.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val storage = MessagingModuleConfiguration.shared.storage
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
// Check it it's added already
val existingOpenGroup = threadDB.getOpenGroupChat(threadID)
if (existingOpenGroup != null) { return null }
if (existingOpenGroup != null) { return threadID to null }
// Clear any existing data if needed
storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server)
@ -86,7 +92,7 @@ object OpenGroupManager {
pollInfo = info.toPollInfo(),
createGroupIfMissingWithPublicKey = publicKey
)
return info
return threadID to info
}
fun restartPollerForServer(server: String) {
@ -102,23 +108,27 @@ object OpenGroupManager {
}
}
@WorkerThread
fun delete(server: String, room: String, context: Context) {
val storage = MessagingModuleConfiguration.shared.storage
val configFactory = MessagingModuleConfiguration.shared.configFactory
val threadDB = DatabaseComponent.get(context).threadDatabase()
val openGroupID = "$server.$room"
val openGroupID = "${server.removeSuffix("/")}.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val recipient = threadDB.getRecipientForThreadId(threadID) ?: return
threadDB.setThreadArchived(threadID)
val groupID = recipient.address.serialize()
// Stop the poller if needed
val openGroups = storage.getAllOpenGroups().filter { it.value.server == server }
if (openGroups.count() == 1) {
if (openGroups.isNotEmpty()) {
synchronized(pollUpdaterLock) {
val poller = pollers[server]
poller?.stop()
pollers.remove(server)
}
}
configFactory.userGroups?.eraseCommunity(server, room)
configFactory.convoVolatile?.eraseCommunity(server, room)
// Delete
storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server)
@ -126,19 +136,19 @@ object OpenGroupManager {
storage.removeLastOutboxMessageId(server)
val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase()
lokiThreadDB.removeOpenGroupChat(threadID)
ThreadUtils.queue {
threadDB.deleteConversation(threadID) // Must be invoked on a background thread
GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread
}
storage.deleteConversation(threadID) // Must be invoked on a background thread
GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
@WorkerThread
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 null
val publicKey = url.queryParameter("public_key") ?: return null
return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function
return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function
}
fun updateOpenGroup(openGroup: OpenGroup, context: Context) {

View file

@ -7,10 +7,15 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getConversationUnread
import javax.inject.Inject
@AndroidEntryPoint
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
@ -19,6 +24,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
// if we want to use dialog fragments properly.
lateinit var thread: ThreadRecord
@Inject lateinit var configFactory: ConfigFactory
var onViewDetailsTapped: (() -> Unit?)? = null
var onCopyConversationId: (() -> Unit?)? = null
var onPinTapped: (() -> Unit)? = null
@ -77,7 +84,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.setOnClickListener(this)
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned
binding.unpinTextView.isVisible = thread.isPinned

View file

@ -6,12 +6,12 @@ import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.recipients.Recipient
@ -19,12 +19,19 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.hig
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class ConversationView : LinearLayout {
@Inject lateinit var configFactory: ConfigFactory
private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) }
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
var thread: ThreadRecord? = null
@ -70,7 +77,7 @@ class ConversationView : LinearLayout {
// 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
}
val formattedUnreadCount = if (thread.isRead) {
val formattedUnreadCount = if (unreadCount == 0) {
null
} else {
if (unreadCount < 10000) unreadCount.toString() else "9999+"
@ -79,6 +86,7 @@ class ConversationView : LinearLayout {
val textSize = if (unreadCount < 1000) 12.0f else 10.0f
binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead)
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true)
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
val senderDisplayName = getUserDisplayName(thread.recipient)
@ -127,7 +135,7 @@ class ConversationView : LinearLayout {
return if (recipient.isLocalNumber) {
context.getString(R.string.note_to_self)
} else {
recipient.name // Internally uses the Contact API
recipient.toShortString() // Internally uses the Contact API
}
}
// endregion

View file

@ -1,19 +1,22 @@
package org.thoughtcrime.securesms.home
import android.Manifest
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -27,11 +30,14 @@ import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import network.loki.messenger.libsession_util.ConfigBase
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent
@ -41,7 +47,6 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.start.NewConversationFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
@ -50,8 +55,10 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
@ -62,7 +69,10 @@ import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.IP2Country
@ -80,6 +90,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null
@ -87,8 +102,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var recipientDatabase: RecipientDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
@ -97,7 +114,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, listener = this)
HomeAdapter(context = this, configFactory = configFactory, listener = this)
}
private val globalSearchAdapter = GlobalSearchAdapter { model ->
@ -157,15 +174,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
binding.sessionToolbar.disableClipping()
// Set up seed reminder view
val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
if (!hasViewedSeed) {
binding.seedReminderView.isVisible = true
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.setProgress(80, false)
binding.seedReminderView.delegate = this@HomeActivity
} else {
binding.seedReminderView.isVisible = false
lifecycleScope.launchWhenStarted {
val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
if (!hasViewedSeed) {
binding.seedReminderView.isVisible = true
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.setProgress(80, false)
binding.seedReminderView.delegate = this@HomeActivity
} else {
binding.seedReminderView.isVisible = false
}
}
setupMessageRequestsBanner()
// Set up recycler view
@ -175,6 +194,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter
binding.configOutdatedView.setOnClickListener {
textSecurePreferences.setHasLegacyConfig(false)
updateLegacyConfigView()
}
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity)
@ -191,6 +215,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
// subscribe to outdated config updates, this should be removed after long enough time for device migration
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
TextSecurePreferences.events.filter { it == TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG }.collect {
updateLegacyConfigView()
}
}
}
lifecycleScope.launchWhenStarted {
launch(Dispatchers.IO) {
// Double check that the long poller is up
@ -211,6 +244,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
}
// monitor the global search VM query
launch {
binding.globalSearchInputLayout.query
@ -263,6 +297,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
EventBus.getDefault().register(this@HomeActivity)
if (intent.hasExtra(FROM_ONBOARDING)
&& intent.getBooleanExtra(FROM_ONBOARDING, false)
&& !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
) {
Permissions.with(this)
.request(Manifest.permission.POST_NOTIFICATIONS)
.execute()
}
}
override fun onInputFocusChanged(hasFocus: Boolean) {
@ -311,6 +353,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
private fun updateLegacyConfigView() {
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset)
&& textSecurePreferences.getHasLegacyConfig()
}
override fun onResume() {
super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true)
@ -321,6 +368,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (textSecurePreferences.getHasViewedSeed()) {
binding.seedReminderView.isVisible = false
}
updateLegacyConfigView()
// TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true
// This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied
if (textSecurePreferences.getConfigurationMessageSynced()) {
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
@ -487,39 +539,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
private fun blockConversation(thread: ThreadRecord) {
AlertDialog.Builder(this)
.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question)
.setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ ->
lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setBlocked(thread.recipient, true)
// TODO: Remove in UserConfig branch
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
dialog.dismiss()
}
showSessionDialog {
title(R.string.RecipientPreferenceActivity_block_this_contact_question)
text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
button(R.string.RecipientPreferenceActivity_block) {
lifecycleScope.launch(Dispatchers.IO) {
storage.setBlocked(listOf(thread.recipient), true)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
}
}.show()
}
}
cancelButton()
}
}
private fun unblockConversation(thread: ThreadRecord) {
AlertDialog.Builder(this)
.setTitle(R.string.RecipientPreferenceActivity_unblock_this_contact_question)
.setMessage(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ ->
lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setBlocked(thread.recipient, false)
// TODO: Remove in UserConfig branch
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
dialog.dismiss()
}
showSessionDialog {
title(R.string.RecipientPreferenceActivity_unblock_this_contact_question)
text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
button(R.string.RecipientPreferenceActivity_unblock) {
lifecycleScope.launch(Dispatchers.IO) {
storage.setBlocked(listOf(thread.recipient), false)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
}
}.show()
}
}
cancelButton()
}
}
private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) {
@ -531,7 +581,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
} else {
MuteDialog.show(this) { until: Long ->
showMuteDialog(this) { until ->
lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setMuted(thread.recipient, until)
withContext(Dispatchers.Main) {
@ -553,14 +603,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun setConversationPinned(threadId: Long, pinned: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.setPinned(threadId, pinned)
storage.setPinned(threadId, pinned)
homeViewModel.tryUpdateChannel()
}
}
private fun markAllAsRead(thread: ThreadRecord) {
ThreadUtils.queue {
threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient)
MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset)
}
}
@ -577,48 +627,41 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} else {
resources.getString(R.string.activity_home_delete_conversation_dialog_message)
}
val dialog = AlertDialog.Builder(this)
dialog.setMessage(message)
dialog.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity as Context
// Cancel any outstanding jobs
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
// Send a leave group message if this is an active closed group
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
var isClosedGroup: Boolean
var groupPublicKey: String?
try {
groupPublicKey = GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
} catch (e: IOException) {
groupPublicKey = null
isClosedGroup = false
showSessionDialog {
text(message)
button(R.string.yes) {
lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity
// Cancel any outstanding jobs
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
// Send a leave group message if this is an active closed group
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
try {
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
?.let { MessageSender.explicitLeave(it, false) }
} catch (_: IOException) {
}
}
if (isClosedGroup) {
MessageSender.explicitLeave(groupPublicKey!!, false)
// Delete the conversation
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) }
} else {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.deleteConversation(threadID)
}
}
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
// Notify the user
val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
// Delete the conversation
val v2OpenGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
OpenGroupManager.delete(v2OpenGroup.server, v2OpenGroup.room, this@HomeActivity)
} else {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.deleteConversation(threadID)
}
}
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
// Notify the user
val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
button(R.string.no)
}
dialog.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}
dialog.create().show()
}
private fun openSettings() {
@ -632,17 +675,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
private fun hideMessageRequests() {
AlertDialog.Builder(this)
.setMessage("Hide message requests?")
.setPositiveButton(R.string.yes) { _, _ ->
showSessionDialog {
text("Hide message requests?")
button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests()
setupMessageRequestsBanner()
homeViewModel.tryUpdateChannel()
}
.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}
.create().show()
button(R.string.no)
}
}
private fun showNewConversation() {

View file

@ -10,10 +10,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter(
private val context: Context,
private val configFactory: ConfigFactory,
private val listener: ConversationClickListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback {
@ -29,7 +31,7 @@ class HomeAdapter(
get() = _data.toList()
set(newData) {
val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context)
val diff = HomeDiffUtil(previousData, newData, context, configFactory)
val diffResult = DiffUtil.calculateDiff(diff)
_data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)

View file

@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.home
import android.content.Context
import androidx.recyclerview.widget.DiffUtil
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread
class HomeDiffUtil(
private val old: List<ThreadRecord>,
private val new: List<ThreadRecord>,
private val context: Context
private val context: Context,
private val configFactory: ConfigFactory
): DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size
@ -42,7 +45,9 @@ class HomeDiffUtil(
oldItem.isFailed == newItem.isFailed &&
oldItem.isDelivered == newItem.isDelivered &&
oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending
oldItem.isPending == newItem.isPending &&
oldItem.lastSeen == newItem.lastSeen &&
configFactory.convoVolatile?.getConversationUnread(newItem) != true
)
}

View file

@ -9,9 +9,14 @@ import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import androidx.lifecycle.coroutineScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toPx
@ -29,6 +34,8 @@ class PathStatusView : View {
result
}
private var updateJob: Job? = null
constructor(context: Context) : super(context) {
initialize()
}
@ -87,16 +94,21 @@ class PathStatusView : View {
private fun handlePathsBuiltEvent() { update() }
private fun update() {
if (OnionRequestAPI.paths.isNotEmpty()) {
setBackgroundResource(R.drawable.accent_dot)
val hasPathsColor = context.getColor(R.color.accent_green)
mainColor = hasPathsColor
sessionShadowColor = hasPathsColor
} else {
setBackgroundResource(R.drawable.paths_building_dot)
val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme)
mainColor = pathsBuildingColor
sessionShadowColor = pathsBuildingColor
if (updateJob?.isActive != true) { // false or null
updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted {
val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths }
if (paths.isNotEmpty()) {
setBackgroundResource(R.drawable.accent_dot)
val hasPathsColor = context.getColor(R.color.accent_green)
mainColor = hasPathsColor
sessionShadowColor = hasPathsColor
} else {
setBackgroundResource(R.drawable.paths_building_dot)
val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme)
mainColor = pathsBuildingColor
sessionShadowColor = pathsBuildingColor
}
}
}
}

View file

@ -25,9 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.UiModeUtilities
import javax.inject.Inject
@AndroidEntryPoint
@ -60,6 +58,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
profilePictureView.update(recipient)
nameTextViewContainer.visibility = View.VISIBLE
nameTextViewContainer.setOnClickListener {
if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener
nameTextViewContainer.visibility = View.INVISIBLE
nameEditTextContainer.visibility = View.VISIBLE
nicknameEditText.text = null
@ -86,8 +85,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
}
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED
nameEditIcon.isVisible = threadRecipient.isContactRecipient
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener {
val clipboard =
@ -129,10 +134,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
newNickName = nicknameEditText.text.toString()
}
val publicKey = recipient.address.serialize()
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey)
val storage = MessagingModuleConfiguration.shared.storage
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName
contactDB.setContact(contact)
storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
}

View file

@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.util.DateUtils
@ -76,7 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
}
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
else -> {}
is Header, // do nothing for header
is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
}
}

View file

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.messagerequests
import android.app.AlertDialog
import android.content.Intent
import android.database.Cursor
import android.os.Bundle
@ -20,6 +19,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.push
import javax.inject.Inject
@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
adapter.glide = glide
binding.recyclerView.adapter = adapter
binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() }
binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() }
}
override fun onResume() {
@ -77,34 +77,34 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
}
override fun onBlockConversationClick(thread: ThreadRecord) {
val dialog = AlertDialog.Builder(this)
dialog.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question)
.setMessage(R.string.message_requests_block_message)
.setPositiveButton(R.string.recipient_preferences__block) { _, _ ->
viewModel.blockMessageRequest(thread)
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}
dialog.create().show()
fun doBlock() {
viewModel.blockMessageRequest(thread)
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
showSessionDialog {
title(R.string.RecipientPreferenceActivity_block_this_contact_question)
text(R.string.message_requests_block_message)
button(R.string.recipient_preferences__block) { doBlock() }
button(R.string.no)
}
}
override fun onDeleteConversationClick(thread: ThreadRecord) {
val dialog = AlertDialog.Builder(this)
dialog.setTitle(R.string.decline)
.setMessage(resources.getString(R.string.message_requests_decline_message))
.setPositiveButton(R.string.decline) { _,_ ->
viewModel.deleteMessageRequest(thread)
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
}
fun doDecline() {
viewModel.deleteMessageRequest(thread)
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
}
.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}
dialog.create().show()
}
showSessionDialog {
title(R.string.decline)
text(resources.getString(R.string.message_requests_decline_message))
button(R.string.decline) { doDecline() }
button(R.string.no)
}
}
private fun updateEmptyState() {
@ -113,19 +113,19 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
binding.clearAllMessageRequestsButton.isVisible = threadCount != 0
}
private fun deleteAllAndBlock() {
val dialog = AlertDialog.Builder(this)
dialog.setMessage(resources.getString(R.string.message_requests_clear_all_message))
dialog.setPositiveButton(R.string.yes) { _, _ ->
viewModel.clearAllMessageRequests()
private fun deleteAll() {
fun doDeleteAllAndBlock() {
viewModel.clearAllMessageRequests(false)
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
}
}
dialog.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
showSessionDialog {
text(resources.getString(R.string.message_requests_clear_all_message))
button(R.string.yes) { doDeleteAllAndBlock() }
button(R.string.no)
}
dialog.create().show()
}
}
}

View file

@ -48,6 +48,7 @@ class MessageRequestsAdapter(
private fun showPopupMenu(view: MessageRequestView) {
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!)

View file

@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor(
repository.deleteMessageRequest(thread)
}
fun clearAllMessageRequests() = viewModelScope.launch {
repository.clearAllMessageRequests()
fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch {
repository.clearAllMessageRequests(block)
}
}

View file

@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilit
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier {
executor.cancel();
}
private void cancelActiveNotifications(@NonNull Context context) {
private boolean cancelActiveNotifications(@NonNull Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
boolean hasNotifications = notifications.getActiveNotifications().length > 0;
notifications.cancel(SUMMARY_NOTIFICATION_ID);
try {
@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
Log.w(TAG, e);
notifications.cancelAll();
}
return hasNotifications;
}
private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) {
@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
!(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) {
TextSecurePreferences.removeHasHiddenMessageRequests(context);
}
if (isVisible && recipient != null) {
List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false);
if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); }
}
if (!TextSecurePreferences.isNotificationsEnabled(context) ||
(recipient != null && recipient.isMuted()))
@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier {
return;
}
if (!isVisible && !homeScreenVisible) {
if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) {
updateNotification(context, signal, 0);
}
}
private boolean hasExistingNotifications(Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
try {
StatusBarNotification[] activeNotifications = notifications.getActiveNotifications();
return activeNotifications.length > 0;
} catch (Exception e) {
return false;
}
}
@Override
public void updateNotification(@NonNull Context context, boolean signal, int reminderCount)
{
@ -267,8 +274,8 @@ public class DefaultMessageNotifier implements MessageNotifier {
if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context))
{
cancelActiveNotifications(context);
updateBadge(context, 0);
cancelActiveNotifications(context);
clearReminder(context);
return;
}

View file

@ -12,6 +12,8 @@ import androidx.core.app.NotificationManagerCompat;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.ReadReceipt;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI;
@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -52,18 +53,12 @@ public class MarkReadReceiver extends BroadcastReceiver {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
List<MarkedMessageInfo> messageIdsCollection = new LinkedList<>();
long currentTime = SnodeAPI.getNowWithOffset();
for (long threadId : threadIds) {
Log.i(TAG, "Marking as read: " + threadId);
List<MarkedMessageInfo> messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true);
messageIdsCollection.addAll(messageIds);
StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
storage.markConversationAsRead(threadId,currentTime, true);
}
process(context, messageIdsCollection);
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context);
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

View file

@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
@ -34,12 +35,19 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
@ -112,6 +120,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
@ -124,9 +133,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
.setAction(R.string.registration_activity__skip) { register(true) }
val skipJob = launch {
delay(30_000L)
delay(15_000L)
snackBar.show()
// show a dialog or something saying do you want to skip this bit?
}
// start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).apply {

View file

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.onboarding
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.app.AlertDialog
import android.content.Intent
import android.graphics.drawable.TransitionDrawable
import android.net.Uri
@ -20,6 +19,7 @@ import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.HomeActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.PNModeView
import org.thoughtcrime.securesms.util.disableClipping
@ -151,18 +151,20 @@ class PNModeActivity : BaseActionBarActivity() {
private fun register() {
if (selectedOptionView == null) {
val dialog = AlertDialog.Builder(this)
dialog.setTitle(R.string.activity_pn_mode_no_option_picked_dialog_title)
dialog.setPositiveButton(R.string.ok) { _, _ -> }
dialog.create().show()
showSessionDialog {
title(R.string.activity_pn_mode_no_option_picked_dialog_title)
button(R.string.ok)
}
return
}
TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView))
val application = ApplicationContext.getInstance(this)
application.startPollingIfNeeded()
application.registerForFCMIfNeeded(true)
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(HomeActivity.FROM_ONBOARDING, true)
show(intent)
}
// endregion

View file

@ -11,6 +11,7 @@ import android.text.style.ClickableSpan
import android.text.style.StyleSpan
import android.view.View
import android.widget.Toast
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
import org.session.libsession.snode.SnodeModule
@ -23,10 +24,17 @@ import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
@ -81,6 +89,7 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View file

@ -16,6 +16,7 @@ import android.text.style.StyleSpan
import android.view.View
import android.widget.Toast
import com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRegisterBinding
import org.session.libsession.snode.SnodeModule
@ -26,10 +27,17 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RegisterActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRegisterBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
@ -119,6 +127,7 @@ class RegisterActivity : BaseActionBarActivity() {
database.clearReceivedMessageHashValues()
KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View file

@ -162,15 +162,13 @@ public class Permissions {
request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]);
}
@SuppressWarnings("ConstantConditions")
private void executePermissionsRequestWithRationale(PermissionsRequest request) {
AlertDialog dialog = RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader)
.setPositiveButton(R.string.Permissions_continue, (d, which) -> executePermissionsRequest(request))
.setNegativeButton(R.string.Permissions_not_now, (d, which) -> executeNoPermissionsRequest(request))
.show();
dialog.getWindow().setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT);
Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
positiveButton.setContentDescription("Continue");
RationaleDialog.show(
permissionObject.getContext(),
rationaleDialogMessage,
() -> executePermissionsRequest(request),
() -> executeNoPermissionsRequest(request),
rationalDialogHeader);
}
private void executePermissionsRequest(PermissionsRequest request) {
@ -257,7 +255,7 @@ public class Permissions {
resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog);
}
private static Intent getApplicationSettingsIntent(@NonNull Context context) {
static Intent getApplicationSettingsIntent(@NonNull Context context) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", context.getPackageName(), null);
@ -354,20 +352,8 @@ public class Permissions {
@Override
public void run() {
Context context = this.context.get();
if (context != null) {
AlertDialog alertDialog = new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog)
.setTitle(R.string.Permissions_permission_required)
.setMessage(message)
.setPositiveButton(R.string.Permissions_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context)))
.setNegativeButton(android.R.string.cancel, null)
.create();
Button positiveButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE);
if (positiveButton != null) {
positiveButton.setContentDescription(context.getString(R.string.AccessibilityId_continue));
}
alertDialog.show();
}
if (context == null) return;
SettingsDialog.show(context, message);
}
}
}

View file

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms.permissions;
import android.app.AlertDialog;
import android.content.Context;
import android.graphics.Color;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout.LayoutParams;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.ViewUtil;
import network.loki.messenger.R;
public class RationaleDialog {
public static AlertDialog.Builder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) {
View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null);
view.setClipToOutline(true);
ViewGroup header = view.findViewById(R.id.header_container);
TextView text = view.findViewById(R.id.message);
for (int i=0;i<drawables.length;i++) {
ImageView imageView = new ImageView(context);
imageView.setImageDrawable(context.getResources().getDrawable(drawables[i]));
imageView.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
header.addView(imageView);
if (i != drawables.length - 1) {
TextView plus = new TextView(context);
plus.setText("+");
plus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 40);
plus.setTextColor(Color.WHITE);
LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
layoutParams.setMargins(ViewUtil.dpToPx(context, 20), 0, ViewUtil.dpToPx(context, 20), 0);
plus.setLayoutParams(layoutParams);
header.addView(plus);
}
}
text.setText(message);
return new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog).setView(view);
}
}

View file

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.permissions
import android.content.Context
import android.graphics.Color
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import org.session.libsession.utilities.ViewUtil
import org.thoughtcrime.securesms.showSessionDialog
object RationaleDialog {
@JvmStatic
fun show(
context: Context,
message: String,
onPositive: Runnable,
onNegative: Runnable,
@DrawableRes vararg drawables: Int
): AlertDialog {
val view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null)
.apply { clipToOutline = true }
val header = view.findViewById<ViewGroup>(R.id.header_container)
view.findViewById<TextView>(R.id.message).text = message
fun addIcon(id: Int) {
ImageView(context).apply {
setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme))
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}.also(header::addView)
}
fun addPlus() {
TextView(context).apply {
text = "+"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 40f)
setTextColor(Color.WHITE)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
ViewUtil.dpToPx(context, 20).let { setMargins(it, 0, it, 0) }
}
}.also(header::addView)
}
drawables.firstOrNull()?.let(::addIcon)
drawables.drop(1).forEach { addPlus(); addIcon(it) }
return context.showSessionDialog {
view(view)
button(R.string.Permissions_continue) { onPositive.run() }
button(R.string.Permissions_not_now) { onNegative.run() }
}
}
}

View file

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.permissions
import android.content.Context
import network.loki.messenger.R
import org.thoughtcrime.securesms.showSessionDialog
class SettingsDialog {
companion object {
@JvmStatic
fun show(context: Context, message: String) {
context.showSessionDialog {
title(R.string.Permissions_permission_required)
text(message)
button(R.string.Permissions_continue, R.string.AccessibilityId_continue) {
context.startActivity(Permissions.getApplicationSettingsIntent(context))
}
cancelButton()
}
}
}
}

View file

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.preferences
import android.app.AlertDialog
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.view.isVisible
@ -8,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityBlockedContactsBinding
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.showSessionDialog
@AndroidEntryPoint
class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
@ -19,17 +19,12 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) }
fun unblock() {
// show dialog
val title = viewModel.getTitle(this)
val message = viewModel.getMessage(this)
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.continue_2) { _, _ -> viewModel.unblock(this@BlockedContactsActivity) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
showSessionDialog {
title(viewModel.getTitle(this@BlockedContactsActivity))
text(viewModel.getMessage(this@BlockedContactsActivity))
button(R.string.continue_2) { viewModel.unblock() }
cancelButton()
}
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
@ -51,4 +46,3 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
}
}

View file

@ -1,9 +0,0 @@
package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class BlockedContactsLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs)

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.view.View
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
@ -16,8 +15,7 @@ class BlockedContactsPreference @JvmOverloads constructor(
super.onBindViewHolder(holder)
holder.itemView.setOnClickListener {
val intent = Intent(context, BlockedContactsActivity::class.java)
context.startActivity(intent)
Intent(context, BlockedContactsActivity::class.java).let(context::startActivity)
}
}
}

View file

@ -63,13 +63,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage)
return _state
}
fun unblock(context: Context) {
storage.unblock(state.selectedItems)
fun unblock() {
storage.setBlocked(state.selectedItems, false)
_state.value = state.copy(selectedItems = emptySet())
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
fun select(selectedItem: Recipient, isSelected: Boolean) {

View file

@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.preferences
import android.Manifest
import androidx.fragment.app.Fragment
import androidx.preference.Preference
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.showSessionDialog
internal class CallToggleListener(
private val context: Fragment,
private val setCallback: (Boolean) -> Unit
) : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
if (newValue == false) return true
// check if we've shown the info dialog and check for microphone permissions
context.showSessionDialog {
title(R.string.dialog_voice_video_title)
text(R.string.dialog_voice_video_message)
button(R.string.dialog_link_preview_enable_button_title, R.string.AccessibilityId_enable) { requestMicrophonePermission() }
cancelButton()
}
return false
}
private fun requestMicrophonePermission() {
Permissions.with(context)
.request(Manifest.permission.RECORD_AUDIO)
.onAllGranted {
setBooleanPreference(
context.requireContext(),
TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED,
true
)
setCallback(true)
}
.onAnyDenied { setCallback(false) }
.execute()
}
}

View file

@ -1,19 +0,0 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
class ChangeUiModeDialog : DialogFragment() {
companion object {
const val TAG = "ChangeUiModeDialog"
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
return android.app.AlertDialog.Builder(context)
.setTitle("TODO: remove this")
.show()
}
}

View file

@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import kotlinx.coroutines.Dispatchers
@ -15,10 +18,10 @@ import network.loki.messenger.databinding.DialogClearAllDataBinding
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ClearAllDataDialog : BaseDialog() {
class ClearAllDataDialog : DialogFragment() {
private lateinit var binding: DialogClearAllDataBinding
enum class Steps {
@ -35,7 +38,11 @@ class ClearAllDataDialog : BaseDialog() {
updateUI()
}
override fun setContentView(builder: AlertDialog.Builder) {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
view(createView())
}
private fun createView(): View {
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only))
val network = RadioOption("deviceAndNetwork", requireContext().getString(R.string.dialog_clear_all_data_clear_device_and_network))
@ -62,8 +69,7 @@ class ClearAllDataDialog : BaseDialog() {
Steps.DELETING -> { /* do nothing intentionally */ }
}
}
builder.setView(binding.root)
builder.setCancelable(false)
return binding.root
}
private fun updateUI() {

View file

@ -24,8 +24,6 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CustomDefaultPreference;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference;
import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat;
import network.loki.messenger.R;
@ -60,9 +58,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp
public void onDisplayPreferenceDialog(Preference preference) {
DialogFragment dialogFragment = null;
if (preference instanceof ColorPickerPreference) {
dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey());
} else if (preference instanceof CustomDefaultPreference) {
if (preference instanceof CustomDefaultPreference) {
dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey());
}

View file

@ -1,41 +1,24 @@
package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.preference.ListPreference
import network.loki.messenger.databinding.DialogListPreferenceBinding
import org.thoughtcrime.securesms.showSessionDialog
fun listPreferenceDialog(
context: Context,
listPreference: ListPreference,
dialogListener: () -> Unit
) : AlertDialog {
onChange: () -> Unit
) : AlertDialog = listPreference.run {
context.showSessionDialog {
val index = entryValues.indexOf(value)
val options = entries.map(CharSequence::toString).toTypedArray()
val builder = AlertDialog.Builder(context)
val binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(context))
binding.titleTextView.text = listPreference.dialogTitle
binding.messageTextView.text = listPreference.dialogMessage
builder.setView(binding.root)
val dialog = builder.show()
val valueIndex = listPreference.findIndexOfValue(listPreference.value)
RadioOptionAdapter(valueIndex) {
listPreference.value = it.value
dialog.dismiss()
dialogListener()
}
.apply {
listPreference.entryValues.zip(listPreference.entries) { value, title ->
RadioOption(value.toString(), title.toString())
}.let(this::submitList)
title(dialogTitle)
text(dialogMessage)
singleChoiceItems(options, index) {
listPreference.setValueIndex(it)
onChange()
}
.let { binding.recyclerView.adapter = it }
binding.closeButton.setOnClickListener { dialog.dismiss() }
return dialog
}
}

View file

@ -1,203 +0,0 @@
package org.thoughtcrime.securesms.preferences;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.fragment.app.Fragment;
import androidx.preference.Preference;
import org.session.libsession.utilities.TextSecurePreferences;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.CallNotificationBuilder;
import org.thoughtcrime.securesms.util.IntentUtils;
import kotlin.jvm.functions.Function1;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.R;
public class PrivacySettingsPreferenceFragment extends ListSummaryPreferenceFragment {
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
}
@Override
public void onCreate(Bundle paramBundle) {
super.onCreate(paramBundle);
this.findPreference(TextSecurePreferences.SCREEN_LOCK).setOnPreferenceChangeListener(new ScreenLockListener());
this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener());
this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener());
this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener());
this.findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED).setOnPreferenceChangeListener(new CallToggleListener(this, this::setCall));
initializeVisibility();
}
private Void setCall(boolean isEnabled) {
((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)).setChecked(isEnabled);
if (isEnabled && !CallNotificationBuilder.areNotificationsEnabled(requireActivity())) {
// show a dialog saying that calls won't work properly if you don't have notifications on at a system level
new AlertDialog.Builder(new ContextThemeWrapper(requireActivity(), R.style.ThemeOverlay_Session_AlertDialog))
.setTitle(R.string.CallNotificationBuilder_system_notification_title)
.setMessage(R.string.CallNotificationBuilder_system_notification_message)
.setPositiveButton(R.string.activity_notification_settings_title, (d, w) -> {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
Intent settingsIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID);
if (IntentUtils.isResolvable(requireContext(), settingsIntent)) {
startActivity(settingsIntent);
}
} else {
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse("package:"+BuildConfig.APPLICATION_ID));
if (IntentUtils.isResolvable(requireContext(), settingsIntent)) {
startActivity(settingsIntent);
}
}
d.dismiss();
})
.setNeutralButton(R.string.dismiss, (d, w) -> {
// do nothing, user might have broken notifications
d.dismiss();
})
.show();
}
return null;
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_app_protection);
}
@Override
public void onResume() {
super.onResume();
}
private void initializeVisibility() {
if (TextSecurePreferences.isPasswordDisabled(getContext())) {
KeyguardManager keyguardManager = (KeyguardManager)getContext().getSystemService(Context.KEYGUARD_SERVICE);
if (!keyguardManager.isKeyguardSecure()) {
((SwitchPreferenceCompat)findPreference(TextSecurePreferences.SCREEN_LOCK)).setChecked(false);
findPreference(TextSecurePreferences.SCREEN_LOCK).setEnabled(false);
}
} else {
findPreference(TextSecurePreferences.SCREEN_LOCK).setVisible(false);
findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setVisible(false);
}
}
private class ScreenLockListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (Boolean)newValue;
TextSecurePreferences.setScreenLockEnabled(getContext(), enabled);
Intent intent = new Intent(getContext(), KeyCachingService.class);
intent.setAction(KeyCachingService.LOCK_TOGGLED_EVENT);
getContext().startService(intent);
return true;
}
}
private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
return true;
}
}
private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean enabled = (boolean)newValue;
if (!enabled) {
ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear();
}
return true;
}
}
private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
return true;
}
}
private class CallToggleListener implements Preference.OnPreferenceChangeListener {
private final Fragment context;
private final Function1<Boolean, Void> setCallback;
private CallToggleListener(Fragment context, Function1<Boolean,Void> setCallback) {
this.context = context;
this.setCallback = setCallback;
}
private void requestMicrophonePermission() {
Permissions.with(context)
.request(Manifest.permission.RECORD_AUDIO)
.onAllGranted(() -> {
TextSecurePreferences.setBooleanPreference(context.requireContext(), TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, true);
setCallback.invoke(true);
})
.onAnyDenied(() -> setCallback.invoke(false))
.execute();
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
boolean val = (boolean) newValue;
if (val) {
// check if we've shown the info dialog and check for microphone permissions
AlertDialog dialog = new AlertDialog.Builder(new ContextThemeWrapper(context.requireContext(), R.style.ThemeOverlay_Session_AlertDialog))
.setTitle(R.string.dialog_voice_video_title)
.setMessage(R.string.dialog_voice_video_message)
.setPositiveButton(R.string.dialog_link_preview_enable_button_title, (d, w) -> {
requestMicrophonePermission();
})
.setNegativeButton(R.string.cancel, (d, w) -> {
})
.show();
Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
positiveButton.setContentDescription("Enable");
return false;
} else {
return true;
}
}
}
}

View file

@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.preferences
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.preference.Preference
import network.loki.messenger.BuildConfig
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswordDisabled
import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNotificationsEnabled
import org.thoughtcrime.securesms.util.IntentUtils
class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() {
override fun onCreate(paramBundle: Bundle?) {
super.onCreate(paramBundle)
findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!!
.onPreferenceChangeListener = ScreenLockListener()
findPreference<Preference>(TextSecurePreferences.TYPING_INDICATORS)!!
.onPreferenceChangeListener = TypingIndicatorsToggleListener()
findPreference<Preference>(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)!!
.onPreferenceChangeListener = CallToggleListener(this) { setCall(it) }
initializeVisibility()
}
private fun setCall(isEnabled: Boolean) {
(findPreference<Preference>(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED) as SwitchPreferenceCompat?)!!.isChecked =
isEnabled
if (isEnabled && !areNotificationsEnabled(requireActivity())) {
// show a dialog saying that calls won't work properly if you don't have notifications on at a system level
showSessionDialog {
title(R.string.CallNotificationBuilder_system_notification_title)
text(R.string.CallNotificationBuilder_system_notification_message)
button(R.string.activity_notification_settings_title) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:" + BuildConfig.APPLICATION_ID))
}
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
.takeIf { IntentUtils.isResolvable(requireContext(), it) }.let {
startActivity(it)
}
}
button(R.string.dismiss)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_app_protection)
}
override fun onResume() {
super.onResume()
}
private fun initializeVisibility() {
if (isPasswordDisabled(requireContext())) {
val keyguardManager =
requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (!keyguardManager.isKeyguardSecure) {
findPreference<SwitchPreferenceCompat>(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false
findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false
}
} else {
findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!!.isVisible = false
findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK_TIMEOUT)!!.isVisible = false
}
}
private inner class ScreenLockListener : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
val enabled = newValue as Boolean
setScreenLockEnabled(context!!, enabled)
val intent = Intent(context, KeyCachingService::class.java)
intent.action = KeyCachingService.LOCK_TOGGLED_EVENT
context!!.startService(intent)
return true
}
}
private inner class TypingIndicatorsToggleListener : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
val enabled = newValue as Boolean
if (!enabled) {
ApplicationContext.getInstance(requireContext()).typingStatusRepository.clear()
}
return true
}
}
}

View file

@ -1,38 +1,34 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.LayoutInflater
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogSeedBinding
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
class SeedDialog : BaseDialog() {
class SeedDialog: DialogFragment() {
private val seed by lazy {
var hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED)
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account
}
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(requireContext(), fileName)
}
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
val hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED)
?: IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account
MnemonicCodec { fileName -> MnemonicUtilities.loadFileContents(requireContext(), fileName) }
.encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english)
}
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogSeedBinding.inflate(LayoutInflater.from(requireContext()))
binding.seedTextView.text = seed
binding.closeButton.setOnClickListener { dismiss() }
binding.copyButton.setOnClickListener { copySeed() }
builder.setView(binding.root)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_seed_title)
text(R.string.dialog_seed_explanation)
text(seed, R.style.SessionIDTextView)
button(R.string.copy, R.string.AccessibilityId_copy_recovery_phrase) { copySeed() }
button(R.string.close) { dismiss() }
}
private fun copySeed() {
@ -42,4 +38,4 @@ class SeedDialog : BaseDialog() {
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
dismiss()
}
}
}

View file

@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.preferences
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
@ -16,16 +19,19 @@ import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.BuildConfig
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding
import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.utilities.*
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
@ -33,6 +39,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
@ -40,6 +47,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@ -48,9 +56,14 @@ import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import java.io.File
import java.security.SecureRandom
import java.util.Date
import javax.inject.Inject
@AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivitySettingsBinding
private var displayNameEditActionMode: ActionMode? = null
set(value) { field = value; handleDisplayNameEditActionModeChanged() }
@ -203,27 +216,36 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
val promises = mutableListOf<Promise<*, Exception>>()
if (displayName != null) {
TextSecurePreferences.setProfileName(this, displayName)
configFactory.user?.setName(displayName)
}
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
if (isUpdatingProfilePicture) {
if (profilePicture != null) {
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
} else {
TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis())
TextSecurePreferences.setProfilePictureURL(this, null)
MessagingModuleConfiguration.shared.storage.clearUserPic()
}
}
val compoundPromise = all(promises)
compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below
val userConfig = configFactory.user
if (isUpdatingProfilePicture) {
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 )
TextSecurePreferences.setLastProfilePictureUpload(this, Date().time)
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// new config
val url = TextSecurePreferences.getProfilePictureURL(this)
val profileKey = ProfileKeyUtil.getProfileKey(this)
if (profilePicture == null) {
userConfig?.setPic(UserPic.DEFAULT)
} else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) {
userConfig?.setPic(UserPic(url, profileKey))
}
}
if (profilePicture != null || displayName != null) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
if (userConfig != null && userConfig.needsDump()) {
configFactory.persist(userConfig, SnodeAPI.nowWithOffset)
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
}
compoundPromise.alwaysUi {
if (displayName != null) {
@ -263,19 +285,15 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
}
private fun showEditProfilePictureUI() {
AlertDialog.Builder(this)
.setTitle(R.string.activity_settings_set_display_picture)
.setView(R.layout.dialog_change_avatar)
.setPositiveButton(R.string.activity_settings_upload) { _, _ ->
startAvatarSelection()
showSessionDialog {
title(R.string.activity_settings_set_display_picture)
view(R.layout.dialog_change_avatar)
button(R.string.activity_settings_upload) { startAvatarSelection() }
if (TextSecurePreferences.getProfileAvatarId(context) != 0) {
button(R.string.activity_settings_remove) { removeAvatar() }
}
.setNegativeButton(R.string.cancel) { _, _ -> }
.apply {
if (TextSecurePreferences.getProfileAvatarId(context) != 0) {
setNeutralButton(R.string.activity_settings_remove) { _, _ -> removeAvatar() }
}
}
.show().apply {
cancelButton()
}.apply {
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
?.also(::setupProfilePictureView)

View file

@ -1,17 +1,18 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.LayoutInflater
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
@ -20,11 +21,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.BuildConfig
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogShareLogsBinding
import org.session.libsignal.utilities.ExternalStorageUtil
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.util.FileProviderUtil
import org.thoughtcrime.securesms.util.StreamUtil
import java.io.File
@ -33,21 +33,15 @@ import java.io.IOException
import java.util.Objects
import java.util.concurrent.TimeUnit
class ShareLogsDialog : BaseDialog() {
class ShareLogsDialog : DialogFragment() {
private var shareJob: Job? = null
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogShareLogsBinding.inflate(LayoutInflater.from(requireContext()))
binding.cancelButton.setOnClickListener {
dismiss()
}
binding.shareButton.setOnClickListener {
// start the export and share
shareLogs()
}
builder.setView(binding.root)
builder.setCancelable(false)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_share_logs_title)
text(R.string.dialog_share_logs_explanation)
button(R.string.share) { shareLogs() }
cancelButton { dismiss() }
}
private fun shareLogs() {

View file

@ -1,251 +0,0 @@
package org.thoughtcrime.securesms.preferences.widgets;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.TypedArrayUtils;
import androidx.preference.DialogPreference;
import androidx.preference.PreferenceViewHolder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.widget.ImageView;
import com.takisoft.colorpicker.ColorPickerDialog;
import com.takisoft.colorpicker.ColorPickerDialog.Size;
import com.takisoft.colorpicker.ColorStateDrawable;
import network.loki.messenger.R;
public class ColorPickerPreference extends DialogPreference {
private static final String TAG = ColorPickerPreference.class.getSimpleName();
private int[] colors;
private CharSequence[] colorDescriptions;
private int color;
private int columns;
private int size;
private boolean sortColors;
private ImageView colorWidget;
private OnPreferenceChangeListener listener;
public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0);
int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors);
if (colorsId != 0) {
colors = context.getResources().getIntArray(colorsId);
}
colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions);
color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0);
columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3);
size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2);
sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false);
a.recycle();
setWidgetLayoutResource(R.layout.preference_widget_color_swatch);
}
public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
@SuppressLint("RestrictedApi")
public ColorPickerPreference(Context context, AttributeSet attrs) {
this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle,
android.R.attr.dialogPreferenceStyle));
}
public ColorPickerPreference(Context context) {
this(context, null);
}
@Override
public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) {
super.setOnPreferenceChangeListener(listener);
this.listener = listener;
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget);
setColorOnWidget(color);
}
private void setColorOnWidget(int color) {
if (colorWidget == null) {
return;
}
Drawable[] colorDrawable = new Drawable[]
{ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)};
colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color));
}
/**
* Returns the current color.
*
* @return The current color.
*/
public int getColor() {
return color;
}
/**
* Sets the current color.
*
* @param color The current color.
*/
public void setColor(int color) {
setInternalColor(color, false);
}
/**
* Returns all of the available colors.
*
* @return The available colors.
*/
public int[] getColors() {
return colors;
}
/**
* Sets the available colors.
*
* @param colors The available colors.
*/
public void setColors(int[] colors) {
this.colors = colors;
}
/**
* Returns whether the available colors should be sorted automatically based on their HSV
* values.
*
* @return Whether the available colors should be sorted automatically based on their HSV
* values.
*/
public boolean isSortColors() {
return sortColors;
}
/**
* Sets whether the available colors should be sorted automatically based on their HSV
* values. The sorting does not modify the order of the original colors supplied via
* {@link #setColors(int[])} or the XML attribute {@code app:colors}.
*
* @param sortColors Whether the available colors should be sorted automatically based on their
* HSV values.
*/
public void setSortColors(boolean sortColors) {
this.sortColors = sortColors;
}
/**
* Returns the available colors' descriptions that can be used by accessibility services.
*
* @return The available colors' descriptions.
*/
public CharSequence[] getColorDescriptions() {
return colorDescriptions;
}
/**
* Sets the available colors' descriptions that can be used by accessibility services.
*
* @param colorDescriptions The available colors' descriptions.
*/
public void setColorDescriptions(CharSequence[] colorDescriptions) {
this.colorDescriptions = colorDescriptions;
}
/**
* Returns the number of columns to be used in the picker dialog for displaying the available
* colors. If the value is less than or equals to 0, the number of columns will be determined
* automatically by the system using FlexboxLayoutManager.
*
* @return The number of columns to be used in the picker dialog.
* @see com.google.android.flexbox.FlexboxLayoutManager
*/
public int getColumns() {
return columns;
}
/**
* Sets the number of columns to be used in the picker dialog for displaying the available
* colors. If the value is less than or equals to 0, the number of columns will be determined
* automatically by the system using FlexboxLayoutManager.
*
* @param columns The number of columns to be used in the picker dialog. Use 0 to set it to
* 'auto' mode.
* @see com.google.android.flexbox.FlexboxLayoutManager
*/
public void setColumns(int columns) {
this.columns = columns;
}
/**
* Returns the size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
*
* @return The size of the color swatches in the dialog.
* @see ColorPickerDialog#SIZE_SMALL
* @see ColorPickerDialog#SIZE_LARGE
*/
@Size
public int getSize() {
return size;
}
/**
* Sets the size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
*
* @param size The size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
* @see ColorPickerDialog#SIZE_SMALL
* @see ColorPickerDialog#SIZE_LARGE
*/
public void setSize(@Size int size) {
this.size = size;
}
private void setInternalColor(int color, boolean force) {
int oldColor = getPersistedInt(0);
boolean changed = oldColor != color;
if (changed || force) {
this.color = color;
persistInt(color);
setColorOnWidget(color);
if (listener != null) listener.onPreferenceChange(this, color);
notifyChanged();
}
}
@Override
protected Object onGetDefaultValue(TypedArray a, int index) {
return a.getString(index);
}
@Override
protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) {
final String defaultValue = (String) defaultValueObj;
setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true);
}
}

View file

@ -1,64 +0,0 @@
package org.thoughtcrime.securesms.preferences.widgets;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceDialogFragmentCompat;
import com.takisoft.colorpicker.ColorPickerDialog;
import com.takisoft.colorpicker.OnColorSelectedListener;
public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener {
private int pickedColor;
public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) {
ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat();
Bundle b = new Bundle(1);
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key);
fragment.setArguments(b);
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
ColorPickerPreference pref = getColorPickerPreference();
ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext())
.setSelectedColor(pref.getColor())
.setColors(pref.getColors())
.setColorContentDescriptions(pref.getColorDescriptions())
.setSize(pref.getSize())
.setSortColors(pref.isSortColors())
.setColumns(pref.getColumns())
.build();
ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params);
dialog.setTitle(pref.getDialogTitle());
return dialog;
}
@Override
public void onDialogClosed(boolean positiveResult) {
ColorPickerPreference preference = getColorPickerPreference();
if (positiveResult) {
preference.setColor(pickedColor);
}
}
@Override
public void onColorSelected(int color) {
this.pickedColor = color;
super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
}
ColorPickerPreference getColorPickerPreference() {
return (ColorPickerPreference) getPreference();
}
}

View file

@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionJobDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@ -62,7 +64,7 @@ interface ConversationRepository {
suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit>
suspend fun clearAllMessageRequests(): ResultOf<Unit>
suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit>
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
@ -82,8 +84,10 @@ class DefaultConversationRepository @Inject constructor(
private val mmsDb: MmsDatabase,
private val mmsSmsDb: MmsSmsDatabase,
private val recipientDb: RecipientDatabase,
private val storage: Storage,
private val lokiMessageDb: LokiMessageDatabase,
private val sessionJobDb: SessionJobDatabase
private val sessionJobDb: SessionJobDatabase,
private val configFactory: ConfigFactory
) : ConversationRepository {
override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? {
@ -125,8 +129,9 @@ class DefaultConversationRepository @Inject constructor(
}
}
// This assumes that recipient.isContactRecipient is true
override fun setBlocked(recipient: Recipient, blocked: Boolean) {
recipientDb.setBlocked(recipient, blocked)
storage.setBlocked(listOf(recipient), blocked)
}
override fun deleteLocally(recipient: Recipient, message: MessageRecord) {
@ -139,7 +144,7 @@ class DefaultConversationRepository @Inject constructor(
}
override fun setApproved(recipient: Recipient, isApproved: Boolean) {
recipientDb.setApproved(recipient, isApproved)
storage.setRecipientApproved(recipient, isApproved)
}
override suspend fun deleteForEveryone(
@ -250,29 +255,33 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun deleteThread(threadId: Long): ResultOf<Unit> {
sessionJobDb.cancelPendingMessageSendJobs(threadId)
threadDb.deleteConversation(threadId)
storage.deleteConversation(threadId)
return ResultOf.Success(Unit)
}
override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> {
sessionJobDb.cancelPendingMessageSendJobs(thread.threadId)
threadDb.deleteConversation(thread.threadId)
storage.deleteConversation(thread.threadId)
return ResultOf.Success(Unit)
}
override suspend fun clearAllMessageRequests(): ResultOf<Unit> {
override suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit> {
threadDb.readerFor(threadDb.unapprovedConversationList).use { reader ->
while (reader.next != null) {
deleteMessageRequest(reader.current)
val recipient = reader.current.recipient
if (block) {
setBlocked(recipient, true)
}
}
}
return ResultOf.Success(Unit)
}
override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation ->
recipientDb.setApproved(recipient, true)
storage.setRecipientApproved(recipient, true)
val message = MessageRequestResponse(true)
MessageSender.send(message, Destination.from(recipient.address))
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
.success {
threadDb.setHasSent(threadId, true)
continuation.resume(ResultOf.Success(Unit))
@ -283,7 +292,7 @@ class DefaultConversationRepository @Inject constructor(
override fun declineMessageRequest(threadId: Long) {
sessionJobDb.cancelPendingMessageSendJobs(threadId)
threadDb.deleteConversation(threadId)
storage.deleteConversation(threadId)
}
override fun hasReceived(threadId: Long): Boolean {

View file

@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.service;
import android.content.Context;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate;
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage;
import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage;
@ -15,6 +17,7 @@ import org.session.libsignal.messages.SignalServiceGroup;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
@ -35,12 +38,14 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
private final SmsDatabase smsDatabase;
private final MmsDatabase mmsDatabase;
private final MmsSmsDatabase mmsSmsDatabase;
private final Context context;
public ExpiringMessageManager(Context context) {
this.context = context.getApplicationContext();
this.smsDatabase = DatabaseComponent.get(context).smsDatabase();
this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase();
this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
executor.execute(new LoadTask());
executor.execute(new ProcessTask());
@ -79,12 +84,11 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
}
if (message.getId() != null) {
DatabaseComponent.get(context).smsDatabase().deleteMessage(message.getId());
smsDatabase.deleteMessage(message.getId());
}
}
private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) {
MmsDatabase database = DatabaseComponent.get(context).mmsDatabase();
String senderPublicKey = message.getSender();
Long sentTimestamp = message.getSentTimestamp();
@ -106,6 +110,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
Address groupAddress = Address.fromSerialized(groupID);
recipient = Recipient.from(context, groupAddress, false);
}
Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient);
if (threadId == null) {
return;
}
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1,
duration * 1000L, true,
@ -120,10 +128,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
Optional.absent(),
Optional.absent());
//insert the timer update message
database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true);
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true);
//set the timer to the conversation
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration);
MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration);
} catch (IOException | MmsException ioe) {
Log.e("Loki", "Failed to insert expiration update message.");
@ -131,28 +139,30 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
}
private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) {
MmsDatabase database = DatabaseComponent.get(context).mmsDatabase();
Long sentTimestamp = message.getSentTimestamp();
String groupId = message.getGroupPublicKey();
int duration = message.getDuration();
Address address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient());
Recipient recipient = Recipient.from(context, address, false);
Address address;
try {
OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId);
database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true);
if (groupId != null) {
// we need the group ID as recipient for setExpireMessages below
recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)), false);
address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId));
} else {
address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient());
}
//set the timer to the conversation
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration);
Recipient recipient = Recipient.from(context, address, false);
StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
message.setThreadID(storage.getOrCreateThreadIdFor(address));
OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId);
mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true);
//set the timer to the conversation
MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration);
} catch (MmsException | IOException ioe) {
Log.e("Loki", "Failed to insert expiration update message.");
Log.e("Loki", "Failed to insert expiration update message.", ioe);
}
}
@ -163,7 +173,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
@Override
public void startAnyExpiration(long timestamp, @NotNull String author) {
MessageRecord messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageFor(timestamp, author);
MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author);
if (messageRecord != null) {
boolean mms = messageRecord.isMms();
Recipient recipient = messageRecord.getRecipient();

View file

@ -1,16 +1,23 @@
package org.thoughtcrime.securesms.sskenvironment
import android.content.Context
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol {
override fun setNickname(context: Context, recipient: Recipient, nickname: String?) {
if (recipient.isLocalNumber) return
val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID)
@ -20,10 +27,12 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
contact.nickname = nickname
contactDatabase.setContact(contact)
}
contactUpdatedInternal(contact)
}
override fun setName(context: Context, recipient: Recipient, name: String) {
override fun setName(context: Context, recipient: Recipient, name: String?) {
// New API
if (recipient.isLocalNumber) return
val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID)
@ -37,40 +46,69 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
val database = DatabaseComponent.get(context).recipientDatabase()
database.setProfileName(recipient, name)
recipient.notifyListeners()
contactUpdatedInternal(contact)
}
override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) {
val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address)
JobQueue.shared.add(job)
override fun setProfilePicture(
context: Context,
recipient: Recipient,
profilePictureURL: String?,
profileKey: ByteArray?
) {
val hasPendingDownload = DatabaseComponent
.get(context)
.sessionJobDatabase()
.getAllJobs(RetrieveProfileAvatarJob.KEY).any {
(it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address
}
val resolved = recipient.resolve()
DatabaseComponent.get(context).storage().setProfilePicture(
recipient = resolved,
newProfileKey = profileKey,
newProfilePicture = profilePictureURL
)
val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID)
if (contact == null) contact = Contact(sessionID)
contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address)
if (contact.profilePictureURL != profilePictureURL) {
if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) {
contact.profilePictureEncryptionKey = profileKey
contact.profilePictureURL = profilePictureURL
contactDatabase.setContact(contact)
}
}
override fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) {
// New API
val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID)
if (contact == null) contact = Contact(sessionID)
contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address)
if (!contact.profilePictureEncryptionKey.contentEquals(profileKey)) {
contact.profilePictureEncryptionKey = profileKey
contactDatabase.setContact(contact)
contactUpdatedInternal(contact)
if (!hasPendingDownload) {
val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address)
JobQueue.shared.add(job)
}
// Old API
val database = DatabaseComponent.get(context).recipientDatabase()
database.setProfileKey(recipient, profileKey)
}
override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) {
val database = DatabaseComponent.get(context).recipientDatabase()
database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode)
}
override fun contactUpdatedInternal(contact: Contact): String? {
val contactConfig = configFactory.contacts ?: return null
if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null
val sessionId = SessionId(contact.sessionID)
if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs
contactConfig.upsertContact(contact.sessionID) {
this.name = contact.name.orEmpty()
this.nickname = contact.nickname.orEmpty()
val url = contact.profilePictureURL
val key = contact.profilePictureEncryptionKey
if (!url.isNullOrEmpty() && key != null && key.size == 32) {
this.profilePicture = UserPic(url, key)
} else if (url.isNullOrEmpty() && key == null) {
this.profilePicture = UserPic.DEFAULT
}
}
if (contactConfig.needsPush()) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
return contactConfig.get(contact.sessionID)?.hashCode()?.toString()
}
}

View file

@ -7,10 +7,10 @@ import android.view.View
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
fun BaseActionBarActivity.setUpActionBarSessionLogo(hideBackButton: Boolean = false) {
val actionbar = supportActionBar!!
@ -66,7 +66,7 @@ interface ActivityDispatcher {
fun get(context: Context) = context.getSystemService(SERVICE) as? ActivityDispatcher
}
fun dispatchIntent(body: (Context)->Intent?)
fun showDialog(baseDialog: BaseDialog, tag: String? = null)
fun showDialog(dialogFragment: DialogFragment, tag: String? = null)
}
fun TextSecurePreferences.themeState(): ThemeState {

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