diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..b650b98b11 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libsession-util/libsession-util"] + path = libsession-util/libsession-util + url = https://github.com/oxen-io/libsession-util.git diff --git a/app/build.gradle b/app/build.gradle index 8a42419732..2fbac71054 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,17 +93,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") @@ -116,52 +112,52 @@ 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' } -def canonicalVersionCode = 338 -def canonicalVersionName = "1.16.9" +def canonicalVersionCode = 353 +def canonicalVersionName = "1.17.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 087d486893..eabe06f7d9 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -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()) } diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt new file mode 100644 index 0000000000..1638d83835 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -0,0 +1,97 @@ +package network.loki.messenger + +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? { + 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): 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() { + 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)) + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d2b2123dd..aa81fafc2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,12 +29,16 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 53141534af..e4be27f24b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 7e732d1aa7..ded6b4f140 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -260,6 +260,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { CALL_CONNECTED -> { wantsToAnswer = false } + else -> { /* do nothing */ } } updateControls(state) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index a827a7d260..0101677d89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -32,10 +32,10 @@ class ProfilePictureView @JvmOverloads constructor( var isLarge = false private val profilePicturesCache = mutableMapOf() - 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 @@ -52,12 +52,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 @@ -108,30 +115,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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e88cf1d08b..72c9749f9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -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.root.glide = glide binding.profilePictureView.root.update(user) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt index 2e62932ab0..92f050f76a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index b419658e78..7b742a9be9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -3,28 +3,50 @@ 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.DialogInterface +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 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 @@ -32,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 @@ -58,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 @@ -91,10 +122,25 @@ 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.BaseDialog +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.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 @@ -107,16 +153,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.showExpirationDialog import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.util.* +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 @@ -185,11 +246,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 @@ -210,6 +271,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null + private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false private val isScrolledToBottom: Boolean @@ -229,11 +291,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) }, @@ -275,6 +343,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(null) + private val firstLoad = AtomicBoolean(true) private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 @@ -319,28 +388,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() @@ -350,20 +422,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) + } } } @@ -371,16 +453,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,18 +503,40 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, 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) { @@ -433,7 +546,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) @@ -442,6 +555,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 { _, _, _, _, _, _, _, _, _ -> @@ -577,7 +694,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() { @@ -610,15 +727,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 { @@ -702,11 +841,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) } @@ -904,17 +1040,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,7 +1149,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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(this@ConversationActivityV2) + viewModel.block() if (deleteThread) { viewModel.deleteThread() finish() @@ -1005,7 +1184,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (group?.isActive == false) { return } } showExpirationDialog(thread.expireMessages) { expirationTime -> - recipientDb.setExpireMessages(thread, expirationTime) + storage.setExpirationTimer(thread.address.serialize(), expirationTime) val message = ExpirationTimerUpdate(expirationTime) message.recipient = thread.address.serialize() message.sentTimestamp = SnodeAPI.nowWithOffset @@ -1022,7 +1201,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe destructiveButton( R.string.ConversationActivity_unblock, R.string.AccessibilityId_block_confirm - ) { viewModel.unblock(this@ConversationActivityV2) } + ) { viewModel.unblock() } cancelButton() } } @@ -1368,11 +1547,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) { @@ -1390,19 +1575,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { - val recipient = viewModel.recipient ?: return + private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { + 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 @@ -1419,14 +1606,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, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { - val recipient = viewModel.recipient ?: return + private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair? { + 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() @@ -1460,6 +1649,7 @@ 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() { @@ -1829,7 +2019,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() } } } @@ -1866,15 +2056,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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index ca7e1de780..6013af5ba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -31,10 +31,14 @@ 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(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val contactCache = SparseArray(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 ) @@ -183,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 } @@ -217,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 } @@ -231,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 } @@ -241,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 + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b8b460b603..13736974b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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 = _uiState private var _recipient: RetrieveOnce = 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 create(modelClass: Class): 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 = emptyList(), - val isMessageRequestAccepted: Boolean? = null + val isMessageRequestAccepted: Boolean? = null, + val conversationExists: Boolean ) data class RetrieveOnce(val retrieval: () -> T?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index c90f5b2be7..7479c4a9ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -39,12 +39,7 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte } 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) - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index e4cbd6ecee..a886e89192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -36,7 +36,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D ThreadUtils.queue { try { openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) } - MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server) + 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 11add657f2..02ee4ae45f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -63,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(R.id.menu_badge_icon) - val badgeView = actionView.findViewById(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(R.id.menu_badge_icon) + val badgeView = actionView.findViewById(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) } @@ -86,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) } } @@ -309,7 +310,7 @@ object ConversationMenuHelper { val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - if (isClosedGroup) MessageSender.leave(groupPublicKey, true) + if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false) else onLeaveFailed() } catch (e: Exception) { onLeaveFailed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 75a3c58752..e7214cfa7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -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 @@ -39,9 +37,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.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 { @@ -69,12 +68,10 @@ class VisibleMessageContentView : ConstraintLayout { onAttachmentNeedsDownload: (Long, Long) -> Unit ) { // 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 @@ -131,7 +128,6 @@ class VisibleMessageContentView : ConstraintLayout { delegate?.scrollToMessageIfPossible(quote.id) } } - val hasMedia = message.slideDeck.asAttachments().isNotEmpty() } if (message is MmsMessageRecord) { @@ -244,11 +240,6 @@ class VisibleMessageContentView : ConstraintLayout { private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = listOf(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, @@ -266,6 +257,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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 319140731a..00c5c08a4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -111,6 +111,8 @@ class VisibleMessageView : LinearLayout { private fun initialize() { isHapticFeedbackEnabled = true setWillNotDraw(false) + binding.root.disableClipping() + binding.mainContainer.disableClipping() binding.messageInnerContainer.disableClipping() binding.messageContentView.root.disableClipping() } @@ -125,6 +127,7 @@ class VisibleMessageView : LinearLayout { searchQuery: String?, contact: Contact?, senderSessionID: String, + lastSeen: Long, delegate: VisibleMessageViewDelegate?, onAttachmentNeedsDownload: (Long, Long) -> Unit ) { @@ -164,6 +167,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)) @@ -177,7 +181,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 @@ -191,6 +195,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 @@ -409,6 +415,10 @@ class VisibleMessageView : LinearLayout { binding.profilePictureView.root.recycle() binding.messageContentView.root.recycle() } + + fun playHighlight() { + binding.messageContentView.root.playHighlight() + } // endregion // region Interaction diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index e1bf92c5f2..2b829af152 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index dd90b699e3..088685241c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt new file mode 100644 index 0000000000..19a511bfd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 79adead57e..66d01114ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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 getAllGroups() { + public List getAllGroups(boolean includeInactive) { Reader reader = getGroups(); GroupRecord record; List 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index b0f6a676c7..53f4ea3196 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt index 300217faba..1cbbf34c9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -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 { 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index e8f65dae06..111b6d5365 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -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?): 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 { + return setMessagesRead( + THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", + arrayOf(threadId.toString(), beforeTime.toString()) + ) + } + fun setMessagesRead(threadId: Long): List { 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 { - 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 { - 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 { 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index c7f9d61324..0db4dd00e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -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 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(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime); + } + public class Reader implements Closeable { private final Cursor cursor; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index e3570fd283..b7b8364184 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -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 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 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 recipients, boolean blocked) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 40eee97428..49a6339368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index b081fb007e..6221446aae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun cancelPendingMessageSendJobs(threadID: Long) { val database = databaseHelper.writableDatabase val attachmentUploadJobKeys = mutableListOf() + 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 42a00ccbb2..4ef576f404 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -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 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 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 insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { + protected Optional 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 insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); } public Optional insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0, true, true); + return insertMessageInbox(message, 0, 0, true); } - public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate); } public Optional 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 365c12b839..c77ad1c638 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -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, - 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 = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = 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 = 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, 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, admins: Collection, 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) { @@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, OpenGroupManager.updateOpenGroup(openGroup, context) } - override fun getAllGroups(): List { - return DatabaseComponent.get(context).groupDatabase().allGroups + override fun getAllGroups(includeInactive: Boolean): List { + 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) { + 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) { 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) { + override fun setBlocked(recipients: Iterable, 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 52d914af08..5044529981 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -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 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 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 setRead(long threadId, long lastReadTime) { + + final List smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime); + final List 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() {{ + addAll(smsRecords); + addAll(mmsRecords); + }}; + } + public List 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 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 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 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 8a4473b409..89bda09948 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 6f26c6ae3a..936e4f287f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt new file mode 100644 index 0000000000..48eda45001 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -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? +) : + 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 = TextSecurePreferences.hasForcedNewConfig(context) + + private val listeners: MutableList = mutableListOf() + fun registerListener(listener: ConfigFactoryUpdateListener) { + listeners += listener + } + + fun unregisterListener(listener: ConfigFactoryUpdateListener) { + listeners -= listener + } + + private inline fun 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 = + 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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index 60d31a19d4..f2c046e0aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -45,4 +45,5 @@ interface DatabaseComponent { fun attachmentProvider(): MessageDataProvider fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun groupMemberDatabase(): GroupMemberDatabase + fun configDatabase(): ConfigDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 3372e10330..524100190e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -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) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java deleted file mode 100644 index 033b3ef45a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.thoughtcrime.securesms.dependencies; - -public interface InjectableType { -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt new file mode 100644 index 0000000000..cd4b071338 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index 8b880d2189..74e2cac4c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() { private fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt new file mode 100644 index 0000000000..8b362d70d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 62e762316b..9fee8adafc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -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() private val zombies = HashSet() private val members = HashSet() @@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { isLoading = true loaderContainer.fadeIn() val promise: Promise = 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, val zombieMembers: List) { } + 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, val zombieMembers: List) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index a3d0e6d252..d4c5acf4ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index d37b17ef9f..ae59c3833e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() { fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } @@ -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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index dbdf2615ae..2754c70f69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -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 { 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 7e9d2640a1..702bf33929 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c6a6e1f7f5..8d5acb244d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -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 @@ -71,7 +78,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+" @@ -80,6 +87,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) @@ -128,7 +136,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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 700eb167d5..e78b4388f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -1,18 +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.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 @@ -26,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 @@ -48,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 @@ -60,6 +69,7 @@ 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 @@ -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() private val homeViewModel by viewModels() @@ -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 -> @@ -158,15 +175,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 @@ -176,6 +195,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) @@ -192,6 +216,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 @@ -212,6 +245,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } + // monitor the global search VM query launch { binding.globalSearchInputLayout.query @@ -264,6 +298,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) { @@ -312,6 +354,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) @@ -322,6 +369,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) @@ -493,9 +545,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) button(R.string.RecipientPreferenceActivity_block) { lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, true) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) + storage.setBlocked(listOf(thread.recipient), true) + withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -511,9 +562,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), 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) { - recipientDatabase.setBlocked(thread.recipient, false) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) + storage.setBlocked(listOf(thread.recipient), false) + withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -554,14 +604,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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 4273794f5e..eaf242aae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -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(), 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index b883709c0a..0fe93d41de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -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, private val new: List, - 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt index 947bd89b4e..7ab7bfb508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -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 + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index bc9a9beced..b37bd886c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -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 @@ -61,6 +59,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { profilePictureView.root.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 @@ -87,8 +86,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 = @@ -130,10 +135,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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 2c64ded866..54f9290ffb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -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,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { } binding.searchResultSubtitle.text = getHighlight(query, membersString) } + is Header, // do nothing for header + is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index 07da14b090..a85ea525ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor( .setDuration(REVEAL_DURATION) .alpha(0f) .setListener(object : AnimationCompleteListener() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { visibility = INVISIBLE } }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 60942fd72b..caecbcd87d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -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() { @@ -113,9 +113,9 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 } - private fun deleteAllAndBlock() { + private fun deleteAll() { fun doDeleteAllAndBlock() { - viewModel.clearAllMessageRequests() + viewModel.clearAllMessageRequests(false) LoaderManager.getInstance(this).restartLoader(0, null, this) lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index 89a841dc0a..10142cc8fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -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!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index 2f448932dd..a3a7caf8d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 327492e95c..0157d8ad41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -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 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 6075be65e7..309f2732f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -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() { @Override protected Void doInBackground(Void... params) { - List messageIdsCollection = new LinkedList<>(); - + long currentTime = SnodeAPI.getNowWithOffset(); for (long threadId : threadIds) { Log.i(TAG, "Marking as read: " + threadId); - List 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 31117ae94d..edd1bc274a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt index 42f3a85b47..2de6269536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -164,6 +164,7 @@ class PNModeActivity : BaseActionBarActivity() { 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt index 5531fea491..051cd7542e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt index b6fdaf9cf9..6e082e0008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index aa7ee5a42d..16499cc4bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -22,7 +22,7 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { showSessionDialog { title(viewModel.getTitle(this@BlockedContactsActivity)) text(viewModel.getMessage(this@BlockedContactsActivity)) - button(R.string.continue_2) { viewModel.unblock(this@BlockedContactsActivity) } + button(R.string.continue_2) { viewModel.unblock() } cancelButton() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index acbba1ebb2..dbe09668c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index badcbe66b8..8c3e6190ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -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()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 210c88fee7..f3cdc269c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -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 @@ -17,14 +20,18 @@ import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast 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 @@ -32,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 @@ -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() } @@ -76,7 +89,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { glide = GlideApp.with(this) with(binding) { setupProfilePictureView(profilePictureView.root) - profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } + profilePictureView.root.setOnClickListener { + showEditProfilePictureUI() + } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -204,27 +219,36 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val promises = mutableListOf>() 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java deleted file mode 100644 index 1cccf1d524..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java deleted file mode 100644 index 964f439ba1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 7c1e036dab..dd013afa74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -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 - suspend fun clearAllMessageRequests(): ResultOf + suspend fun clearAllMessageRequests(block: Boolean): ResultOf suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf @@ -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 { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) return ResultOf.Success(Unit) } override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf { sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - threadDb.deleteConversation(thread.threadId) + storage.deleteConversation(thread.threadId) return ResultOf.Success(Unit) } - override suspend fun clearAllMessageRequests(): ResultOf { + override suspend fun clearAllMessageRequests(block: Boolean): ResultOf { 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 = 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java index f42b55b5fe..85d8c8f436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index f9f5524efa..8b1975865d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -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() + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt index 56c0a55dda..0ba63fc549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -1,12 +1,10 @@ package org.thoughtcrime.securesms.util import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat @@ -32,15 +30,7 @@ class CallNotificationBuilder { @JvmStatic fun areNotificationsEnabled(context: Context): Boolean { val notificationManager = NotificationManagerCompat.from(context) - return when { - !notificationManager.areNotificationsEnabled() -> false - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { - notificationManager.notificationChannels.firstOrNull { channel -> - channel.importance == NotificationManager.IMPORTANCE_NONE - } == null - } - else -> true - } + return notificationManager.areNotificationsEnabled() } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index fd462417d9..297014d86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,18 +1,66 @@ package org.thoughtcrime.securesms.util import android.content.Context +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 network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact +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 nl.komponents.kovenant.Promise +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage 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.TextSecurePreferences +import org.session.libsession.utilities.WindowDebouncer +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import java.util.Timer object ConfigurationMessageUtilities { + private val debouncer = WindowDebouncer(3000, Timer()) + + private fun scheduleConfigSync(userPublicKey: String) { + debouncer.publish { + // don't schedule job if we already have one + val storage = MessagingModuleConfiguration.shared.storage + val ourDestination = Destination.Contact(userPublicKey) + val currentStorageJob = storage.getConfigSyncJob(ourDestination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSync = ConfigurationSyncJob(ourDestination) + JobQueue.shared.add(newConfigSync) + } + } + @JvmStatic fun syncConfigurationIfNeeded(context: Context) { + // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + scheduleConfigSync(userPublicKey) + return + } val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val now = System.currentTimeMillis() if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return @@ -35,7 +83,16 @@ object ConfigurationMessageUtilities { } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) + // add if check here to schedule new config job process and return early + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + // schedule job if none exist + // don't schedule job if we already have one + scheduleConfigSync(userPublicKey) + return Promise.ofSuccess(Unit) + } val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> @@ -50,9 +107,179 @@ object ConfigurationMessageUtilities { ) } val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) + val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) return promise } + private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + + fun generateUserProfileConfigDump(): ByteArray? { + val storage = MessagingModuleConfiguration.shared.storage + val ownPublicKey = storage.getUserPublicKey() ?: return null + val config = ConfigurationMessage.getCurrent(listOf()) ?: return null + val secretKey = maybeUserSecretKey() ?: return null + val profile = UserProfile.newInstance(secretKey) + profile.setName(config.displayName) + val picUrl = config.profilePicture + val picKey = config.profileKey + if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + profile.setPic(UserPic(picUrl, picKey)) + } + val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) + profile.setNtsPriority( + if (ownThreadId != null) + if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + else ConfigBase.PRIORITY_HIDDEN + ) + val dump = profile.dump() + profile.free() + return dump + } + + fun generateContactConfigDump(): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val localUserKey = storage.getUserPublicKey() ?: return null + val contactsWithSettings = storage.getAllContacts().filter { recipient -> + recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.sessionID) != null + }.map { contact -> + val address = Address.fromSerialized(contact.sessionID) + val thread = storage.getThreadId(address) + val isPinned = if (thread != null) { + storage.isPinned(thread) + } else false + + Triple(contact, storage.getRecipientSettings(address)!!, isPinned) + } + val contactConfig = Contacts.newInstance(secretKey) + for ((contact, settings, isPinned) in contactsWithSettings) { + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { + null + } else { + UserPic(url, key) + } + + val contactInfo = Contact( + id = contact.sessionID, + name = contact.name.orEmpty(), + nickname = contact.nickname.orEmpty(), + blocked = settings.isBlocked, + approved = settings.isApproved, + approvedMe = settings.hasApprovedMe(), + profilePicture = userPic ?: UserPic.DEFAULT, + priority = if (isPinned) 1 else 0, + expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) + ) + contactConfig.set(contactInfo) + } + val dump = contactConfig.dump() + contactConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateConversationVolatileDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val convoConfig = ConversationVolatileConfig.newInstance(secretKey) + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.approvedConversationList.use { cursor -> + val reader = threadDb.readerFor(cursor) + var current = reader.next + while (current != null) { + val recipient = current.recipient + val contact = when { + recipient.isOpenGroupRecipient -> { + val openGroup = storage.getOpenGroup(current.threadId) ?: continue + val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue + convoConfig.getOrConstructCommunity(base, room, pubKey) + } + recipient.isClosedGroupRecipient -> { + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + convoConfig.getOrConstructLegacyGroup(groupPublicKey) + } + recipient.isContactRecipient -> { + if (recipient.isLocalNumber) null // this is handled by the user profile NTS data + else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude + else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null + else convoConfig.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> null + } + if (contact == null) { + current = reader.next + continue + } + contact.lastRead = current.lastSeen + contact.unread = false + convoConfig.set(contact) + current = reader.next + } + } + + val dump = convoConfig.dump() + convoConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateUserGroupDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val groupConfig = UserGroupsConfig.newInstance(secretKey) + val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup -> + val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null + val pubKeyHex = Hex.toStringCondensed(pubKey) + val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) + val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null + val isPinned = storage.isPinned(threadId) + GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) + } + + val allLgc = storage.getAllGroups(includeInactive = false).filter { + it.isClosedGroup && it.isActive && it.members.size > 1 + }.mapNotNull { group -> + val groupAddress = Address.fromSerialized(group.encodedId) + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null + val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null + val threadId = storage.getThreadId(group.encodedId) + val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false + val admins = group.admins.map { it.serialize() to true }.toMap() + val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() + GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = group.title, + members = admins + members, + priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = recipient.expireMessages.toLong(), + joinedAt = (group.formationTimestamp / 1000L) + ) + } + (allOpenGroups + allLgc).forEach { groupInfo -> + groupConfig.set(groupInfo) + } + val dump = groupConfig.dump() + groupConfig.free() + if (dump.isEmpty()) return null + return dump + } + + @JvmField + val DELETE_INACTIVE_GROUPS: String = """ + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + """.trimIndent() + + @JvmField + val DELETE_INACTIVE_ONE_TO_ONES: String = """ + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + """.trimIndent() + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 874440f5de..66c838cc1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -67,7 +67,8 @@ public class DateUtils extends android.text.format.DateUtils { } public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy + if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) { return c.getString(R.string.DateUtils_just_now); } else if (isToday(timestamp)) { return getFormattedDateTime(timestamp, getHourFormat(c), locale); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 08b81e5cb7..c7d53c1fef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt @@ -55,16 +56,21 @@ object GlowViewUtilities { animation.start() } - fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { + fun animateShadowColorChange( + view: GlowView, + @ColorInt startColor: Int, + @ColorInt endColor: Int, + duration: Long = 250 + ) { val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) - animation.duration = 250 + animation.duration = duration + animation.interpolator = AccelerateDecelerateInterpolator() animation.addUpdateListener { animator -> val color = animator.animatedValue as Int view.sessionShadowColor = color } animation.start() } - } class PNModeView : LinearLayout, GlowView { @@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { } // endregion } + +class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView { + @ColorInt override var mainColor: Int = 0 + set(newValue) { field = newValue; paint.color = newValue } + @ColorInt override var sessionShadowColor: Int = 0 + set(newValue) { + field = newValue + shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue) + + if (numShadowRenders == 0) { + numShadowRenders = 1 + } + + invalidate() + } + var cornerRadius: Float = 0f + var numShadowRenders: Int = 0 + + private val paint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + private val shadowPaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + // region Lifecycle + constructor(context: Context) : super(context) { } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { } + + init { + setWillNotDraw(false) + } + // endregion + + // region Updating + override fun onDraw(c: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + + (0 until numShadowRenders).forEach { + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint) + } + + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + super.onDraw(c) + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 10d507a538..06fda29306 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -7,8 +7,6 @@ import org.session.libsession.messaging.messages.signal.IncomingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi -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.GroupUtil import org.session.libsession.utilities.recipients.Recipient @@ -21,7 +19,6 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.GroupManager import java.security.SecureRandom -import java.util.* import kotlin.random.asKotlinRandom object MockDataGenerator { @@ -139,7 +136,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -235,8 +231,9 @@ object MockDataGenerator { // Add the group to the user's set of public keys to poll for and store the key pair val encryptionKeyPair = Curve.generateKeyPair() - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.setExpirationTimer(groupId, 0) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) // Add the group created message if (userSessionId == adminUserId) { @@ -269,7 +266,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -395,7 +391,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt index 05b6fe86f8..c10e1b635d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt @@ -49,11 +49,11 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendReadReceipt(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } @JvmStatic fun shouldSendTypingIndicator(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt new file mode 100644 index 0000000000..b15d82a33e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util + +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.database.model.ThreadRecord + +fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { + val recipient = thread.recipient + if (recipient.isContactRecipient + && recipient.isOpenGroupInboxRecipient + && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { + return getOneToOne(recipient.address.serialize())?.unread == true + } else if (recipient.isClosedGroupRecipient) { + return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true + } else if (recipient.isOpenGroupRecipient) { + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false + return getCommunity(openGroup.server, openGroup.room)?.unread == true + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 8345473490..dfd4ffe419 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -58,7 +58,7 @@ fun View.fadeIn(duration: Long = 150) { fun View.fadeOut(duration: Long = 150) { animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index d96de5eedb..894de9de64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -303,7 +303,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va sdpMLineIndexes = sdpMLineIndexes, sdpMids = sdpMids, currentCallId - ), currentRecipient.address) + ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } @@ -437,7 +437,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va pendingIncomingIceUpdates.clear() val answerMessage = CallMessage.answer(answer.description, callId) Log.i("Loki", "Posting new answer") - MessageSender.sendNonDurably(answerMessage, recipient.address) + MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { Promise.ofFail(Exception("Couldn't reconnect from current state")) } @@ -481,11 +481,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(answer) val answerMessage = CallMessage.answer(answer.description, callId) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) - MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress)) + MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address) + ), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -535,13 +535,13 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va Log.d("Loki", "Sending pre-offer") return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address).bind { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address).success { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +555,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +575,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -726,7 +726,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va }) connection.setLocalDescription(offer) - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/res/color/prominent_button_color.xml b/app/src/main/res/color/prominent_button_color.xml index 8f2e692fda..39985565d1 100644 --- a/app/src/main/res/color/prominent_button_color.xml +++ b/app/src/main/res/color/prominent_button_color.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index d56b399fc3..5afde1e296 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -216,6 +216,19 @@ + + - + android:layout_height="@dimen/path_status_view_size" + android:layout_alignEnd="@+id/profileButton" + android:layout_alignBottom="@+id/profileButton" /> + android:layout_height="?actionBarSize" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:visibility="gone"> + + android:layout_height="wrap_content" + android:layout_centerVertical="true" /> + + + + + + android:clipChildren="false" + android:focusable="false"> + tools:listitem="@layout/view_global_search_result" /> diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index d328d19ab3..11155ccc20 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -8,6 +8,46 @@ android:layout_height="wrap_content" android:orientation="vertical"> + + + + + + - - + Failed to send Search GIFs? Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs. + Some of your devices are using outdated versions. Syncing may be unreliable until they are updated. + + There are no messages in %s. + You have no messages in Note to Self. + You have no messages from %s.\nSend a message to start the conversation! + + Unread Messages + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index edf8108d60..2d4ee8b7f7 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -642,4 +642,84 @@ @color/ocean_light_5 + + + + diff --git a/app/src/main/res/xml/preferences_app_protection.xml b/app/src/main/res/xml/preferences_app_protection.xml index 6119719930..12607ee769 100644 --- a/app/src/main/res/xml/preferences_app_protection.xml +++ b/app/src/main/res/xml/preferences_app_protection.xml @@ -12,7 +12,6 @@ android:title="@string/preferences_app_protection__screen_lock" android:summary="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint" /> - + convo is Conversation.OneToOne && convo.sessionId == definitelyRealId + } + assertEquals(1, numErased) + assertEquals(1, convos.sizeOneToOnes()) + } + + @Test + fun test_open_group_urls() { + val (base1, room1, pk1) = BaseCommunityInfo.parseFullUrl( + "https://example.com/" + + "someroom?public_key=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )!! + + val (base2, room2, pk2) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/" + + "someroom?public_key=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + )!! + + val (base3, room3, pk3) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base4, room4, pk4) = BaseCommunityInfo.parseFullUrl( + "http://example.com/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base5, room5, pk5) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.com:443/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base6, room6, pk6) = BaseCommunityInfo.parseFullUrl( + "HTTP://EXAMPLE.com:80/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base7, room7, pk7) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=ASNFZ4mrze8BI0VniavN7wEjRWeJq83vASNFZ4mrze8" + )!! + val (base8, room8, pk8) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=yrtwk3hjixg66yjdeiuauk6p7hy1gtm8tgih55abrpnsxnpm3zzo" + )!! + + assertEquals("https://example.com", base1) + assertEquals("http://example.com", base4) + assertEquals(base1, base2) + assertEquals(base1, base3) + assertNotEquals(base1, base4) + assertEquals(base1, base5) + assertEquals(base4, base6) + assertEquals(base4, base7) + assertEquals(base4, base8) + assertEquals("someroom", room1) + assertEquals("someroom", room2) + assertEquals("someroom", room3) + assertEquals("someroom", room4) + assertEquals("someroom", room5) + assertEquals("someroom", room6) + assertEquals("someroom", room7) + assertEquals("someroom", room8) + assertEquals(Hex.toStringCondensed(pk1), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk2), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk3), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk4), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk5), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk6), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk7), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk8), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + } + + @Test + fun test_conversations() { + val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey) + val definitelyRealId = "055000000000000000000000000000000000000000000000000000000000000000" + assertNull(convos.getOneToOne(definitelyRealId)) + assertTrue(convos.empty()) + assertEquals(0, convos.size()) + + val c = convos.getOrConstructOneToOne(definitelyRealId) + + assertEquals(definitelyRealId, c.sessionId) + assertEquals(0, c.lastRead) + + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(0, convos.push().seqNo) + + val nowMs = System.currentTimeMillis() + + c.lastRead = nowMs + + convos.set(c) + + assertNull(convos.getLegacyClosedGroup(definitelyRealId)) + assertNotNull(convos.getOneToOne(definitelyRealId)) + assertEquals(nowMs, convos.getOneToOne(definitelyRealId)?.lastRead) + + assertTrue(convos.needsPush()) + assertTrue(convos.needsDump()) + + val openGroupPubKey = Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + val og = convos.getOrConstructCommunity("http://Example.ORG:5678", "SudokuRoom", openGroupPubKey) + val ogCommunity = og.baseCommunityInfo + + assertEquals("http://example.org:5678", ogCommunity.baseUrl) // Note: lower-case + assertEquals("sudokuroom", ogCommunity.room) // Note: lower-case + assertEquals(64, ogCommunity.pubKeyHex.length) + assertEquals("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ogCommunity.pubKeyHex) + + og.unread = true + + convos.set(og) + + val (_, seqNo) = convos.push() + + assertEquals(1, seqNo) + + convos.confirmPushed(seqNo, "fakehash1") + + assertTrue(convos.needsDump()) + assertFalse(convos.needsPush()) + + val convos2 = ConversationVolatileConfig.newInstance(keyPair.secretKey, convos.dump()) + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(1, convos.push().seqNo) + assertFalse(convos.needsDump()) + + val x1 = convos2.getOneToOne(definitelyRealId)!! + assertEquals(nowMs, x1.lastRead) + assertEquals(definitelyRealId, x1.sessionId) + assertEquals(false, x1.unread) + + val x2 = convos2.getCommunity("http://EXAMPLE.org:5678", "sudokuRoom")!! + val x2Info = x2.baseCommunityInfo + assertEquals("http://example.org:5678", x2Info.baseUrl) + assertEquals("sudokuroom", x2Info.room) + assertEquals(x2Info.pubKeyHex, Hex.toStringCondensed(openGroupPubKey)) + assertTrue(x2.unread) + + val anotherId = "051111111111111111111111111111111111111111111111111111111111111111" + val c2 = convos.getOrConstructOneToOne(anotherId) + c2.unread = true + convos2.set(c2) + + val c3 = convos.getOrConstructLegacyGroup( + "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ) + c3.lastRead = nowMs - 50 + convos2.set(c3) + + assertTrue(convos2.needsPush()) + + val (toPush2, seqNo2) = convos2.push() + assertEquals(2, seqNo2) + + convos2.confirmPushed(seqNo2, "fakehash2") + convos.merge("fakehash2" to toPush2) + + assertFalse(convos.needsPush()) + assertEquals(seqNo2, convos.push().seqNo) + + val seen = mutableListOf() + for ((ind, conv) in listOf(convos, convos2).withIndex()) { + Log.e("Test","Testing seen from convo #$ind") + seen.clear() + assertEquals(4, conv.size()) + assertEquals(2, conv.sizeOneToOnes()) + assertEquals(1, conv.sizeCommunities()) + assertEquals(1, conv.sizeLegacyClosedGroups()) + assertFalse(conv.empty()) + val allConvos = conv.all() + for (convo in allConvos) { + when (convo) { + is Conversation.OneToOne -> seen.add("1-to-1: ${convo.sessionId}") + is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}") + is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}") + } + } + + assertTrue(seen.contains("1-to-1: 051111111111111111111111111111111111111111111111111111111111111111")) + assertTrue(seen.contains("1-to-1: 055000000000000000000000000000000000000000000000000000000000000000")) + assertTrue(seen.contains("og: http://example.org:5678/r/sudokuroom")) + assertTrue(seen.contains("cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) + assertTrue(seen.size == 4) // for some reason iterative checks aren't working in test cases + } + + assertFalse(convos.needsPush()) + convos.eraseOneToOne("052000000000000000000000000000000000000000000000000000000000000000") + assertFalse(convos.needsPush()) + convos.eraseOneToOne("055000000000000000000000000000000000000000000000000000000000000000") + assertTrue(convos.needsPush()) + + assertEquals(1, convos.allOneToOnes().size) + assertEquals("051111111111111111111111111111111111111111111111111111111111111111", + convos.allOneToOnes().map(Conversation.OneToOne::sessionId).first() + ) + assertEquals(1, convos.allCommunities().size) + assertEquals("http://example.org:5678", + convos.allCommunities().map { it.baseCommunityInfo.baseUrl }.first() + ) + assertEquals(1, convos.allLegacyClosedGroups().size) + assertEquals("05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + convos.allLegacyClosedGroups().map(Conversation.LegacyGroup::groupId).first() + ) + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/AndroidManifest.xml b/libsession-util/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..65483324a6 --- /dev/null +++ b/libsession-util/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..d01523a24e --- /dev/null +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -0,0 +1,66 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. + +cmake_minimum_required(VERSION 3.18.1) + +# Declares and names the project. + +project("session_util") + +# Compiles in C++17 mode +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_BUILD_TYPE Release) + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. + +set(STATIC_BUNDLE ON) +add_subdirectory(../../../libsession-util libsession) + +set(SOURCES + user_profile.cpp + user_groups.cpp + config_base.cpp + contacts.cpp + conversation.cpp + util.cpp) + +add_library( # Sets the name of the library. + session_util + # Sets the library as a shared library. + SHARED + # Provides a relative path to your source file(s). + ${SOURCES}) + +# Searches for a specified prebuilt library and stores the path as a +# variable. Because CMake includes system libraries in the search path by +# default, you only need to specify the name of the public NDK library +# you want to add. CMake verifies that the library exists before +# completing its build. + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log) + +# Specifies libraries CMake should link to your target library. You +# can link multiple libraries, such as libraries you define in this +# build script, prebuilt third-party libraries, or system libraries. + +target_link_libraries( # Specifies the target library. + session_util + PUBLIC + libsession::config + libsession::crypto + # Links the target library to the log library + # included in the NDK. + ${log-lib}) diff --git a/libsession-util/src/main/cpp/config_base.cpp b/libsession-util/src/main/cpp/config_base.cpp new file mode 100644 index 0000000000..eed3ec56af --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -0,0 +1,154 @@ +#include "config_base.h" +#include "util.h" + +extern "C" { +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dirty(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto* configBase = ptrToConfigBase(env, thiz); + return configBase->is_dirty(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsPush(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_push(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsDump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_dump(); +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_push(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto push_tuple = config->push(); + auto to_push_str = std::get<1>(push_tuple); + auto to_delete = std::get<2>(push_tuple); + + jbyteArray returnByteArray = util::bytes_from_ustring(env, to_push_str); + jlong seqNo = std::get<0>(push_tuple); + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/ConfigPush"); + jclass stackClass = env->FindClass("java/util/Stack"); + jmethodID methodId = env->GetMethodID(returnObjectClass, "", "([BJLjava/util/List;)V"); + jmethodID stack_init = env->GetMethodID(stackClass, "", "()V"); + jobject our_stack = env->NewObject(stackClass, stack_init); + jmethodID push_stack = env->GetMethodID(stackClass, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto entry : to_delete) { + auto entry_jstring = env->NewStringUTF(entry.data()); + env->CallObjectMethod(our_stack, push_stack, entry_jstring); + } + jobject returnObject = env->NewObject(returnObjectClass, methodId, returnByteArray, seqNo, our_stack); + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_free(JNIEnv *env, jobject thiz) { + auto config = ptrToConfigBase(env, thiz); + delete config; +} + +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto dumped = config->dump(); + jbyteArray bytes = util::bytes_from_ustring(env, dumped); + return bytes; +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_encryptionDomain(JNIEnv *env, + jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return env->NewStringUTF(conf->encryption_domain()); +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *env, jobject thiz, + jlong seq_no, + jstring new_hash_jstring) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + auto new_hash = env->GetStringUTFChars(new_hash_jstring, nullptr); + conf->confirm_pushed(seq_no, new_hash); + env->ReleaseStringUTFChars(new_hash_jstring, new_hash); +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobjectArray to_merge) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + size_t number = env->GetArrayLength(to_merge); + std::vector> configs = {}; + for (int i = 0; i < number; i++) { + auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); + auto pair = extractHashAndData(env, jElement); + configs.push_back(pair); + } + return conf->merge(configs); +} + +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobject to_merge) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + std::vector> configs = {extractHashAndData(env, to_merge)}; + return conf->merge(configs); +} + +#pragma clang diagnostic pop +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_configNamespace(JNIEnv *env, jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return (std::int16_t) conf->storage_namespace(); +} +extern "C" +JNIEXPORT jclass JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_00024Companion_kindFor(JNIEnv *env, + jobject thiz, + jint config_namespace) { + auto user_class = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + auto contact_class = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + auto convo_volatile_class = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + auto group_list_class = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + switch (config_namespace) { + case (int)session::config::Namespace::UserProfile: + return user_class; + case (int)session::config::Namespace::Contacts: + return contact_class; + case (int)session::config::Namespace::ConvoInfoVolatile: + return convo_volatile_class; + case (int)session::config::Namespace::UserGroups: + return group_list_class; + default: + return nullptr; + } +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_currentHashes(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + auto vec = conf->current_hashes(); + for (std::string element: vec) { + env->CallObjectMethod(our_stack, push, env->NewStringUTF(element.data())); + } + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/config_base.h b/libsession-util/src/main/cpp/config_base.h new file mode 100644 index 0000000000..836fb04ef5 --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.h @@ -0,0 +1,28 @@ +#ifndef SESSION_ANDROID_CONFIG_BASE_H +#define SESSION_ANDROID_CONFIG_BASE_H + +#include "session/config/base.hpp" +#include "util.h" +#include +#include + +inline session::config::ConfigBase* ptrToConfigBase(JNIEnv *env, jobject obj) { + jclass baseClass = env->FindClass("network/loki/messenger/libsession_util/ConfigBase"); + jfieldID pointerField = env->GetFieldID(baseClass, "pointer", "J"); + return (session::config::ConfigBase*) env->GetLongField(obj, pointerField); +} + +inline std::pair extractHashAndData(JNIEnv *env, jobject kotlin_pair) { + jclass pair = env->FindClass("kotlin/Pair"); + jfieldID first = env->GetFieldID(pair, "first", "Ljava/lang/Object;"); + jfieldID second = env->GetFieldID(pair, "second", "Ljava/lang/Object;"); + jstring hash_as_jstring = static_cast(env->GetObjectField(kotlin_pair, first)); + jbyteArray data_as_jbytes = static_cast(env->GetObjectField(kotlin_pair, second)); + auto hash_as_string = env->GetStringUTFChars(hash_as_jstring, nullptr); + auto data_as_ustring = util::ustring_from_bytes(env, data_as_jbytes); + auto ret_pair = std::pair{hash_as_string, data_as_ustring}; + env->ReleaseStringUTFChars(hash_as_jstring, hash_as_string); + return ret_pair; +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.cpp b/libsession-util/src/main/cpp/contacts.cpp new file mode 100644 index 0000000000..7d04904802 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.cpp @@ -0,0 +1,100 @@ +#include "contacts.h" +#include "util.h" + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_get(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + if (!contact) return nullptr; + jobject j_contact = serialize_contact(env, contact.value()); + return j_contact; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_getOrConstruct(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get_or_construct(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return serialize_contact(env, contact); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_set(JNIEnv *env, jobject thiz, + jobject contact) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto contact_info = deserialize_contact(env, contact, contacts); + contacts->set(contact_info); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_erase(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + + bool result = contacts->erase(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return result; +} +extern "C" +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto* contacts = new session::config::Contacts(secret_key, std::nullopt); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + + return newConfig; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + + auto* contacts = new session::config::Contacts(secret_key, initial); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + + return newConfig; +} +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_all(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto& contact : *contacts) { + auto contact_obj = serialize_contact(env, contact); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.h b/libsession-util/src/main/cpp/contacts.h new file mode 100644 index 0000000000..c5496a68c8 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.h @@ -0,0 +1,109 @@ +#ifndef SESSION_ANDROID_CONTACTS_H +#define SESSION_ANDROID_CONTACTS_H + +#include +#include "session/config/contacts.hpp" +#include "util.h" + +inline session::config::Contacts *ptrToContacts(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::Contacts *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_contact(JNIEnv *env, session::config::contact_info info) { + jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); + jmethodID constructor = env->GetMethodID(contactClass, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZLnetwork/loki/messenger/libsession_util/util/UserPic;ILnetwork/loki/messenger/libsession_util/util/ExpiryMode;)V"); + jstring id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jstring nickname = env->NewStringUTF(info.nickname.data()); + jboolean approved, approvedMe, blocked; + approved = info.approved; + approvedMe = info.approved_me; + blocked = info.blocked; + auto created = info.created; + jobject profilePic = util::serialize_user_pic(env, info.profile_picture); + jobject returnObj = env->NewObject(contactClass, constructor, id, name, nickname, approved, + approvedMe, blocked, profilePic, info.priority, + util::serialize_expiry(env, info.exp_mode, info.exp_timer)); + return returnObj; +} + +inline session::config::contact_info +deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { + jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); + + jfieldID getId, getName, getNick, getApproved, getApprovedMe, getBlocked, getUserPic, getPriority, getExpiry, getHidden; + getId = env->GetFieldID(contactClass, "id", "Ljava/lang/String;"); + getName = env->GetFieldID(contactClass, "name", "Ljava/lang/String;"); + getNick = env->GetFieldID(contactClass, "nickname", "Ljava/lang/String;"); + getApproved = env->GetFieldID(contactClass, "approved", "Z"); + getApprovedMe = env->GetFieldID(contactClass, "approvedMe", "Z"); + getBlocked = env->GetFieldID(contactClass, "blocked", "Z"); + getUserPic = env->GetFieldID(contactClass, "profilePicture", + "Lnetwork/loki/messenger/libsession_util/util/UserPic;"); + getPriority = env->GetFieldID(contactClass, "priority", "I"); + getExpiry = env->GetFieldID(contactClass, "expiryMode", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode;"); + jstring name, nickname, session_id; + session_id = static_cast(env->GetObjectField(info, getId)); + name = static_cast(env->GetObjectField(info, getName)); + nickname = static_cast(env->GetObjectField(info, getNick)); + bool approved, approvedMe, blocked, hidden; + int priority = env->GetIntField(info, getPriority); + approved = env->GetBooleanField(info, getApproved); + approvedMe = env->GetBooleanField(info, getApprovedMe); + blocked = env->GetBooleanField(info, getBlocked); + jobject user_pic = env->GetObjectField(info, getUserPic); + jobject expiry_mode = env->GetObjectField(info, getExpiry); + + auto expiry_pair = util::deserialize_expiry(env, expiry_mode); + + std::string url; + session::ustring key; + + if (user_pic != nullptr) { + auto deserialized_pic = util::deserialize_user_pic(env, user_pic); + auto url_jstring = deserialized_pic.first; + auto url_bytes = env->GetStringUTFChars(url_jstring, nullptr); + url = std::string(url_bytes); + env->ReleaseStringUTFChars(url_jstring, url_bytes); + key = util::ustring_from_bytes(env, deserialized_pic.second); + } + + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto name_bytes = name ? env->GetStringUTFChars(name, nullptr) : nullptr; + auto nickname_bytes = nickname ? env->GetStringUTFChars(nickname, nullptr) : nullptr; + + auto contact_info = conf->get_or_construct(session_id_bytes); + if (name_bytes) { + contact_info.name = name_bytes; + } + if (nickname_bytes) { + contact_info.nickname = nickname_bytes; + } + contact_info.approved = approved; + contact_info.approved_me = approvedMe; + contact_info.blocked = blocked; + if (!url.empty() && !key.empty()) { + contact_info.profile_picture = session::config::profile_pic(url, key); + } else { + contact_info.profile_picture = session::config::profile_pic(); + } + + env->ReleaseStringUTFChars(session_id, session_id_bytes); + if (name_bytes) { + env->ReleaseStringUTFChars(name, name_bytes); + } + if (nickname_bytes) { + env->ReleaseStringUTFChars(nickname, nickname_bytes); + } + + contact_info.priority = priority; + contact_info.exp_mode = expiry_pair.first; + contact_info.exp_timer = std::chrono::seconds(expiry_pair.second); + + return contact_info; +} + + +#endif //SESSION_ANDROID_CONTACTS_H diff --git a/libsession-util/src/main/cpp/conversation.cpp b/libsession-util/src/main/cpp/conversation.cpp new file mode 100644 index 0000000000..4f0f531dea --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.cpp @@ -0,0 +1,352 @@ +#include +#include "conversation.h" + +#pragma clang diagnostic push + +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_00024Companion_newInstance___3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, std::nullopt); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast(convo_info_volatile)); + + return newConfig; +} +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + auto* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, initial); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast(convo_info_volatile)); + + return newConfig; +} + + + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + return conversations->size_1to1(); +} + +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseAll(JNIEnv *env, + jobject thiz, + jobject predicate) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + + jclass predicate_class = env->FindClass("kotlin/jvm/functions/Function1"); + jmethodID predicate_call = env->GetMethodID(predicate_class, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;"); + + jclass bool_class = env->FindClass("java/lang/Boolean"); + jmethodID bool_get = env->GetMethodID(bool_class, "booleanValue", "()Z"); + + int removed = 0; + auto to_erase = std::vector(); + + for (auto it = conversations->begin(); it != conversations->end(); ++it) { + auto result = env->CallObjectMethod(predicate, predicate_call, serialize_any(env, *it)); + bool bool_result = env->CallBooleanMethod(result, bool_get); + if (bool_result) { + to_erase.push_back(*it); + } + } + + for (auto & entry : to_erase) { + if (conversations->erase(entry)) { + removed++; + } + } + + return removed; +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_size(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return (jint)config->size(); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_empty(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return config->empty(); +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_set(JNIEnv *env, + jobject thiz, + jobject to_store) { + std::lock_guard lock{util::util_mutex_}; + + auto convos = ptrToConvoInfo(env, thiz); + + jclass one_to_one = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jclass open_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + jclass legacy_closed_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + + jclass to_store_class = env->GetObjectClass(to_store); + if (env->IsSameObject(to_store_class, one_to_one)) { + // store as 1to1 + convos->set(deserialize_one_to_one(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,open_group)) { + // store as open_group + convos->set(deserialize_community(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,legacy_closed_group)) { + // store as legacy_closed_group + convos->set(deserialize_legacy_closed_group(env, to_store, convos)); + } +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + if (internal) { + return serialize_one_to_one(env, *internal); + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructOneToOne( + JNIEnv *env, jobject thiz, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_or_construct_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return serialize_one_to_one(env, internal); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto result = convos->erase_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return result; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto open = convos->get_community(base_url_chars, room_chars); + if (open) { + auto serialized = serialize_open_group(env, *open); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2_3B( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jbyteArray pub_key) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_ustring = util::ustring_from_bytes(env, pub_key); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, pub_key_ustring); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, hex_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, hex_chars); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_Conversation_Community_2(JNIEnv *env, + jobject thiz, + jobject open_group) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_community(env, open_group, convos); + return convos->erase(deserialized); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto result = convos->erase_community(base_url_chars, room_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + return result; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + if (lgc) { + auto serialized = serialize_legacy_group(env, *lgc); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructLegacyGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_or_construct_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return serialize_legacy_group(env, lgc); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto result = convos->erase_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return result; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_erase(JNIEnv *env, + jobject thiz, + jobject conversation) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_any(env, conversation, convos); + if (!deserialized.has_value()) return false; + return convos->erase(*deserialized); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_communities(); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_legacy_groups(); +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_all(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto& convo : *convos) { + auto contact_obj = serialize_any(env, convo); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_1to1(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_one_to_one(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_communities(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_open_group(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_legacy_groups(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_legacy_group(env, *contact)); + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/conversation.h b/libsession-util/src/main/cpp/conversation.h new file mode 100644 index 0000000000..45e453a595 --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.h @@ -0,0 +1,122 @@ +#ifndef SESSION_ANDROID_CONVERSATION_H +#define SESSION_ANDROID_CONVERSATION_H + +#include +#include "util.h" +#include "session/config/convo_info_volatile.hpp" + +inline session::config::ConvoInfoVolatile *ptrToConvoInfo(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::ConvoInfoVolatile *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_one_to_one(JNIEnv *env, session::config::convo::one_to_one one_to_one) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/String;JZ)V"); + auto session_id = env->NewStringUTF(one_to_one.session_id.data()); + auto last_read = one_to_one.last_read; + auto unread = one_to_one.unread; + jobject serialized = env->NewObject(clazz, constructor, session_id, last_read, unread); + return serialized; +} + +inline jobject serialize_open_group(JNIEnv *env, session::config::convo::community community) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community = util::serialize_base_community(env, community); + jmethodID constructor = env->GetMethodID(clazz, "", + "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;JZ)V"); + auto last_read = community.last_read; + auto unread = community.unread; + jobject serialized = env->NewObject(clazz, constructor, base_community, last_read, unread); + return serialized; +} + +inline jobject serialize_legacy_group(JNIEnv *env, session::config::convo::legacy_group group) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/String;JZ)V"); + auto group_id = env->NewStringUTF(group.id.data()); + auto last_read = group.last_read; + auto unread = group.unread; + jobject serialized = env->NewObject(clazz, constructor, group_id, last_read, unread); + return serialized; +} + +inline jobject serialize_any(JNIEnv *env, session::config::convo::any any) { + if (auto* dm = std::get_if(&any)) { + return serialize_one_to_one(env, *dm); + } else if (auto* og = std::get_if(&any)) { + return serialize_open_group(env, *og); + } else if (auto* lgc = std::get_if(&any)) { + return serialize_legacy_group(env, *lgc); + } + return nullptr; +} + +inline session::config::convo::one_to_one deserialize_one_to_one(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto id_getter = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + jstring id = static_cast(env->GetObjectField(info, id_getter)); + auto id_chars = env->GetStringUTFChars(id, nullptr); + std::string id_string = std::string{id_chars}; + auto deserialized = conf->get_or_construct_1to1(id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(id, id_chars); + return deserialized; +} + +inline session::config::convo::community deserialize_community(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community_getter = env->GetFieldID(clazz, "baseCommunityInfo", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + + auto base_community_info = env->GetObjectField(info, base_community_getter); + + auto base_community_deserialized = util::deserialize_base_community(env, base_community_info); + auto deserialized = conf->get_or_construct_community( + base_community_deserialized.base_url(), + base_community_deserialized.room(), + base_community_deserialized.pubkey() + ); + + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + + return deserialized; +} + +inline session::config::convo::legacy_group deserialize_legacy_closed_group(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto group_id_getter = env->GetFieldID(clazz, "groupId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + auto group_id = static_cast(env->GetObjectField(info, group_id_getter)); + auto group_id_bytes = env->GetStringUTFChars(group_id, nullptr); + auto group_id_string = std::string{group_id_bytes}; + auto deserialized = conf->get_or_construct_legacy_group(group_id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(group_id, group_id_bytes); + return deserialized; +} + +inline std::optional deserialize_any(JNIEnv *env, jobject convo, session::config::ConvoInfoVolatile *conf) { + auto oto_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto og_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto lgc_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto object_class = env->GetObjectClass(convo); + if (env->IsSameObject(object_class, oto_class)) { + return session::config::convo::any{deserialize_one_to_one(env, convo, conf)}; + } else if (env->IsSameObject(object_class, og_class)) { + return session::config::convo::any{deserialize_community(env, convo, conf)}; + } else if (env->IsSameObject(object_class, lgc_class)) { + return session::config::convo::any{deserialize_legacy_closed_group(env, convo, conf)}; + } + return std::nullopt; +} + +#endif //SESSION_ANDROID_CONVERSATION_H \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_groups.cpp b/libsession-util/src/main/cpp/user_groups.cpp new file mode 100644 index 0000000000..4f2b0e6b85 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.cpp @@ -0,0 +1,273 @@ +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +#include "user_groups.h" + + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_00024Companion_newInstance___3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + + auto* user_groups = new session::config::UserGroups(secret_key, std::nullopt); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(user_groups)); + + return newConfig; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + + auto* user_groups = new session::config::UserGroups(secret_key, initial); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(user_groups)); + + return newConfig; +} +#pragma clang diagnostic pop + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_util_GroupInfo_00024LegacyGroupInfo_00024Companion_NAME_1MAX_1LENGTH( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + return session::config::legacy_group_info::NAME_MAX_LENGTH; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getCommunityInfo(JNIEnv *env, + jobject thiz, + jstring base_url, + jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + + auto community = conf->get_community(base_url_bytes, room_bytes); + + jobject community_info = nullptr; + + if (community) { + community_info = serialize_community_info(env, *community); + } + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return community_info; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getLegacyGroupInfo(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto legacy_group = conf->get_legacy_group(id_bytes); + jobject return_group = nullptr; + if (legacy_group) { + return_group = serialize_legacy_group_info(env, *legacy_group); + } + env->ReleaseStringUTFChars(session_id, id_bytes); + return return_group; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructCommunityInfo( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto pub_hex_bytes = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto group = conf->get_or_construct_community(base_url_bytes, room_bytes, pub_hex_bytes); + + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + env->ReleaseStringUTFChars(pub_key_hex, pub_hex_bytes); + return serialize_community_info(env, group); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructLegacyGroupInfo( + JNIEnv *env, jobject thiz, jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto group = conf->get_or_construct_legacy_group(id_bytes); + env->ReleaseStringUTFChars(session_id, id_bytes); + return serialize_legacy_group_info(env, group); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_set__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto community_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto legacy_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto object_class = env->GetObjectClass(group_info); + if (env->IsSameObject(community_info, object_class)) { + auto deserialized = deserialize_community_info(env, group_info, conf); + conf->set(deserialized); + } else if (env->IsSameObject(legacy_info, object_class)) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->set(deserialized); + } +} + + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_erase__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto communityInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto legacyInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + if (env->GetObjectClass(group_info) == communityInfo) { + auto deserialized = deserialize_community_info(env, group_info, conf); + conf->erase(deserialized); + } else if (env->GetObjectClass(group_info) == legacyInfo) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->erase(deserialized); + } +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_communities(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_legacy_groups(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_size(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConvoInfo(env, thiz); + return conf->size(); +} + +inline jobject iterator_as_java_stack(JNIEnv *env, const session::config::UserGroups::iterator& begin, const session::config::UserGroups::iterator& end) { + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto it = begin; it != end;) { + // do something with it + auto item = *it; + jobject serialized = nullptr; + if (auto* lgc = std::get_if(&item)) { + serialized = serialize_legacy_group_info(env, *lgc); + } else if (auto* community = std::get_if(&item)) { + serialized = serialize_community_info(env, *community); + } + if (serialized != nullptr) { + env->CallObjectMethod(our_stack, push, serialized); + } + it++; + } + return our_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_all(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject all_stack = iterator_as_java_stack(env, conf->begin(), conf->end()); + return all_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject community_stack = iterator_as_java_stack(env, conf->begin_communities(), conf->end()); + return community_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject legacy_stack = iterator_as_java_stack(env, conf->begin_legacy_groups(), conf->end()); + return legacy_stack; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_BaseCommunityInfo_2(JNIEnv *env, + jobject thiz, + jobject base_community_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_community = util::deserialize_base_community(env, base_community_info); + return conf->erase_community(base_community.base_url(),base_community.room()); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring server, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto server_bytes = env->GetStringUTFChars(server, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto community = conf->get_community(server_bytes, room_bytes); + bool deleted = false; + if (community) { + deleted = conf->erase(*community); + } + env->ReleaseStringUTFChars(server, server_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return deleted; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseLegacyGroup(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + bool return_bool = conf->erase_legacy_group(session_id_bytes); + env->ReleaseStringUTFChars(session_id, session_id_bytes); + return return_bool; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_groups.h b/libsession-util/src/main/cpp/user_groups.h new file mode 100644 index 0000000000..c4754fe113 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.h @@ -0,0 +1,139 @@ + +#ifndef SESSION_ANDROID_USER_GROUPS_H +#define SESSION_ANDROID_USER_GROUPS_H + +#include "jni.h" +#include "util.h" +#include "conversation.h" +#include "session/config/user_groups.hpp" + +inline session::config::UserGroups* ptrToUserGroups(JNIEnv *env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserGroups*) env->GetLongField(obj, pointerField); +} + +inline void deserialize_members_into(JNIEnv *env, jobject members_map, session::config::legacy_group_info& to_append_group) { + jclass map_class = env->FindClass("java/util/Map"); + jclass map_entry_class = env->FindClass("java/util/Map$Entry"); + jclass set_class = env->FindClass("java/util/Set"); + jclass iterator_class = env->FindClass("java/util/Iterator"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + + jmethodID get_entry_set = env->GetMethodID(map_class, "entrySet", "()Ljava/util/Set;"); + jmethodID get_at = env->GetMethodID(set_class, "iterator", "()Ljava/util/Iterator;"); + jmethodID has_next = env->GetMethodID(iterator_class, "hasNext", "()Z"); + jmethodID next = env->GetMethodID(iterator_class, "next", "()Ljava/lang/Object;"); + jmethodID get_key = env->GetMethodID(map_entry_class, "getKey", "()Ljava/lang/Object;"); + jmethodID get_value = env->GetMethodID(map_entry_class, "getValue", "()Ljava/lang/Object;"); + jmethodID get_bool_value = env->GetMethodID(boxed_bool, "booleanValue", "()Z"); + + jobject entry_set = env->CallObjectMethod(members_map, get_entry_set); + jobject iterator = env->CallObjectMethod(entry_set, get_at); + + while (env->CallBooleanMethod(iterator, has_next)) { + jobject entry = env->CallObjectMethod(iterator, next); + jstring key = static_cast(env->CallObjectMethod(entry, get_key)); + jobject boxed = env->CallObjectMethod(entry, get_value); + bool is_admin = env->CallBooleanMethod(boxed, get_bool_value); + auto member_string = env->GetStringUTFChars(key, nullptr); + to_append_group.insert(member_string, is_admin); + env->ReleaseStringUTFChars(key, member_string); + } +} + +inline session::config::legacy_group_info deserialize_legacy_group_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto id_field = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto name_field = env->GetFieldID(clazz, "name", "Ljava/lang/String;"); + auto members_field = env->GetFieldID(clazz, "members", "Ljava/util/Map;"); + auto enc_pub_key_field = env->GetFieldID(clazz, "encPubKey", "[B"); + auto enc_sec_key_field = env->GetFieldID(clazz, "encSecKey", "[B"); + auto priority_field = env->GetFieldID(clazz, "priority", "I"); + auto disappearing_timer_field = env->GetFieldID(clazz, "disappearingTimer", "J"); + auto joined_at_field = env->GetFieldID(clazz, "joinedAt", "J"); + jstring id = static_cast(env->GetObjectField(info, id_field)); + jstring name = static_cast(env->GetObjectField(info, name_field)); + jobject members_map = env->GetObjectField(info, members_field); + jbyteArray enc_pub_key = static_cast(env->GetObjectField(info, enc_pub_key_field)); + jbyteArray enc_sec_key = static_cast(env->GetObjectField(info, enc_sec_key_field)); + int priority = env->GetIntField(info, priority_field); + long joined_at = env->GetLongField(info, joined_at_field); + + auto id_bytes = env->GetStringUTFChars(id, nullptr); + auto name_bytes = env->GetStringUTFChars(name, nullptr); + auto enc_pub_key_bytes = util::ustring_from_bytes(env, enc_pub_key); + auto enc_sec_key_bytes = util::ustring_from_bytes(env, enc_sec_key); + + auto info_deserialized = conf->get_or_construct_legacy_group(id_bytes); + + auto current_members = info_deserialized.members(); + for (auto member = current_members.begin(); member != current_members.end(); ++member) { + info_deserialized.erase(member->first); + } + deserialize_members_into(env, members_map, info_deserialized); + info_deserialized.name = name_bytes; + info_deserialized.enc_pubkey = enc_pub_key_bytes; + info_deserialized.enc_seckey = enc_sec_key_bytes; + info_deserialized.priority = priority; + info_deserialized.disappearing_timer = std::chrono::seconds(env->GetLongField(info, disappearing_timer_field)); + info_deserialized.joined_at = joined_at; + env->ReleaseStringUTFChars(id, id_bytes); + env->ReleaseStringUTFChars(name, name_bytes); + return info_deserialized; +} + +inline session::config::community_info deserialize_community_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto base_info = env->GetFieldID(clazz, "community", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto priority = env->GetFieldID(clazz, "priority", "I"); + jobject base_community_info = env->GetObjectField(info, base_info); + auto deserialized_base_info = util::deserialize_base_community(env, base_community_info); + int deserialized_priority = env->GetIntField(info, priority); + auto community_info = conf->get_or_construct_community(deserialized_base_info.base_url(), deserialized_base_info.room(), deserialized_base_info.pubkey_hex()); + community_info.priority = deserialized_priority; + return community_info; +} + +inline jobject serialize_members(JNIEnv *env, std::map members_map) { + jclass map_class = env->FindClass("java/util/HashMap"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + jmethodID map_constructor = env->GetMethodID(map_class, "", "()V"); + jmethodID insert = env->GetMethodID(map_class, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + jmethodID new_bool = env->GetMethodID(boxed_bool, "", "(Z)V"); + + jobject new_map = env->NewObject(map_class, map_constructor); + for (auto it = members_map.begin(); it != members_map.end(); it++) { + auto session_id = env->NewStringUTF(it->first.data()); + bool is_admin = it->second; + auto jbool = env->NewObject(boxed_bool, new_bool, is_admin); + env->CallObjectMethod(new_map, insert, session_id, jbool); + } + return new_map; +} + +inline jobject serialize_legacy_group_info(JNIEnv *env, session::config::legacy_group_info info) { + jstring session_id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jobject members = serialize_members(env, info.members()); + jbyteArray enc_pubkey = util::bytes_from_ustring(env, info.enc_pubkey); + jbyteArray enc_seckey = util::bytes_from_ustring(env, info.enc_seckey); + int priority = info.priority; + long joined_at = info.joined_at; + + jclass legacy_group_class = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + jmethodID constructor = env->GetMethodID(legacy_group_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;[B[BIJJ)V"); + jobject serialized = env->NewObject(legacy_group_class, constructor, session_id, name, members, enc_pubkey, enc_seckey, priority, (jlong) info.disappearing_timer.count(), joined_at); + return serialized; +} + +inline jobject serialize_community_info(JNIEnv *env, session::config::community_info info) { + auto priority = info.priority; + auto serialized_info = util::serialize_base_community(env, info); + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;I)V"); + jobject serialized = env->NewObject(clazz, constructor, serialized_info, priority); + return serialized; +} + +#endif //SESSION_ANDROID_USER_GROUPS_H diff --git a/libsession-util/src/main/cpp/user_profile.cpp b/libsession-util/src/main/cpp/user_profile.cpp new file mode 100644 index 0000000000..78b671ef0d --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.cpp @@ -0,0 +1,98 @@ +#include "user_profile.h" +#include "util.h" + +extern "C" { +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + auto* profile = new session::config::UserProfile(secret_key, std::optional(initial)); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast(profile)); + + return newConfig; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_00024Companion_newInstance___3B( + JNIEnv* env, + jobject, + jbyteArray secretKey) { + std::lock_guard lock{util::util_mutex_}; + auto* profile = new session::config::UserProfile(util::ustring_from_bytes(env, secretKey), std::nullopt); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast(profile)); + + return newConfig; +} +#pragma clang diagnostic pop + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setName( + JNIEnv* env, + jobject thiz, + jstring newName) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name_chars = env->GetStringUTFChars(newName, nullptr); + profile->set_name(name_chars); + env->ReleaseStringUTFChars(newName, name_chars); +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getName(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name = profile->get_name(); + if (name == std::nullopt) return nullptr; + jstring returnString = env->NewStringUTF(name->data()); + return returnString; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getPic(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = profile->get_profile_pic(); + + jobject returnObject = util::serialize_user_pic(env, pic); + + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setPic(JNIEnv *env, jobject thiz, + jobject user_pic) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = util::deserialize_user_pic(env, user_pic); + auto url = env->GetStringUTFChars(pic.first, nullptr); + auto key = util::ustring_from_bytes(env, pic.second); + profile->set_profile_pic(url, key); + env->ReleaseStringUTFChars(pic.first, url); +} + +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setNtsPriority(JNIEnv *env, jobject thiz, + jint priority) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + profile->set_nts_priority(priority); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getNtsPriority(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + return profile->get_nts_priority(); +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_profile.h b/libsession-util/src/main/cpp/user_profile.h new file mode 100644 index 0000000000..cb1b8d973b --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.h @@ -0,0 +1,14 @@ +#ifndef SESSION_ANDROID_USER_PROFILE_H +#define SESSION_ANDROID_USER_PROFILE_H + +#include "session/config/user_profile.hpp" +#include +#include + +inline session::config::UserProfile* ptrToProfile(JNIEnv* env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserProfile*) env->GetLongField(obj, pointerField); +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.cpp b/libsession-util/src/main/cpp/util.cpp new file mode 100644 index 0000000000..69469eac1e --- /dev/null +++ b/libsession-util/src/main/cpp/util.cpp @@ -0,0 +1,167 @@ +#include "util.h" +#include +#include + +namespace util { + + std::mutex util_mutex_ = std::mutex(); + + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str) { + size_t length = from_str.length(); + auto jlength = (jsize)length; + jbyteArray new_array = env->NewByteArray(jlength); + env->SetByteArrayRegion(new_array, 0, jlength, (jbyte*)from_str.data()); + return new_array; + } + + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray) { + size_t len = env->GetArrayLength(byteArray); + auto bytes = env->GetByteArrayElements(byteArray, nullptr); + + session::ustring st{reinterpret_cast(bytes), len}; + env->ReleaseByteArrayElements(byteArray, bytes, 0); + return st; + } + + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic) { + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jmethodID constructor = env->GetMethodID(returnObjectClass, "", "(Ljava/lang/String;[B)V"); + jstring url = env->NewStringUTF(pic.url.data()); + jbyteArray byteArray = util::bytes_from_ustring(env, pic.key); + return env->NewObject(returnObjectClass, constructor, url, byteArray); + } + + std::pair deserialize_user_pic(JNIEnv *env, jobject user_pic) { + jclass userPicClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jfieldID picField = env->GetFieldID(userPicClass, "url", "Ljava/lang/String;"); + jfieldID keyField = env->GetFieldID(userPicClass, "key", "[B"); + auto pic = (jstring)env->GetObjectField(user_pic, picField); + auto key = (jbyteArray)env->GetObjectField(user_pic, keyField); + return {pic, key}; + } + + jobject serialize_base_community(JNIEnv *env, const session::config::community& community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jmethodID base_community_constructor = env->GetMethodID(base_community_clazz, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + auto base_url = env->NewStringUTF(community.base_url().data()); + auto room = env->NewStringUTF(community.room().data()); + auto pubkey_jstring = env->NewStringUTF(community.pubkey_hex().data()); + jobject ret = env->NewObject(base_community_clazz, base_community_constructor, base_url, room, pubkey_jstring); + return ret; + } + + session::config::community deserialize_base_community(JNIEnv *env, jobject base_community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jfieldID base_url_field = env->GetFieldID(base_community_clazz, "baseUrl", "Ljava/lang/String;"); + jfieldID room_field = env->GetFieldID(base_community_clazz, "room", "Ljava/lang/String;"); + jfieldID pubkey_hex_field = env->GetFieldID(base_community_clazz, "pubKeyHex", "Ljava/lang/String;"); + auto base_url = (jstring)env->GetObjectField(base_community,base_url_field); + auto room = (jstring)env->GetObjectField(base_community, room_field); + auto pub_key_hex = (jstring)env->GetObjectField(base_community, pubkey_hex_field); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto community = session::config::community(base_url_chars, room_chars, pub_key_hex_chars); + + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, pub_key_hex_chars); + return community; + } + + jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds) { + jclass none = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$NONE"); + jfieldID none_instance = env->GetStaticFieldID(none, "INSTANCE", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode$NONE;"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jmethodID send_init = env->GetMethodID(after_send, "", "(J)V"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jmethodID read_init = env->GetMethodID(after_read, "", "(J)V"); + + if (mode == session::config::expiration_mode::none) { + return env->GetStaticObjectField(none, none_instance); + } else if (mode == session::config::expiration_mode::after_send) { + return env->NewObject(after_send, send_init, time_seconds.count()); + } else if (mode == session::config::expiration_mode::after_read) { + return env->NewObject(after_read, read_init, time_seconds.count()); + } + return nullptr; + } + + std::pair deserialize_expiry(JNIEnv *env, jobject expiry_mode) { + jclass parent = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jfieldID duration_seconds = env->GetFieldID(parent, "expirySeconds", "J"); + + jclass object_class = env->GetObjectClass(expiry_mode); + + if (object_class == after_read) { + return std::pair(session::config::expiration_mode::after_read, env->GetLongField(expiry_mode, duration_seconds)); + } else if (object_class == after_send) { + return std::pair(session::config::expiration_mode::after_send, env->GetLongField(expiry_mode, duration_seconds)); + } + return std::pair(session::config::expiration_mode::none, 0); + } + +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519KeyPair(JNIEnv *env, jobject thiz, jbyteArray seed) { + std::array ed_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + std::array ed_sk; // NOLINT(cppcoreguidelines-pro-type-member-init) + auto seed_bytes = util::ustring_from_bytes(env, seed); + crypto_sign_ed25519_seed_keypair(ed_pk.data(), ed_sk.data(), seed_bytes.data()); + + jclass kp_class = env->FindClass("network/loki/messenger/libsession_util/util/KeyPair"); + jmethodID kp_constructor = env->GetMethodID(kp_class, "", "([B[B)V"); + + jbyteArray pk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_pk.data(), ed_pk.size()}); + jbyteArray sk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_sk.data(), ed_sk.size()}); + + jobject return_obj = env->NewObject(kp_class, kp_constructor, pk_jarray, sk_jarray); + return return_obj; +} +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519PkToCurve25519(JNIEnv *env, + jobject thiz, + jbyteArray pk) { + auto ed_pk = util::ustring_from_bytes(env, pk); + std::array curve_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + int success = crypto_sign_ed25519_pk_to_curve25519(curve_pk.data(), ed_pk.data()); + if (success != 0) { + jclass exception = env->FindClass("java/lang/Exception"); + env->ThrowNew(exception, "Invalid crypto_sign_ed25519_pk_to_curve25519 operation"); + return nullptr; + } + jbyteArray curve_pk_jarray = util::bytes_from_ustring(env, session::ustring_view {curve_pk.data(), curve_pk.size()}); + return curve_pk_jarray; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_00024Companion_parseFullUrl( + JNIEnv *env, jobject thiz, jstring full_url) { + auto bytes = env->GetStringUTFChars(full_url, nullptr); + auto [base, room, pk] = session::config::community::parse_full_url(bytes); + env->ReleaseStringUTFChars(full_url, bytes); + + jclass clazz = env->FindClass("kotlin/Triple"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V"); + + auto base_j = env->NewStringUTF(base.data()); + auto room_j = env->NewStringUTF(room.data()); + auto pk_jbytes = util::bytes_from_ustring(env, pk); + + jobject triple = env->NewObject(clazz, constructor, base_j, room_j, pk_jbytes); + return triple; +} +extern "C" +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_fullUrl(JNIEnv *env, + jobject thiz) { + auto deserialized = util::deserialize_base_community(env, thiz); + auto full_url = deserialized.full_url(); + return env->NewStringUTF(full_url.data()); +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.h b/libsession-util/src/main/cpp/util.h new file mode 100644 index 0000000000..9348e8bd7e --- /dev/null +++ b/libsession-util/src/main/cpp/util.h @@ -0,0 +1,24 @@ +#ifndef SESSION_ANDROID_UTIL_H +#define SESSION_ANDROID_UTIL_H + +#include +#include +#include +#include "session/types.hpp" +#include "session/config/profile_pic.hpp" +#include "session/config/user_groups.hpp" +#include "session/config/expiring.hpp" + +namespace util { + extern std::mutex util_mutex_; + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str); + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray); + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic); + std::pair deserialize_user_pic(JNIEnv *env, jobject user_pic); + jobject serialize_base_community(JNIEnv *env, const session::config::community& base_community); + session::config::community deserialize_base_community(JNIEnv *env, jobject base_community); + jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds); + std::pair deserialize_expiry(JNIEnv *env, jobject expiry_mode); +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt new file mode 100644 index 0000000000..52fb541d7d --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -0,0 +1,200 @@ +package network.loki.messenger.libsession_util + +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.ConfigPush +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log + + +sealed class ConfigBase(protected val /* yucky */ pointer: Long) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun kindFor(configNamespace: Int): Class + + fun ConfigBase.protoKindFor(): Kind = when (this) { + is UserProfile -> Kind.USER_PROFILE + is Contacts -> Kind.CONTACTS + is ConversationVolatileConfig -> Kind.CONVO_INFO_VOLATILE + is UserGroupsConfig -> Kind.GROUPS + } + + // TODO: time in future to activate (hardcoded to 1st jan 2024 for testing, change before release) + private const val ACTIVATE_TIME = 1690761600000 + + fun isNewConfigEnabled(forced: Boolean, currentTime: Long) = + forced || currentTime >= ACTIVATE_TIME + + const val PRIORITY_HIDDEN = -1 + const val PRIORITY_VISIBLE = 0 + const val PRIORITY_PINNED = 1 + + } + + external fun dirty(): Boolean + external fun needsPush(): Boolean + external fun needsDump(): Boolean + external fun push(): ConfigPush + external fun dump(): ByteArray + external fun encryptionDomain(): String + external fun confirmPushed(seqNo: Long, newHash: String) + external fun merge(toMerge: Array>): Int + external fun currentHashes(): List + + external fun configNamespace(): Int + + // Singular merge + external fun merge(toMerge: Pair): Int + + external fun free() + +} + +class Contacts(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): Contacts + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): Contacts + } + + external fun get(sessionId: String): Contact? + external fun getOrConstruct(sessionId: String): Contact + external fun all(): List + external fun set(contact: Contact) + external fun erase(sessionId: String): Boolean + + /** + * Similar to [updateIfExists], but will create the underlying contact if it doesn't exist before passing to [updateFunction] + */ + fun upsertContact(sessionId: String, updateFunction: Contact.()->Unit = {}) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = getOrConstruct(sessionId) + updateFunction(contact) + set(contact) + } + + /** + * Updates the contact by sessionId with a given [updateFunction], and applies to the underlying config. + * the [updateFunction] doesn't run if there is no contact + */ + fun updateIfExists(sessionId: String, updateFunction: Contact.()->Unit) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = get(sessionId) ?: return + updateFunction(contact) + set(contact) + } +} + +class UserProfile(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserProfile + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserProfile + } + + external fun setName(newName: String) + external fun getName(): String? + external fun getPic(): UserPic + external fun setPic(userPic: UserPic) + external fun setNtsPriority(priority: Int) + external fun getNtsPriority(): Int +} + +class ConversationVolatileConfig(pointer: Long): ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): ConversationVolatileConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): ConversationVolatileConfig + } + + external fun getOneToOne(pubKeyHex: String): Conversation.OneToOne? + external fun getOrConstructOneToOne(pubKeyHex: String): Conversation.OneToOne + external fun eraseOneToOne(pubKeyHex: String): Boolean + + external fun getCommunity(baseUrl: String, room: String): Conversation.Community? + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKeyHex: String): Conversation.Community + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKey: ByteArray): Conversation.Community + external fun eraseCommunity(community: Conversation.Community): Boolean + external fun eraseCommunity(baseUrl: String, room: String): Boolean + + external fun getLegacyClosedGroup(groupId: String): Conversation.LegacyGroup? + external fun getOrConstructLegacyGroup(groupId: String): Conversation.LegacyGroup + external fun eraseLegacyClosedGroup(groupId: String): Boolean + external fun erase(conversation: Conversation): Boolean + + external fun set(toStore: Conversation) + + /** + * Erase all conversations that do not satisfy the `predicate`, similar to [MutableList.removeAll] + */ + external fun eraseAll(predicate: (Conversation) -> Boolean): Int + + external fun sizeOneToOnes(): Int + external fun sizeCommunities(): Int + external fun sizeLegacyClosedGroups(): Int + external fun size(): Int + + external fun empty(): Boolean + + external fun allOneToOnes(): List + external fun allCommunities(): List + external fun allLegacyClosedGroups(): List + external fun all(): List + +} + +class UserGroupsConfig(pointer: Long): ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserGroupsConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserGroupsConfig + } + + external fun getCommunityInfo(baseUrl: String, room: String): GroupInfo.CommunityGroupInfo? + external fun getLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo? + external fun getOrConstructCommunityInfo(baseUrl: String, room: String, pubKeyHex: String): GroupInfo.CommunityGroupInfo + external fun getOrConstructLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo + external fun set(groupInfo: GroupInfo) + external fun erase(communityInfo: GroupInfo) + external fun eraseCommunity(baseCommunityInfo: BaseCommunityInfo): Boolean + external fun eraseCommunity(server: String, room: String): Boolean + external fun eraseLegacyGroup(sessionId: String): Boolean + external fun sizeCommunityInfo(): Int + external fun sizeLegacyGroupInfo(): Int + external fun size(): Int + external fun all(): List + external fun allCommunityInfo(): List + external fun allLegacyGroupInfo(): List +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt new file mode 100644 index 0000000000..a48d082a62 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt @@ -0,0 +1,11 @@ +package network.loki.messenger.libsession_util.util + +data class BaseCommunityInfo(val baseUrl: String, val room: String, val pubKeyHex: String) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun parseFullUrl(fullUrl: String): Triple? + } + external fun fullUrl(): String +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt new file mode 100644 index 0000000000..8cc22a6afe --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt @@ -0,0 +1,13 @@ +package network.loki.messenger.libsession_util.util + +data class Contact( + val id: String, + var name: String = "", + var nickname: String = "", + var approved: Boolean = false, + var approvedMe: Boolean = false, + var blocked: Boolean = false, + var profilePicture: UserPic = UserPic.DEFAULT, + var priority: Int = 0, + var expiryMode: ExpiryMode, +) \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt new file mode 100644 index 0000000000..97930e8b40 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt @@ -0,0 +1,25 @@ +package network.loki.messenger.libsession_util.util + +sealed class Conversation { + + abstract var lastRead: Long + abstract var unread: Boolean + + data class OneToOne( + val sessionId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() + + data class Community( + val baseCommunityInfo: BaseCommunityInfo, + override var lastRead: Long, + override var unread: Boolean + ) : Conversation() + + data class LegacyGroup( + val groupId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt new file mode 100644 index 0000000000..58e98a4392 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt @@ -0,0 +1,7 @@ +package network.loki.messenger.libsession_util.util + +sealed class ExpiryMode(val expirySeconds: Long) { + object NONE: ExpiryMode(0) + class AfterSend(seconds: Long): ExpiryMode(seconds) + class AfterRead(seconds: Long): ExpiryMode(seconds) +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt new file mode 100644 index 0000000000..c8ace0a9a7 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt @@ -0,0 +1,53 @@ +package network.loki.messenger.libsession_util.util + +sealed class GroupInfo { + + data class CommunityGroupInfo(val community: BaseCommunityInfo, val priority: Int) : GroupInfo() + + data class LegacyGroupInfo( + val sessionId: String, + val name: String, + val members: Map, + val encPubKey: ByteArray, + val encSecKey: ByteArray, + val priority: Int, + val disappearingTimer: Long, + val joinedAt: Long + ): GroupInfo() { + companion object { + @Suppress("FunctionName") + external fun NAME_MAX_LENGTH(): Int + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LegacyGroupInfo + + if (sessionId != other.sessionId) return false + if (name != other.name) return false + if (members != other.members) return false + if (!encPubKey.contentEquals(other.encPubKey)) return false + if (!encSecKey.contentEquals(other.encSecKey)) return false + if (priority != other.priority) return false + if (disappearingTimer != other.disappearingTimer) return false + if (joinedAt != other.joinedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = sessionId.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + members.hashCode() + result = 31 * result + encPubKey.contentHashCode() + result = 31 * result + encSecKey.contentHashCode() + result = 31 * result + priority + result = 31 * result + disappearingTimer.hashCode() + result = 31 * result + joinedAt.hashCode() + return result + } + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt new file mode 100644 index 0000000000..6168bd2165 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt @@ -0,0 +1,9 @@ +package network.loki.messenger.libsession_util.util + +object Sodium { + init { + System.loadLibrary("session_util") + } + external fun ed25519KeyPair(seed: ByteArray): KeyPair + external fun ed25519PkToCurve25519(pk: ByteArray): ByteArray +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt new file mode 100644 index 0000000000..4222395b5d --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt @@ -0,0 +1,67 @@ +package network.loki.messenger.libsession_util.util + +data class ConfigPush(val config: ByteArray, val seqNo: Long, val obsoleteHashes: List) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConfigPush + + if (!config.contentEquals(other.config)) return false + if (seqNo != other.seqNo) return false + if (obsoleteHashes != other.obsoleteHashes) return false + + return true + } + + override fun hashCode(): Int { + var result = config.contentHashCode() + result = 31 * result + seqNo.hashCode() + result = 31 * result + obsoleteHashes.hashCode() + return result + } + +} + +data class UserPic(val url: String, val key: ByteArray) { + companion object { + val DEFAULT = UserPic("", byteArrayOf()) + } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UserPic + + if (url != other.url) return false + if (!key.contentEquals(other.key)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + key.contentHashCode() + return result + } +} + +data class KeyPair(val pubKey: ByteArray, val secretKey: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyPair + + if (!pubKey.contentEquals(other.pubKey)) return false + if (!secretKey.contentEquals(other.secretKey)) return false + + return true + } + + override fun hashCode(): Int { + var result = pubKey.contentHashCode() + result = 31 * result + secretKey.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt new file mode 100644 index 0000000000..3d156bfd4d --- /dev/null +++ b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt @@ -0,0 +1,14 @@ +package network.loki.messenger.libsession_util + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + +} \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index dd8959958e..045648e090 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -18,6 +18,7 @@ android { dependencies { implementation project(":libsignal") + implementation project(":libsession-util") implementation project(":liblazysodium") implementation "net.java.dev.jna:jna:5.8.0@aar" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" @@ -34,7 +35,6 @@ dependencies { implementation 'com.annimon:stream:1.1.8' implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.esotericsoftware:kryo:5.1.1' - implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" @@ -46,10 +46,6 @@ dependencies { testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation "org.mockito:mockito-inline:4.0.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' testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" diff --git a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java index a448b3f7a7..f78089e25e 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java @@ -33,7 +33,7 @@ public class ResourceContactPhoto implements FallbackContactPhoto { Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); - foreground.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); if (inverted) { foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 9a88202470..4fff833835 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -2,12 +2,14 @@ package org.session.libsession.database import android.content.Context import android.net.Uri +import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageSendJob +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 @@ -30,6 +32,7 @@ import org.session.libsession.utilities.recipients.Recipient.RecipientSettings import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact interface StorageProtocol { @@ -38,6 +41,9 @@ interface StorageProtocol { fun getUserX25519KeyPair(): ECKeyPair fun getUserProfile(): Profile fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) + fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) + fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) + fun clearUserPic() // Signal fun getOrGenerateRegistrationID(): Int @@ -50,8 +56,10 @@ interface StorageProtocol { fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageReceiveJob(messageReceiveJobID: String): Job? fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): Job? + fun getConfigSyncJob(destination: Destination): Job? fun resumeMessageSendJobIfNeeded(messageSendJobID: String) fun isJobCanceled(job: Job): Boolean + fun cancelPendingMessageSendJobs(threadID: Long) // Authorization fun getAuthToken(room: String, server: String): String? @@ -67,7 +75,7 @@ interface StorageProtocol { fun updateOpenGroup(openGroup: OpenGroup) fun getOpenGroup(threadId: Long): OpenGroup? fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? - fun onOpenGroupAdded(server: String) + fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun getOpenGroup(room: String, server: String): OpenGroup? @@ -119,6 +127,8 @@ interface StorageProtocol { // Closed Groups fun getGroup(groupID: String): GroupRecord? fun createGroup(groupID: String, title: String?, members: List
, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List
, formationTimestamp: Long) + fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) + fun updateGroupConfig(groupPublicKey: String) fun isGroupActive(groupPublicKey: String): Boolean fun setActive(groupID: String, value: Boolean) fun getZombieMembers(groupID: String): Set @@ -129,7 +139,7 @@ interface StorageProtocol { fun getAllActiveClosedGroupPublicKeys(): Set fun addClosedGroupPublicKey(groupPublicKey: String) fun removeClosedGroupPublicKey(groupPublicKey: String) - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) @@ -140,18 +150,20 @@ interface StorageProtocol { fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) - fun setExpirationTimer(groupID: String, duration: Int) + fun setExpirationTimer(address: String, duration: Int) // Groups - fun getAllGroups(): List + fun getAllGroups(includeInactive: Boolean): List // Settings fun setProfileSharing(address: Address, value: Boolean) + // Thread fun getOrCreateThreadIdFor(address: Address): Long - fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long + fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? fun getThreadId(publicKeyOrOpenGroupID: String): Long? + fun getThreadId(openGroup: OpenGroup): Long? fun getThreadId(address: Address): Long? fun getThreadId(recipient: Recipient): Long? fun getThreadIdForMms(mmsId: Long): Long @@ -159,7 +171,10 @@ interface StorageProtocol { fun trimThread(threadID: Long, threadLimit: Int) fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long - fun deleteConversation(threadId: Long) + fun setPinned(threadID: Long, isPinned: Boolean) + fun isPinned(threadID: Long): Boolean + fun deleteConversation(threadID: Long) + fun setThreadDate(threadId: Long, newDate: Long) // Contacts fun getContactWithSessionID(sessionID: String): Contact? @@ -167,6 +182,7 @@ interface StorageProtocol { fun setContact(contact: Contact) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? + fun addLibSessionContacts(contacts: List) fun addContacts(contacts: List) // Attachments @@ -177,13 +193,14 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runIncrement: Boolean, runThreadUpdate: Boolean): Long? - fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) - fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): Long? + fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) + fun getLastSeen(threadId: Long): Long fun updateThread(threadId: Long, unarchive: Boolean) fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) fun insertMessageRequestResponse(response: MessageRequestResponse) fun setRecipientApproved(recipient: Recipient, approved: Boolean) + fun getRecipientApproved(address: Address): Boolean fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) fun conversationHasOutgoing(userPublicKey: String): Boolean @@ -203,6 +220,12 @@ interface StorageProtocol { fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) fun deleteReactions(messageId: Long, mms: Boolean) - fun unblock(toUnblock: Iterable) + fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean = false) + fun setRecipientHash(recipient: Recipient, recipientHash: String?) fun blockedContacts(): List + + // Shared configs + fun notifyConfigUpdates(forConfigObject: ConfigBase) + fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean + fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean } diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 37c391dfd6..0437196772 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -4,12 +4,14 @@ import android.content.Context import com.goterl.lazysodium.utils.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.ConfigFactoryProtocol class MessagingModuleConfiguration( val context: Context, val storage: StorageProtocol, val messageDataProvider: MessageDataProvider, - val getUserED25519KeyPair: ()-> KeyPair? + val getUserED25519KeyPair: () -> KeyPair?, + val configFactory: ConfigFactoryProtocol ) { companion object { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index ef1d7567b3..b9eaf8d50d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -42,7 +42,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val threadID = storage.getThreadIdForMms(databaseMessageID) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index cd4189a653..19b6555b50 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -16,7 +16,11 @@ import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource import org.session.libsession.utilities.UploadResult import org.session.libsignal.messages.SignalServiceAttachmentStream -import org.session.libsignal.streams.* +import org.session.libsignal.streams.AttachmentCipherOutputStream +import org.session.libsignal.streams.AttachmentCipherOutputStreamFactory +import org.session.libsignal.streams.DigestingRequestBody +import org.session.libsignal.streams.PaddingInputStream +import org.session.libsignal.streams.PlaintextOutputStreamFactory import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PushAttachmentData import org.session.libsignal.utilities.Util @@ -45,7 +49,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess private val MESSAGE_SEND_JOB_ID_KEY = "message_send_job_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { try { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index c5ec1bc74e..20442e5594 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -3,9 +3,7 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.utilities.Data -import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.Log @@ -29,7 +27,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { return "$server.$room" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { try { val openGroup = OpenGroupUrlParser.parseUrl(joinUrl) val storage = MessagingModuleConfiguration.shared.storage @@ -40,8 +38,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { return } storage.addOpenGroup(openGroup.joinUrl()) - Log.d(KEY, "onOpenGroupAdded(${openGroup.server})") - storage.onOpenGroupAdded(openGroup.server) + storage.onOpenGroupAdded(openGroup.server, openGroup.room) } catch (e: Exception) { Log.e("OpenGroupDispatcher", "Failed to add group because",e) delegate?.handleJobFailed(this, dispatcherName, e) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index fa07a7d9c0..3aea8a1e30 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -7,15 +7,26 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage +import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.ParsedMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.* +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.handle +import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions +import org.session.libsession.messaging.sending_receiving.handleUnsendRequest +import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities @@ -49,6 +60,9 @@ class BatchMessageReceiveJob( const val BATCH_DEFAULT_NUMBER = 512 + // used for processing messages that don't have a thread and shouldn't create one + const val NO_THREAD_MAPPING = -1L + // Keys used for database storage private val NUM_MESSAGES_KEY = "numMessages" private val DATA_KEY = "data" @@ -57,16 +71,27 @@ class BatchMessageReceiveJob( private val OPEN_GROUP_ID_KEY = "open_group_id" } - private fun getThreadId(message: Message, storage: StorageProtocol): Long { - val senderOrSync = when (message) { - is VisibleMessage -> message.syncTarget ?: message.sender!! - is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! - else -> message.sender!! + private fun shouldCreateThread(parsedMessage: ParsedMessage): Boolean { + val message = parsedMessage.message + if (message is VisibleMessage) return true + else { // message is control message otherwise + return when(message) { + is SharedConfigurationMessage -> false + is ClosedGroupControlMessage -> false // message.kind is ClosedGroupControlMessage.Kind.New && !message.isSenderSelf + is DataExtractionNotification -> false + is MessageRequestResponse -> false + is ExpirationTimerUpdate -> false + is ConfigurationMessage -> false + is TypingIndicator -> false + is UnsendRequest -> false + is ReadReceipt -> false + is CallMessage -> false // TODO: maybe + else -> false // shouldn't happen, or I guess would be Visible + } } - return storage.getOrCreateThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID) } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { executeAsync(dispatcherName).get() } @@ -77,15 +102,16 @@ class BatchMessageReceiveJob( val context = MessagingModuleConfiguration.shared.context val localUserPublicKey = storage.getUserPublicKey() val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() // parse and collect IDs messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) message.serverHash = serverHash - val threadID = getThreadId(message, storage) val parsedParams = ParsedMessage(messageParameters, message, proto) + val threadID = Message.getThreadId(message, openGroupID, storage, shouldCreateThread(parsedParams)) ?: NO_THREAD_MAPPING if (!threadMap.containsKey(threadID)) { threadMap[threadID] = mutableListOf(parsedParams) } else { @@ -115,77 +141,101 @@ class BatchMessageReceiveJob( // iterate over threads and persist them (persistence is the longest constant in the batch process operation) runBlocking(Dispatchers.IO) { - val deferredThreadMap = threadMap.entries.map { (threadId, messages) -> - async { - // The LinkedHashMap should preserve insertion order - val messageIds = linkedMapOf>() - messages.forEach { (parameters, message, proto) -> - try { - when (message) { - is VisibleMessage -> { - val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, - runIncrement = false, - runThreadUpdate = false, - runProfileUpdate = true - ) - - if (messageId != null && message.reaction == null) { - val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId( - IdPrefix.BLINDED, it.publicKey.asBytes).hexString } - messageIds[messageId] = Pair( - (message.sender == localUserPublicKey || isUserBlindedSender), - message.hasMention + fun processMessages(threadId: Long, messages: List) = async { + // The LinkedHashMap should preserve insertion order + val messageIds = linkedMapOf>() + val myLastSeen = storage.getLastSeen(threadId) + var newLastSeen = if (myLastSeen == -1L) 0 else myLastSeen + messages.forEach { (parameters, message, proto) -> + try { + when (message) { + is VisibleMessage -> { + val isUserBlindedSender = + message.sender == serverPublicKey?.let { + SodiumUtilities.blindedKeyPair( + it, + MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! ) + }?.let { + SessionId( + IdPrefix.BLINDED, it.publicKey.asBytes + ).hexString } - parameters.openGroupMessageServerID?.let { - MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions) + val sentTimestamp = message.sentTimestamp!! + if (message.sender == localUserPublicKey || isUserBlindedSender) { + if (sentTimestamp > newLastSeen) { + newLastSeen = + sentTimestamp // use sent timestamp here since that is technically the last one we have } } + val messageId = MessageReceiver.handleVisibleMessage( + message, proto, openGroupID, threadId, + runThreadUpdate = false, + runProfileUpdate = true + ) - is UnsendRequest -> { - val deletedMessageId = MessageReceiver.handleUnsendRequest(message) - - // If we removed a message then ensure it isn't in the 'messageIds' - if (deletedMessageId != null) { - messageIds.remove(deletedMessageId) - } + if (messageId != null && message.reaction == null) { + messageIds[messageId] = Pair( + (message.sender == localUserPublicKey || isUserBlindedSender), + message.hasMention + ) } + parameters.openGroupMessageServerID?.let { + MessageReceiver.handleOpenGroupReactions( + threadId, + it, + parameters.reactions + ) + } + } - else -> MessageReceiver.handle(message, proto, openGroupID) - } - } catch (e: Exception) { - Log.e(TAG, "Couldn't process message (id: $id)", e) - if (e is MessageReceiver.Error && !e.isRetryable) { - Log.e(TAG, "Message failed permanently (id: $id)", e) - } else { - Log.e(TAG, "Message failed (id: $id)", e) - failures += parameters + is UnsendRequest -> { + val deletedMessageId = + MessageReceiver.handleUnsendRequest(message) + + // If we removed a message then ensure it isn't in the 'messageIds' + if (deletedMessageId != null) { + messageIds.remove(deletedMessageId) + } } + + else -> MessageReceiver.handle(message, proto, threadId, openGroupID) + } + } catch (e: Exception) { + Log.e(TAG, "Couldn't process message (id: $id)", e) + if (e is MessageReceiver.Error && !e.isRetryable) { + Log.e(TAG, "Message failed permanently (id: $id)", e) + } else { + Log.e(TAG, "Message failed (id: $id)", e) + failures += parameters } } - // increment unreads, notify, and update thread - val unreadFromMine = messageIds.map { it.value.first }.indexOfLast { it } - var trueUnreadCount = messageIds.filter { !it.value.first }.size - var trueUnreadMentionCount = messageIds.filter { !it.value.first && it.value.second }.size - if (unreadFromMine >= 0) { - storage.markConversationAsRead(threadId, false) - - val trueUnreadIds = messageIds.keys.toList().subList(unreadFromMine + 1, messageIds.keys.count()) - trueUnreadCount = trueUnreadIds.size - trueUnreadMentionCount = messageIds - .filter { trueUnreadIds.contains(it.key) && !it.value.first && it.value.second } - .size - } - if (trueUnreadCount > 0) { - storage.incrementUnread(threadId, trueUnreadCount, trueUnreadMentionCount) - } - storage.updateThread(threadId, true) - SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) } + // increment unreads, notify, and update thread + // last seen will be the current last seen if not changed (re-computes the read counts for thread record) + // might have been updated from a different thread at this point + val currentLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } + if (currentLastSeen > newLastSeen) { + newLastSeen = currentLastSeen + } + if (newLastSeen > 0 || currentLastSeen == 0L) { + storage.markConversationAsRead(threadId, newLastSeen, force = true) + } + storage.updateThread(threadId, true) + SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) + } + + val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING } + val noThreadMessages = threadMap[NO_THREAD_MAPPING] ?: listOf() + val deferredThreadMap = withoutDefault.map { (threadId, messages) -> + processMessages(threadId, messages) } // await all thread processing deferredThreadMap.awaitAll() + if (noThreadMessages.isNotEmpty()) { + processMessages(NO_THREAD_MAPPING, noThreadMessages).await() + } } if (failures.isEmpty()) { handleSuccess(dispatcherName) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt new file mode 100644 index 0000000000..ec8de44163 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -0,0 +1,206 @@ +package org.session.libsession.messaging.jobs + +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor +import nl.komponents.kovenant.functional.bind +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.snode.RawResponse +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import java.util.concurrent.atomic.AtomicBoolean + +// only contact (self) and closed group destinations will be supported +data class ConfigurationSyncJob(val destination: Destination): Job { + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 10 + + val shouldRunAgain = AtomicBoolean(false) + + override suspend fun execute(dispatcherName: String) { + val storage = MessagingModuleConfiguration.shared.storage + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context) + val currentTime = SnodeAPI.nowWithOffset + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() + val userPublicKey = storage.getUserPublicKey() + val delegate = delegate + if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature + // if we haven't enabled the new configs don't run + || !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime) + // if we don't have a user ed key pair for signing updates + || userEdKeyPair == null + // this will be useful to not handle null delegate cases + || delegate == null + // check our local identity key exists + || userPublicKey.isNullOrEmpty() + // don't allow pushing configs for non-local user + || (destination is Destination.Contact && destination.publicKey != userPublicKey) + ) { + Log.w(TAG, "No need to run config sync job, TODO") + return delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit + } + + // configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc + val configFactory = MessagingModuleConfiguration.shared.configFactory + + // get latest states, filter out configs that don't need push + val configsRequiringPush = configFactory.getUserConfigs().filter { config -> config.needsPush() } + + // don't run anything if we don't need to push anything + if (configsRequiringPush.isEmpty()) return delegate.handleJobSucceeded(this, dispatcherName) + + // need to get the current hashes before we call `push()` + val toDeleteHashes = mutableListOf() + + // allow null results here so the list index matches configsRequiringPush + val sentTimestamp: Long = SnodeAPI.nowWithOffset + val batchObjects: List?> = configsRequiringPush.map { config -> + val (data, seqNo, obsoleteHashes) = config.push() + toDeleteHashes += obsoleteHashes + SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config + }.map { (message, config) -> + // return a list of batch request objects + val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) + val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( + destination.destinationPublicKey(), + config.configNamespace(), + snodeMessage + ) ?: return@map null // this entry will be null otherwise + message to authenticated // to keep track of seqNo for calling confirmPushed later + } + + val toDeleteRequest = toDeleteHashes.let { toDeleteFromAllNamespaces -> + if (toDeleteFromAllNamespaces.isEmpty()) null + else SnodeAPI.buildAuthenticatedDeleteBatchInfo(destination.destinationPublicKey(), toDeleteFromAllNamespaces) + } + + if (batchObjects.any { it == null }) { + // stop running here, something like a signing error occurred + return delegate.handleJobFailedPermanently(this, dispatcherName, NullPointerException("One or more requests had a null batch request info")) + } + + val allRequests = mutableListOf() + allRequests += batchObjects.requireNoNulls().map { (_, request) -> request } + // add in the deletion if we have any hashes + if (toDeleteRequest != null) { + allRequests += toDeleteRequest + Log.d(TAG, "Including delete request for current hashes") + } + + val batchResponse = SnodeAPI.getSingleTargetSnode(destination.destinationPublicKey()).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + destination.destinationPublicKey(), + allRequests, + sequence = true + ) + } + + try { + val rawResponses = batchResponse.get() + @Suppress("UNCHECKED_CAST") + val responseList = (rawResponses["results"] as List) + // we are always adding in deletions at the end + val deletionResponse = if (toDeleteRequest != null && responseList.isNotEmpty()) responseList.last() else null + val deletedHashes = deletionResponse?.let { + @Suppress("UNCHECKED_CAST") + // get the sub-request body + (deletionResponse["body"] as? RawResponse)?.let { body -> + // get the swarm dict + body["swarm"] as? RawResponse + }?.mapValues { (_, swarmDict) -> + // get the deleted values from dict + ((swarmDict as? RawResponse)?.get("deleted") as? List)?.toSet() ?: emptySet() + }?.values?.reduce { acc, strings -> + // create an intersection of all deleted hashes (common between all swarm nodes) + acc intersect strings + } + } ?: emptySet() + + // at this point responseList index should line up with configsRequiringPush index + configsRequiringPush.forEachIndexed { index, config -> + val (toPushMessage, _) = batchObjects[index]!! + val response = responseList[index] + val responseBody = response["body"] as? RawResponse + val insertHash = responseBody?.get("hash") as? String ?: run { + Log.w(TAG, "No hash returned for the configuration in namespace ${config.configNamespace()}") + return@forEachIndexed + } + Log.d(TAG, "Hash ${insertHash.take(4)} returned from store request for new config") + + // confirm pushed seqno + val thisSeqNo = toPushMessage.seqNo + config.confirmPushed(thisSeqNo, insertHash) + Log.d(TAG, "Successfully removed the deleted hashes from ${config.javaClass.simpleName}") + // dump and write config after successful + if (config.needsDump()) { // usually this will be true? + configFactory.persist(config, toPushMessage.sentTimestamp ?: sentTimestamp) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error performing batch request", e) + return delegate.handleJobFailed(this, dispatcherName, e) + } + delegate.handleJobSucceeded(this, dispatcherName) + if (shouldRunAgain.get() && storage.getConfigSyncJob(destination) == null) { + // reschedule if something has updated since we started this job + JobQueue.shared.add(ConfigurationSyncJob(destination)) + } + } + + fun Destination.destinationPublicKey(): String = when (this) { + is Destination.Contact -> publicKey + is Destination.ClosedGroup -> groupPublicKey + else -> throw NullPointerException("Not public key for this destination") + } + + override fun serialize(): Data { + val (type, address) = when (destination) { + is Destination.Contact -> CONTACT_TYPE to destination.publicKey + is Destination.ClosedGroup -> GROUP_TYPE to destination.groupPublicKey + else -> return Data.EMPTY + } + return Data.Builder() + .putInt(DESTINATION_TYPE_KEY, type) + .putString(DESTINATION_ADDRESS_KEY, address) + .build() + } + + override fun getFactoryKey(): String = KEY + + companion object { + const val TAG = "ConfigSyncJob" + const val KEY = "ConfigSyncJob" + + // Keys used for DB storage + const val DESTINATION_ADDRESS_KEY = "destinationAddress" + const val DESTINATION_TYPE_KEY = "destinationType" + + // type mappings + const val CONTACT_TYPE = 1 + const val GROUP_TYPE = 2 + + } + + class Factory: Job.Factory { + override fun create(data: Data): ConfigurationSyncJob? { + if (!data.hasInt(DESTINATION_TYPE_KEY) || !data.hasString(DESTINATION_ADDRESS_KEY)) return null + + val address = data.getString(DESTINATION_ADDRESS_KEY) + val destination = when (data.getInt(DESTINATION_TYPE_KEY)) { + CONTACT_TYPE -> Destination.Contact(address) + GROUP_TYPE -> Destination.ClosedGroup(address) + else -> return null + } + + return ConfigurationSyncJob(destination) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index 07fd6254da..f0831b8bb6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -13,14 +13,18 @@ class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: override var failureCount: Int = 0 override val maxFailureCount: Int = 10 - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { if (imageId == null) { delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob now requires imageId")) return } - val storage = MessagingModuleConfiguration.shared.storage - val storedImageId = storage.getOpenGroup(room, server)?.imageId + val openGroup = storage.getOpenGroup(room, server) + if (openGroup == null || storage.getThreadId(openGroup) == null) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob openGroup is null")) + return + } + val storedImageId = openGroup.imageId if (storedImageId == null || storedImageId != imageId) { delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId does not match the OpenGroup")) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt index 74e324f0ea..7f3bf9b173 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt @@ -17,7 +17,7 @@ interface Job { internal const val MAX_BUFFER_SIZE = 1_000_000 // bytes } - fun execute(dispatcherName: String) + suspend fun execute(dispatcherName: String) fun serialize(): Data diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 03b9546c4c..b437808f98 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -94,7 +94,7 @@ class JobQueue : JobDelegate { } } - private fun Job.process(dispatcherName: String) { + private suspend fun Job.process(dispatcherName: String) { Log.d(dispatcherName,"processJob: ${javaClass.simpleName} (id: $id)") delegate = this@JobQueue @@ -122,7 +122,7 @@ class JobQueue : JobDelegate { while (isActive) { when (val job = queue.receive()) { - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { + is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob, is ConfigurationSyncJob -> { txQueue.send(job) } is RetrieveProfileAvatarJob, @@ -226,6 +226,7 @@ class JobQueue : JobDelegate { BackgroundGroupAddJob.KEY, OpenGroupDeleteJob.KEY, RetrieveProfileAvatarJob.KEY, + ConfigurationSyncJob.KEY, ) allJobTypes.forEach { type -> resumePendingJobs(type) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt index 2ba33b5632..1ac482d5b4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.jobs import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.utilities.Data @@ -25,20 +26,22 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val private val OPEN_GROUP_ID_KEY = "open_group_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { executeAsync(dispatcherName).get() } fun executeAsync(dispatcherName: String): Promise { val deferred = deferred() try { - val isRetry: Boolean = failureCount != 0 + val storage = MessagingModuleConfiguration.shared.storage val serverPublicKey = openGroupID?.let { - MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) + storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } - val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() + val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) + val threadId = Message.getThreadId(message, this.openGroupID, storage, false) message.serverHash = serverHash - MessageReceiver.handle(message, proto, this.openGroupID) + MessageReceiver.handle(message, proto, threadId ?: -1, this.openGroupID) this.handleSuccess(dispatcherName) deferred.resolve(Unit) } catch (e: Exception) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 524338592c..2a152d0a01 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -10,7 +10,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log @@ -33,7 +32,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { private val DESTINATION_KEY = "destination" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val message = message as? VisibleMessage val storage = MessagingModuleConfiguration.shared.storage @@ -65,7 +64,8 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { return } // Wait for all attachments to upload before continuing } - val promise = MessageSender.send(this.message, this.destination).success { + val isSync = destination is Destination.Contact && destination.publicKey == sender + val promise = MessageSender.send(this.message, this.destination, isSync).success { this.handleSuccess(dispatcherName) }.fail { exception -> var logStacktrace = true diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 25fb2194c8..be58544970 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -8,15 +8,13 @@ import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE - import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.Version - -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.retryIfNeeded class NotifyPNServerJob(val message: SnodeMessage) : Job { @@ -32,7 +30,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { private val MESSAGE_KEY = "message" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val server = PushNotificationAPI.server val parameters = mapOf( "data" to message.data, "send_to" to message.recipient ) val url = "${server}/notify" diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 4c76f87633..333c87ba78 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -19,7 +19,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override var failureCount: Int = 0 override val maxFailureCount: Int = 1 - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size Log.d(TAG, "Deleting $numberToDelete messages") diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt index 5c617fbdb0..9ca2534f66 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -1,16 +1,14 @@ package org.session.libsession.messaging.jobs -import android.text.TextUtils import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.Address import org.session.libsession.utilities.DownloadUtilities.downloadFile import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL import org.session.libsession.utilities.Util.copy import org.session.libsession.utilities.Util.equals -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.streams.ProfileCipherInputStream import org.session.libsignal.utilities.Log @@ -19,12 +17,13 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.security.SecureRandom +import java.util.concurrent.ConcurrentSkipListSet -class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val recipientAddress: Address): Job { +class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipientAddress: Address): Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 - override val maxFailureCount: Int = 0 + override val maxFailureCount: Int = 3 companion object { val TAG = RetrieveProfileAvatarJob::class.simpleName @@ -33,20 +32,30 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val r // Keys used for database storage private const val PROFILE_AVATAR_KEY = "profileAvatar" private const val RECEIPIENT_ADDRESS_KEY = "recipient" + + val errorUrls = ConcurrentSkipListSet() + } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { + val delegate = delegate ?: return + if (profileAvatar in errorUrls) return delegate.handleJobFailed(this, dispatcherName, Exception("Profile URL 404'd this app instance")) val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage val recipient = Recipient.from(context, recipientAddress, true) val profileKey = recipient.resolve().profileKey if (profileKey == null || (profileKey.size != 32 && profileKey.size != 16)) { - Log.w(TAG, "Recipient profile key is gone!") - return + return delegate.handleJobFailedPermanently(this, dispatcherName, Exception("Recipient profile key is gone!")) } - if (AvatarHelper.avatarFileExists(context, recipient.resolve().address) && equals(profileAvatar, recipient.resolve().profileAvatar)) { + // Commit '78d1e9d' (fix: open group threads and avatar downloads) had this commented out so + // it's now limited to just the current user case + if ( + recipient.isLocalNumber && + AvatarHelper.avatarFileExists(context, recipient.resolve().address) && + equals(profileAvatar, recipient.resolve().profileAvatar) + ) { Log.w(TAG, "Already retrieved profile avatar: $profileAvatar") return } @@ -72,16 +81,23 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val r val decryptDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) copy(avatarStream, FileOutputStream(decryptDestination)) decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address)) + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SecureRandom().nextInt()) + setProfilePictureURL(context, profileAvatar) + } + + storage.setProfileAvatar(recipient, profileAvatar) + } catch (e: Exception) { + Log.e("Loki", "Failed to download profile avatar", e) + if (failureCount + 1 >= maxFailureCount) { + errorUrls += profileAvatar + } + return delegate.handleJobFailed(this, dispatcherName, e) } finally { downloadDestination.delete() } - - if (recipient.isLocalNumber) { - setProfileAvatarId(context, SecureRandom().nextInt()) - setProfilePictureURL(context, profileAvatar) - } - - storage.setProfileAvatar(recipient, profileAvatar) + return delegate.handleJobSucceeded(this, dispatcherName) } override fun serialize(): Data { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cfe792274f..46c87d5b90 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -16,6 +16,7 @@ class SessionJobManagerFactories { GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.Factory(), BackgroundGroupAddJob.KEY to BackgroundGroupAddJob.Factory(), OpenGroupDeleteJob.KEY to OpenGroupDeleteJob.Factory(), + ConfigurationSyncJob.KEY to ConfigurationSyncJob.Factory() ) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt index d082ac7088..cc388b0376 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt @@ -20,7 +20,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { const val THREAD_LENGTH_TRIGGER_SIZE = 2000 } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val context = MessagingModuleConfiguration.shared.context val trimmingEnabled = TextSecurePreferences.isThreadLengthTrimmingEnabled(context) val storage = MessagingModuleConfiguration.shared.storage diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt index d201daa98d..dd1d5f1852 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,6 +1,9 @@ package org.session.libsession.messaging.messages import com.google.protobuf.ByteString +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.utilities.GroupUtil import org.session.libsignal.protos.SignalServiceProtos @@ -11,6 +14,7 @@ abstract class Message { var receivedTimestamp: Long? = null var recipient: String? = null var sender: String? = null + var isSenderSelf: Boolean = false var groupPublicKey: String? = null var openGroupServerMessageID: Long? = null var serverHash: String? = null @@ -18,6 +22,17 @@ abstract class Message { open val ttl: Long = 14 * 24 * 60 * 60 * 1000 open val isSelfSendValid: Boolean = false + companion object { + fun getThreadId(message: Message, openGroupID: String?, storage: StorageProtocol, shouldCreateThread: Boolean): Long? { + val senderOrSync = when (message) { + is VisibleMessage -> message.syncTarget ?: message.sender!! + is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! + else -> message.sender!! + } + return storage.getThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID, createThread = shouldCreateThread) + } + } + open fun isValid(): Boolean { val sentTimestamp = sentTimestamp if (sentTimestamp != null && sentTimestamp <= 0) { return false } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt index 30a47ab85b..eae9a76730 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt @@ -122,9 +122,9 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: val displayName = TextSecurePreferences.getProfileName(context) ?: return null val profilePicture = TextSecurePreferences.getProfilePictureURL(context) val profileKey = ProfileKeyUtil.getProfileKey(context) - val groups = storage.getAllGroups() + val groups = storage.getAllGroups(includeInactive = false) for (group in groups) { - if (group.isClosedGroup) { + if (group.isClosedGroup && group.isActive) { if (!group.members.contains(Address.fromSerialized(storage.getUserPublicKey()!!))) continue val groupPublicKey = GroupUtil.doubleDecodeGroupID(group.encodedId).toHexString() val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: continue diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt new file mode 100644 index 0000000000..72b2474965 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt @@ -0,0 +1,36 @@ +package org.session.libsession.messaging.messages.control + +import com.google.protobuf.ByteString +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage + +class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: ByteArray, val seqNo: Long): ControlMessage() { + + override val ttl: Long = 30 * 24 * 60 * 60 * 1000L + override val isSelfSendValid: Boolean = true + + companion object { + fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? { + if (!proto.hasSharedConfigMessage()) return null + val sharedConfig = proto.sharedConfigMessage + if (!sharedConfig.hasKind() || !sharedConfig.hasData()) return null + return SharedConfigurationMessage(sharedConfig.kind, sharedConfig.data.toByteArray(), sharedConfig.seqno) + } + } + + override fun isValid(): Boolean { + if (!super.isValid()) return false + return data.isNotEmpty() && seqNo >= 0 + } + + override fun toProto(): SignalServiceProtos.Content? { + val sharedConfigurationMessage = SharedConfigMessage.newBuilder() + .setKind(kind) + .setSeqno(seqNo) + .setData(ByteString.copyFrom(data)) + .build() + return SignalServiceProtos.Content.newBuilder() + .setSharedConfigMessage(sharedConfigurationMessage) + .build() + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index f5965d5f28..34022b7396 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -9,6 +9,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage @@ -34,13 +35,14 @@ object MessageReceiver { object NoThread: Error("Couldn't find thread for message.") object SelfSend: Error("Message addressed at self.") object InvalidGroupPublicKey: Error("Invalid group public key.") + object NoGroupThread: Error("No thread exists for this group.") object NoGroupKeyPair: Error("Missing group key pair.") object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") internal val isRetryable: Boolean = when (this) { is DuplicateMessage, is InvalidMessage, is UnknownMessage, is UnknownEnvelopeType, is InvalidSignature, is NoData, - is SenderBlocked, is SelfSend -> false + is SenderBlocked, is SelfSend, is NoGroupThread -> false else -> true } } @@ -51,6 +53,7 @@ object MessageReceiver { isOutgoing: Boolean? = null, otherBlindedPublicKey: String? = null, openGroupPublicKey: String? = null, + currentClosedGroups: Set? ): Pair { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() @@ -70,7 +73,7 @@ object MessageReceiver { } else { when (envelope.type) { SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { - if (IdPrefix.fromValue(envelope.source) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(envelope.source)?.isBlinded() == true) { openGroupPublicKey ?: throw Error.InvalidGroupPublicKey otherBlindedPublicKey ?: throw Error.DecryptionFailed val decryptionResult = MessageDecrypter.decryptBlinded( @@ -139,6 +142,7 @@ object MessageReceiver { UnsendRequest.fromProto(proto) ?: MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: + SharedConfigurationMessage.fromProto(proto) ?: VisibleMessage.fromProto(proto) ?: run { throw Error.UnknownMessage } @@ -147,6 +151,9 @@ object MessageReceiver { if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) { throw Error.SelfSend } + if (sender == userPublicKey || isUserBlindedSender) { + message.isSenderSelf = true + } // Guard against control messages in open groups if (isOpenGroupMessage && message !is VisibleMessage) { throw Error.InvalidMessage @@ -167,12 +174,16 @@ object MessageReceiver { // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround // for this issue. - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) { + if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet())) { + throw Error.NoGroupThread + } + if ((message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) || message is SharedConfigurationMessage) { // Allow duplicates in this case to avoid the following situation: // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished // • The user doesn't see the new closed group + // also allow shared configuration messages to be duplicates since we track hashes separately use seqno for conflict resolution } else { if (storage.isDuplicateMessage(envelope.timestamp)) { throw Error.DuplicateMessage } storage.addReceivedMessageTimestamp(envelope.timestamp) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index fa0a49a647..804b2f15be 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -13,6 +13,7 @@ import org.session.libsession.messaging.messages.control.ClosedGroupControlMessa import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.Quote @@ -61,7 +62,7 @@ object MessageSender { } // Convenience - fun send(message: Message, destination: Destination, isSyncMessage: Boolean = false): Promise { + fun send(message: Message, destination: Destination, isSyncMessage: Boolean): Promise { return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { @@ -69,71 +70,115 @@ object MessageSender { } } + // One-on-One Chats & Closed Groups + @Throws(Exception::class) + fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey() + // Set the timestamp, sender and recipient + val messageSendTime = SnodeAPI.nowWithOffset + if (message.sentTimestamp == null) { + message.sentTimestamp = + messageSendTime // Visible messages will already have their sent timestamp set + } + + message.sender = userPublicKey + + when (destination) { + is Destination.Contact -> message.recipient = destination.publicKey + is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey + else -> throw IllegalStateException("Destination should not be an open group.") + } + + val isSelfSend = (message.recipient == userPublicKey) + // Validate the message + if (!message.isValid()) { + throw Error.InvalidMessage + } + // Stop here if this is a self-send, unless it's: + // • a configuration message + // • a sync message + // • a closed group control message of type `new` + var isNewClosedGroupControlMessage = false + if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = + true + if (isSelfSend + && message !is ConfigurationMessage + && !isSyncMessage + && !isNewClosedGroupControlMessage + && message !is UnsendRequest + && message !is SharedConfigurationMessage + ) { + throw Error.InvalidMessage + } + // Attach the user's profile if needed + if (message is VisibleMessage) { + message.profile = storage.getUserProfile() + } + if (message is MessageRequestResponse) { + message.profile = storage.getUserProfile() + } + // Convert it to protobuf + val proto = message.toProto() ?: throw Error.ProtoConversionFailed + // Serialize the protobuf + val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) + // Encrypt the serialized protobuf + val ciphertext = when (destination) { + is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) + is Destination.ClosedGroup -> { + val encryptionKeyPair = + MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( + destination.groupPublicKey + )!! + MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) + } + else -> throw IllegalStateException("Destination should not be open group.") + } + // Wrap the result + val kind: SignalServiceProtos.Envelope.Type + val senderPublicKey: String + when (destination) { + is Destination.Contact -> { + kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE + senderPublicKey = "" + } + is Destination.ClosedGroup -> { + kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE + senderPublicKey = destination.groupPublicKey + } + else -> throw IllegalStateException("Destination should not be open group.") + } + val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result + return SnodeMessage( + message.recipient!!, + base64EncodedData, + message.ttl, + messageSendTime + ) + } + // One-on-One Chats & Closed Groups private fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise { val deferred = deferred() val promise = deferred.promise val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() - // Set the timestamp, sender and recipient - if (message.sentTimestamp == null) { - message.sentTimestamp = SnodeAPI.nowWithOffset // Visible messages will already have their sent timestamp set - } - val messageSendTime = SnodeAPI.nowWithOffset + // recipient will be set later, so initialize it as a function here + val isSelfSend = { message.recipient == userPublicKey } - message.sender = userPublicKey - val isSelfSend = (message.recipient == userPublicKey) // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { + if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend()) { SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!) } deferred.reject(error) } try { - when (destination) { - is Destination.Contact -> message.recipient = destination.publicKey - is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey - else -> throw IllegalStateException("Destination should not be an open group.") - } - // Validate the message - if (!message.isValid()) { throw Error.InvalidMessage } - // Stop here if this is a self-send, unless it's: - // • a configuration message - // • a sync message - // • a closed group control message of type `new` - var isNewClosedGroupControlMessage = false - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = true - if (isSelfSend && message !is ConfigurationMessage && !isSyncMessage && !isNewClosedGroupControlMessage && message !is UnsendRequest) { - handleSuccessfulMessageSend(message, destination) - deferred.resolve(Unit) - return promise - } - // Attach the user's profile if needed - if (message is VisibleMessage) { - message.profile = storage.getUserProfile() - } - if (message is MessageRequestResponse) { - message.profile = storage.getUserProfile() - } - // Convert it to protobuf - val proto = message.toProto() ?: throw Error.ProtoConversionFailed - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.ClosedGroup -> { - val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String + val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) // TODO: this might change in future for config messages val forkInfo = SnodeAPI.forkInfo val namespaces: List = when { @@ -143,29 +188,6 @@ object MessageSender { && forkInfo.hasNamespaces() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP, Namespace.DEFAULT) else -> listOf(Namespace.DEFAULT) } - when (destination) { - is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey - } - else -> throw IllegalStateException("Destination should not be open group.") - } - val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) - // Send the result - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("calculatingPoW", messageSendTime) - } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) - // Send the result - val timestamp = messageSendTime + SnodeAPI.clockOffset - val snodeMessage = SnodeMessage(message.recipient!!, base64EncodedData, message.ttl, timestamp) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("sendingMessage", messageSendTime) - } namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> var isSuccess = false val promiseCount = promises.size @@ -174,9 +196,6 @@ object MessageSender { promise.success { if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds isSuccess = true - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("messageSent", messageSendTime) - } val hash = it["hash"] as? String message.serverHash = hash handleSuccessfulMessageSend(message, destination, isSyncMessage) @@ -414,24 +433,24 @@ object MessageSender { @JvmStatic fun send(message: Message, address: Address) { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(address) + val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) val job = MessageSendJob(message, destination) JobQueue.shared.add(job) } - fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address): Promise { + fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address, isSyncMessage: Boolean): Promise { val attachmentIDs = MessagingModuleConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!) message.attachmentIDs.addAll(attachmentIDs) - return sendNonDurably(message, address) + return sendNonDurably(message, address, isSyncMessage) } - fun sendNonDurably(message: Message, address: Address): Promise { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(address) + fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise { + val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) - return send(message, destination) + return send(message, destination, isSyncMessage) } // Closed groups diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index f62fd5a93a..a98b6b1b6b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -18,14 +18,14 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.Curve import org.session.libsignal.crypto.ecc.ECKeyPair -import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.ThreadUtils +import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.ThreadUtils -import org.session.libsignal.utilities.Log import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -60,16 +60,20 @@ fun MessageSender.create(name: String, members: Collection): Promise): Promise): Promise, name: String) { - val context = MessagingModuleConfiguration.shared.context - val storage = MessagingModuleConfiguration.shared.storage - val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) - val group = storage.getGroup(groupID) ?: run { - Log.d("Loki", "Can't update nonexistent closed group.") - throw Error.NoThread - } - // Update name if needed - if (name != group.title) { setName(groupPublicKey, name) } - // Add members if needed - val addedMembers = members - group.members.map { it.serialize() } - if (!addedMembers.isEmpty()) { addMembers(groupPublicKey, addedMembers) } - // Remove members if needed - val removedMembers = group.members.map { it.serialize() } - members - if (removedMembers.isEmpty()) { removeMembers(groupPublicKey, removedMembers) } -} - fun MessageSender.setName(groupPublicKey: String, newName: String) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage @@ -252,15 +240,15 @@ fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Pro val sentTime = SnodeAPI.nowWithOffset closedGroupControlMessage.sentTimestamp = sentTime storage.setActive(groupID, false) - sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID)).success { + sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID), isSyncMessage = false).success { // Notify the user val infoType = SignalServiceGroup.Type.QUIT - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) if (notifyUser) { + val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) storage.insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime) } // Remove the group private key and unsubscribe from PNs - MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) + MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) deferred.resolve(Unit) }.fail { storage.setActive(groupID, true) @@ -292,7 +280,7 @@ fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, ta // Distribute it sendEncryptionKeyPair(groupPublicKey, newKeyPair, targetMembers)?.success { // Store it * after * having sent out the message to the group - storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey, SnodeAPI.nowWithOffset) pendingKeyPairs[groupPublicKey] = Optional.absent() } } @@ -312,7 +300,8 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe val closedGroupControlMessage = ClosedGroupControlMessage(kind) closedGroupControlMessage.sentTimestamp = sentTime return if (force) { - MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination)) + val isSync = MessagingModuleConfiguration.shared.storage.getUserPublicKey() == destination + MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination), isSyncMessage = isSync) } else { MessageSender.send(closedGroupControlMessage, Address.fromSerialized(destination)) null diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 523ad450b1..19278aadde 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -1,11 +1,11 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils +import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage @@ -42,6 +42,7 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -58,7 +59,10 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient.isBlocked } -fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, openGroupID: String?) { +fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?) { + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return } + when (message) { is ReadReceipt -> handleReadReceipt(message) is TypingIndicator -> handleTypingIndicator(message) @@ -68,8 +72,8 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, is ConfigurationMessage -> handleConfigurationMessage(message) is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> handleMessageRequestResponse(message) - is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID, - runIncrement = true, + is VisibleMessage -> handleVisibleMessage( + message, proto, openGroupID, threadId, runThreadUpdate = true, runProfileUpdate = true ) @@ -77,6 +81,33 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, } } +fun MessageReceiver.messageIsOutdated(message: Message, threadId: Long, openGroupID: String?): Boolean { + when (message) { + is ReadReceipt -> return false // No visible artifact created so better to keep for more reliable read states + is UnsendRequest -> return false // We should always process the removal of messages just in case + } + + // Determine the state of the conversation and the validity of the message + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey()!! + val threadRecipient = storage.getRecipientForThread(threadId) + val conversationVisibleInConfig = storage.conversationInConfig( + if (message.groupPublicKey == null) threadRecipient?.address?.serialize() else null, + message.groupPublicKey, + openGroupID, + true + ) + val canPerformChange = storage.canPerformConfigChange( + if (threadRecipient?.address?.serialize() == userPublicKey) SharedConfigMessage.Kind.USER_PROFILE.name else SharedConfigMessage.Kind.CONTACTS.name, + userPublicKey, + message.sentTimestamp!! + ) + + // If the thread is visible or the message was sent more recently than the last config message (minus + // buffer period) then we should process the message, if not then the message is outdated + return (!conversationVisibleInConfig && !canPerformChange) +} + // region Control Messages private fun MessageReceiver.handleReadReceipt(message: ReadReceipt) { val context = MessagingModuleConfiguration.shared.context @@ -129,6 +160,7 @@ private fun MessageReceiver.handleDataExtractionNotification(message: DataExtrac if (message.groupPublicKey != null) return val storage = MessagingModuleConfiguration.shared.storage val senderPublicKey = message.sender!! + val notification: DataExtractionNotificationInfoMessage = when(message.kind) { is DataExtractionNotification.Kind.Screenshot -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) @@ -149,11 +181,17 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) + val isForceSync = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { + TextSecurePreferences.setHasLegacyConfig(context, true) + if (!firstTimeSync) return + } val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() for (closedGroup in message.closedGroups) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { // just handle the closed group encryption key pairs to avoid sync'd devices getting out of sync - storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey) + storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey, message.sentTimestamp!!) } else if (firstTimeSync) { // only handle new closed group if it's first time sync handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name, @@ -166,9 +204,9 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { .replace(OpenGroupApi.httpDefaultServer, OpenGroupApi.defaultServer) }) { if (allV2OpenGroups.contains(openGroup)) continue - Log.d("OpenGroup", "All open groups doesn't contain $openGroup") + Log.d("OpenGroup", "All open groups doesn't contain open group") if (!storage.hasBackgroundGroupAddJob(openGroup)) { - Log.d("OpenGroup", "Doesn't contain background job for $openGroup, adding") + Log.d("OpenGroup", "Doesn't contain background job for open group, adding") JobQueue.shared.add(BackgroundGroupAddJob(openGroup)) } } @@ -182,10 +220,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { val profileKey = Base64.encodeBytes(message.profileKey) ProfileKeyUtil.setEncodedProfileKey(context, profileKey) - profileManager.setProfileKey(context, recipient, message.profileKey) - if (!message.profilePicture.isNullOrEmpty() && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { - JobQueue.shared.add(RetrieveProfileAvatarJob(message.profilePicture!!, recipient.address)) - } + profileManager.setProfilePicture(context, recipient, message.profilePicture, message.profileKey) } storage.addContacts(message.contacts) } @@ -215,24 +250,28 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion -fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, - proto: SignalServiceProtos.Content, - openGroupID: String?, - runIncrement: Boolean, - runThreadUpdate: Boolean, - runProfileUpdate: Boolean): Long? { +fun MessageReceiver.handleVisibleMessage( + message: VisibleMessage, + proto: SignalServiceProtos.Content, + openGroupID: String?, + threadId: Long, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean +): Long? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val userPublicKey = storage.getUserPublicKey() val messageSender: String? = message.sender + + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return null } + // Get or create thread // FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet // exist. This is intentional, but it's very non-obvious. - val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID) - if (threadID < 0) { + val threadID = storage.getThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID, createThread = true) // Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread - throw MessageReceiver.Error.NoThread - } + ?: throw MessageReceiver.Error.NoThread val threadRecipient = storage.getRecipientForThread(threadID) val userBlindedKey = openGroupID?.let { val openGroup = storage.getOpenGroup(threadID) ?: return@let null @@ -259,9 +298,10 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfileKey(context, recipient, newProfileKey!!) + profileManager.setProfilePicture(context, recipient, profile.profilePictureURL, newProfileKey) profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) - profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!) + } else if (newProfileKey == null || newProfileKey.isEmpty() || profile.profilePictureURL.isNullOrEmpty()) { + profileManager.setProfilePicture(context, recipient, null, null) } } } @@ -344,7 +384,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, message.threadID = threadID val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, - attachments, runIncrement, runThreadUpdate + attachments, runThreadUpdate ) ?: return null val openGroupServerID = message.openGroupServerMessageID if (openGroupServerID != null) { @@ -437,12 +477,34 @@ private fun MessageReceiver.handleClosedGroupControlMessage(message: ClosedGroup is ClosedGroupControlMessage.Kind.MembersRemoved -> handleClosedGroupMembersRemoved(message) is ClosedGroupControlMessage.Kind.MemberLeft -> handleClosedGroupMemberLeft(message) } + if ( + message.kind !is ClosedGroupControlMessage.Kind.New && + MessagingModuleConfiguration.shared.storage.canPerformConfigChange( + SharedConfigMessage.Kind.GROUPS.name, + MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!, + message.sentTimestamp!! + ) + ) { + // update the config + val closedGroupPublicKey = message.getPublicKey() + val storage = MessagingModuleConfiguration.shared.storage + storage.updateGroupConfig(closedGroupPublicKey) + } } +private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when (it) { + is ClosedGroupControlMessage.Kind.New -> it.publicKey.toByteArray().toHexString() + is ClosedGroupControlMessage.Kind.EncryptionKeyPair -> it.publicKey?.toByteArray()?.toHexString() ?: groupPublicKey!! + is ClosedGroupControlMessage.Kind.MemberLeft -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersAdded -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersRemoved -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.NameChange -> groupPublicKey!! +}} + private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) { val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false) - if (!recipient.isApproved && !recipient.isLocalNumber) return + if (!recipient.isApproved && !recipient.isLocalNumber) return Log.e("Loki", "not accepting new closed group from unapproved recipient") val groupPublicKey = kind.publicKey.toByteArray().toHexString() val members = kind.members.map { it.toByteArray().toHexString() } val admins = kind.admins.map { it.toByteArray().toHexString() } @@ -453,10 +515,24 @@ private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMess private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List, admins: List, formationTimestamp: Long, expireTimer: Int) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage - val userPublicKey = TextSecurePreferences.getLocalNumber(context) - // Create the group + val userPublicKey = storage.getUserPublicKey()!! val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val groupExists = storage.getGroup(groupID) != null + + if (!storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, sentTimestamp)) { + // If the closed group already exists then store the encryption keys (since the config only stores + // the latest key we won't be able to decrypt older messages if we were added to the group within + // the last two weeks and the key has been rotated - unfortunately if the user was added more than + // two weeks ago and the keys were rotated within the last two weeks then we won't be able to decrypt + // messages received before the key rotation) + if (groupExists) { + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.updateGroupConfig(groupPublicKey) + } + return + } + + // Create the group if (groupExists) { // Update the group if (!storage.isGroupActive(groupPublicKey)) { @@ -475,18 +551,15 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli // Add the group to the user's set of public keys to poll for storage.addClosedGroupPublicKey(groupPublicKey) // Store the encryption key pair - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair) // Set expiration timer storage.setExpirationTimer(groupID, expireTimer) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, storage.getUserPublicKey()!!) - // Notify the user - if (userPublicKey == sender && !groupExists) { - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTimestamp) - } else if (userPublicKey != sender) { - storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp) - } + PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) + // Create thread + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.setThreadDate(threadId, formationTimestamp) // Start polling ClosedGroupPollerV2.shared.startPolling(groupPublicKey) } @@ -527,7 +600,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr Log.d("Loki", "Ignoring duplicate closed group encryption key pair.") return } - storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey, message.sentTimestamp!!) Log.d("Loki", "Received a new closed group encryption key pair.") } @@ -555,7 +628,12 @@ private fun MessageReceiver.handleClosedGroupNameChanged(message: ClosedGroupCon val members = group.members.map { it.serialize() } val admins = group.admins.map { it.serialize() } val name = kind.name - storage.updateTitle(groupID, name) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey!!, message.sentTimestamp!!)) { + storage.updateTitle(groupID, name) + } + // Notify the user if (userPublicKey == senderPublicKey) { val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) @@ -589,12 +667,16 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo val updateMembers = kind.members.map { it.toByteArray().toHexString() } val newMembers = members + updateMembers - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members in case the added members are zombies - val zombies = storage.getZombieMembers(groupID) - if (zombies.intersect(updateMembers).isNotEmpty()) { - storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + + // Update zombie members in case the added members are zombies + val zombies = storage.getZombieMembers(groupID) + if (zombies.intersect(updateMembers).isNotEmpty()) { + storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -676,13 +758,18 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup Log.d("Loki", "Received a MEMBERS_REMOVED instead of a MEMBERS_LEFT from sender: $senderPublicKey.") } val wasCurrentUserRemoved = userPublicKey in removedMembers - // Admin should send a MEMBERS_LEFT message but handled here just in case - if (didAdminLeave || wasCurrentUserRemoved) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members - storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + // Admin should send a MEMBERS_LEFT message but handled here just in case + if (didAdminLeave || wasCurrentUserRemoved) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) + return + } else { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + // Update zombie members + storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -731,24 +818,30 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont val didAdminLeave = admins.contains(senderPublicKey) val updatedMemberList = members - senderPublicKey val userLeft = (userPublicKey == senderPublicKey) - if (didAdminLeave || userLeft) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) - // Update zombie members - val zombies = storage.getZombieMembers(groupID) - storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + if (didAdminLeave || userLeft) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, delete = userLeft) + + if (userLeft) { + return + } + } else { + storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) + // Update zombie members + val zombies = storage.getZombieMembers(groupID) + storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + } } + // Notify the user - if (userLeft) { - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, threadID, message.sentTimestamp!!) - } else { + if (!userLeft) { storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, message.sentTimestamp!!) } } -private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { +private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { val oldMembers = group.members.map { it.serialize() } // Check that the message isn't from before the group was created if (group.formationTimestamp > sentTimestamp) { @@ -763,7 +856,7 @@ private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPu return true } -fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String) { +fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean) { val storage = MessagingModuleConfiguration.shared.storage storage.removeClosedGroupPublicKey(groupPublicKey) // Remove the key pairs @@ -775,5 +868,11 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) // Stop polling ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + + if (delete) { + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } } // endregion diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 387381c9cc..b9baadcaba 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint @@ -169,6 +170,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S is Endpoint.Outbox, is Endpoint.OutboxSince -> { handleDirectMessages(server, true, response.body as List) } + else -> { /* We don't care about the result of any other calls (won't be polled for) */} } if (secondToLastJob == null && !isCaughtUp) { isCaughtUp = true @@ -205,7 +207,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S val storage = MessagingModuleConfiguration.shared.storage storage.setServerCapabilities(server, capabilities.capabilities) } - + private fun handleMessages( server: String, roomToken: String, @@ -260,7 +262,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S null, fromOutbox, if (fromOutbox) it.recipient else it.sender, - serverPublicKey + serverPublicKey, + emptySet() // this shouldn't be necessary as we are polling open groups here ) if (fromOutbox) { val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping( @@ -277,7 +280,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S } mappingCache[it.recipient] = mapping } - MessageReceiver.handle(message, proto, null) + val threadId = Message.getThreadId(message, null, MessagingModuleConfiguration.shared.storage, false) + MessageReceiver.handle(message, proto, threadId ?: -1, null) } catch (e: Exception) { Log.e("Loki", "Couldn't handle direct message", e) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 4a39b70c0f..f0b20436fc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -1,5 +1,14 @@ package org.session.libsession.messaging.sending_receiving.pollers +import android.util.SparseArray +import androidx.core.util.valueIterator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +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 nl.komponents.kovenant.Deferred import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred @@ -10,17 +19,23 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import java.security.SecureRandom import java.util.Timer import java.util.TimerTask +import kotlin.time.Duration.Companion.days private class PromiseCanceledException : Exception("Promise canceled.") -class Poller { +class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Timer) { var userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: "" private var hasStarted: Boolean = false private val usedSnodes: MutableSet = mutableSetOf() @@ -97,23 +112,159 @@ class Poller { } } + private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { + val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) + val parameters = messages.map { (envelope, serverHash) -> + MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + } + parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> + val job = BatchMessageReceiveJob(chunk) + JobQueue.shared.add(job) + } + } + + private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) { + if (forConfigObject == null) return + + val messages = SnodeAPI.parseRawMessagesResponse( + rawMessages, + snode, + userPublicKey, + namespace, + updateLatestHash = true, + updateStoredHashes = true, + ) + + if (messages.isEmpty()) { + // no new messages to process + return + } + + var latestMessageTimestamp: Long? = null + messages.forEach { (envelope, hash) -> + try { + val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), + // assume no groups in personal poller messages + openGroupServerID = null, currentClosedGroups = emptySet() + ) + // sanity checks + if (message !is SharedConfigurationMessage) { + Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") + return@forEach + } + forConfigObject.merge(hash!! to message.data) + latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + } catch (e: Exception) { + Log.e("Loki", e) + } + } + // process new results + if (forConfigObject.needsDump()) { + configFactory.persist(forConfigObject, latestMessageTimestamp ?: SnodeAPI.nowWithOffset) + } + } + private fun poll(snode: Snode, deferred: Deferred): Promise { if (!hasStarted) { return Promise.ofFail(PromiseCanceledException()) } - return SnodeAPI.getRawMessages(snode, userPublicKey).bind { rawResponse -> - isCaughtUp = true - if (deferred.promise.isDone()) { - task { Unit } // The long polling connection has been canceled; don't recurse - } else { - val messages = SnodeAPI.parseRawMessagesResponse(rawResponse, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + return task { + runBlocking(Dispatchers.IO) { + val requestSparseArray = SparseArray() + // get messages + SnodeAPI.buildAuthenticatedRetrieveBatchRequest(snode, userPublicKey, maxSize = -2)!!.also { personalMessages -> + // namespaces here should always be set + requestSparseArray[personalMessages.namespace!!] = personalMessages } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - val job = BatchMessageReceiveJob(chunk) - JobQueue.shared.add(job) + // get the latest convo info volatile + val hashesToExtend = mutableSetOf() + configFactory.getUserConfigs().mapNotNull { config -> + hashesToExtend += config.currentHashes() + SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + snode, userPublicKey, + config.configNamespace(), + maxSize = -8 + ) + }.forEach { request -> + // namespaces here should always be set + requestSparseArray[request.namespace!!] = request } - poll(snode, deferred) + val requests = + requestSparseArray.valueIterator().asSequence().toMutableList() + + if (hashesToExtend.isNotEmpty()) { + SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + messageHashes = hashesToExtend.toList(), + publicKey = userPublicKey, + newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + extend = true + )?.let { extensionRequest -> + requests += extensionRequest + } + } + + SnodeAPI.getRawBatchResponse(snode, userPublicKey, requests).bind { rawResponses -> + isCaughtUp = true + if (deferred.promise.isDone()) { + return@bind Promise.ofSuccess(Unit) + } else { + val responseList = (rawResponses["results"] as List) + // in case we had null configs, the array won't be fully populated + // index of the sparse array key iterator should be the request index, with the key being the namespace + // TODO: add in specific ordering of config namespaces for processing + listOfNotNull( + configFactory.user?.configNamespace(), + configFactory.contacts?.configNamespace(), + configFactory.userGroups?.configNamespace(), + configFactory.convoVolatile?.configNamespace() + ).map { + it to requestSparseArray.indexOfKey(it) + }.filter { (_, i) -> i >= 0 }.forEach { (key, requestIndex) -> + responseList.getOrNull(requestIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + return@forEach + } + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request didn't contain a body") + return@forEach + } + if (key == Namespace.DEFAULT) { + return@forEach // continue, skip default namespace + } else { + when (ConfigBase.kindFor(key)) { + UserProfile::class.java -> processConfig(snode, body, key, configFactory.user) + Contacts::class.java -> processConfig(snode, body, key, configFactory.contacts) + ConversationVolatileConfig::class.java -> processConfig(snode, body, key, configFactory.convoVolatile) + UserGroupsConfig::class.java -> processConfig(snode, body, key, configFactory.userGroups) + } + } + } + } + + // the first response will be the personal messages (we want these to be processed after config messages) + val personalResponseIndex = requestSparseArray.indexOfKey(Namespace.DEFAULT) + if (personalResponseIndex >= 0) { + responseList.getOrNull(personalResponseIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request for personal messages had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + } else { + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request for personal messages didn't contain a body") + } else { + processPersonalMessages(snode, body) + } + } + } + } + + poll(snode, deferred) + } + }.fail { + Log.e("Loki", "Failed to get raw batch response", it) + poll(snode, deferred) + } } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index 35328b9742..e4db056d8e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -74,6 +74,7 @@ object UpdateMessageBuilder { context.getString(R.string.ConversationItem_group_action_left, senderName) } } + is UpdateMessageData.Kind.OpenGroupInvitation -> { /*Handled externally*/ } } return message } diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 087c8e29d3..8851dfc2b3 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -419,6 +419,8 @@ object OnionRequestAPI { Log.d("Loki","Destination server returned ${exception.statusCode}") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") + } else if (exception.statusCode == 404) { + // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here } else { // Only drop snode/path if not receiving above two exception cases handleUnspecificError() } @@ -446,8 +448,8 @@ object OnionRequestAPI { val payloadData = JsonUtil.toJson(payload).toByteArray() return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> val error = when (exception) { - is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) + is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) else -> null } if (error != null) { throw error } diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index ebd66d3a3f..b1a274773c 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -28,12 +28,12 @@ import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded import java.security.SecureRandom -import java.util.Date import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 @@ -102,6 +102,14 @@ object SnodeAPI { object ValidationFailed : Error("ONS name validation failed.") } + // Batch + data class SnodeBatchRequestInfo( + val method: String, + val params: Map, + @Transient + val namespace: Int? + ) // assume signatures, pubkey and namespaces are attached in parameters if required + // Internal API internal fun invoke( method: Snode.Method, @@ -319,26 +327,32 @@ object SnodeAPI { fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" - val parameters = mutableMapOf( + val parameters = mutableMapOf( "pubKey" to publicKey, "last_hash" to lastHashValue, ) // Construct signature if (requiresAuth) { val userED25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) + MessagingModuleConfiguration.shared.getUserED25519KeyPair() + ?: return Promise.ofFail(Error.NoKeyPair) } catch (e: Exception) { Log.e("Loki", "Error getting KeyPair", e) return Promise.ofFail(Error.NoKeyPair) } - val timestamp = Date().time + SnodeAPI.clockOffset + val timestamp = System.currentTimeMillis() + clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) val verificationData = if (namespace != 0) "retrieve$namespace$timestamp".toByteArray() else "retrieve$timestamp".toByteArray() try { - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userED25519KeyPair.secretKey.asBytes + ) } catch (exception: Exception) { return Promise.ofFail(Error.SigningFailed) } @@ -354,7 +368,251 @@ object SnodeAPI { } // Make the request - return invoke(Snode.Method.GetMessages, snode, parameters, publicKey) + return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) + } + + fun buildAuthenticatedStoreBatchInfo(publicKey: String, namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { + val params = mutableMapOf() + // load the message data params into the sub request + // currently loads: + // pubKey + // data + // ttl + // timestamp + params.putAll(message.toJSON()) + params["namespace"] = namespace + + // used for sig generation since it is also the value used in timestamp parameter + val messageTimestamp = message.timestamp + + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "store$namespace$messageTimestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + } + // timestamp already set + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.SendMessage.rawValue, + params, + namespace + ) + } + + /** + * Message hashes can be shared across multiple namespaces (for a single public key destination) + * @param publicKey the destination's identity public key to delete from (05...) + * @param messageHashes a list of stored message hashes to delete from the server + * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 + */ + fun buildAuthenticatedDeleteBatchInfo(publicKey: String, messageHashes: List, required: Boolean = false): SnodeBatchRequestInfo? { + val params = mutableMapOf( + "pubkey" to publicKey, + "required" to required, // could be omitted technically but explicit here + "messages" to messageHashes + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "delete${messageHashes.joinToString("")}".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.DeleteMessage.rawValue, + params, + null + ) + } + + fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? { + val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" + val params = mutableMapOf( + "pubkey" to publicKey, + "last_hash" to lastHashValue, + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val timestamp = System.currentTimeMillis() + clockOffset + val signature = ByteArray(Sign.BYTES) + val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray() + else "retrieve$namespace$timestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["timestamp"] = timestamp + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + if (namespace != 0) { + params["namespace"] = namespace + } + if (maxSize != null) { + params["max_size"] = maxSize + } + return SnodeBatchRequestInfo( + Snode.Method.Retrieve.rawValue, + params, + namespace + ) + } + + fun buildAuthenticatedAlterTtlBatchRequest( + messageHashes: List, + newExpiry: Long, + publicKey: String, + shorten: Boolean = false, + extend: Boolean = false): SnodeBatchRequestInfo? { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) ?: return null + return SnodeBatchRequestInfo( + Snode.Method.Expire.rawValue, + params, + null + ) + } + + fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List, sequence: Boolean = false): RawResponsePromise { + val parameters = mutableMapOf( + "requests" to requests + ) + return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses -> + val responseList = (rawResponses["results"] as List) + responseList.forEachIndexed { index, response -> + if (response["code"] as? Int != 200) { + Log.w("Loki", "response code was not 200") + handleSnodeError( + response["code"] as? Int ?: 0, + response, + snode, + publicKey + ) + } + } + } + } + + fun getExpiries(messageHashes: List, publicKey: String) : RawResponsePromise { + val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) + return retryIfNeeded(maxRetryCount) { + val timestamp = System.currentTimeMillis() + clockOffset + val params = mutableMapOf( + "pubkey" to publicKey, + "messages" to messageHashes, + "timestamp" to timestamp + ) + val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${messageHashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return@retryIfNeeded Promise.ofFail(e) + } + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + getSingleTargetSnode(publicKey).bind { snode -> + invoke(Snode.Method.GetExpiries, snode, params, publicKey) + } + } + } + + fun alterTtl(messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise { + return retryIfNeeded(maxRetryCount) { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) + ?: return@retryIfNeeded Promise.ofFail( + Exception("Couldn't build signed params for alterTtl request for newExpiry=$newExpiry, extend=$extend, shorten=$shorten") + ) + getSingleTargetSnode(publicKey).bind { snode -> + invoke(Snode.Method.Expire, snode, params, publicKey) + } + } + } + + private fun buildAlterTtlParams( // TODO: in future this will probably need to use the closed group subkeys / admin keys for group swarms + messageHashes: List, + newExpiry: Long, + publicKey: String, + extend: Boolean = false, + shorten: Boolean = false): Map? { + val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + val params = mutableMapOf( + "expiry" to newExpiry, + "messages" to messageHashes, + ) + if (extend) { + params["extend"] = true + } else if (shorten) { + params["shorten"] = true + } + val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" + + val signData = "${Snode.Method.Expire.rawValue}$shortenOrExtend$newExpiry${messageHashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey"] = publicKey + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + + return params } fun getMessages(publicKey: String): MessageListPromise { @@ -483,13 +741,14 @@ object SnodeAPI { retryIfNeeded(maxRetryCount) { getNetworkTime(snode).bind { (_, timestamp) -> val signature = ByteArray(Sign.BYTES) - val verificationData = (Snode.Method.DeleteAll.rawValue + timestamp.toString()).toByteArray() + val verificationData = (Snode.Method.DeleteAll.rawValue + Namespace.ALL + timestamp.toString()).toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) val deleteMessageParams = mapOf( "pubkey" to userPublicKey, "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, "timestamp" to timestamp, - "signature" to Base64.encodeBytes(signature) + "signature" to Base64.encodeBytes(signature), + "namespace" to Namespace.ALL, ) invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) @@ -502,11 +761,13 @@ object SnodeAPI { } } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0): List> { + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { - updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - val newRawMessages = removeDuplicates(publicKey, messages, namespace) + if (updateLatestHash) { + updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) + } + val newRawMessages = removeDuplicates(publicKey, messages, namespace, updateStoredHashes) return parseEnvelopes(newRawMessages) } else { listOf() @@ -523,7 +784,7 @@ object SnodeAPI { } } - private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int): List<*> { + private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> @@ -538,7 +799,7 @@ object SnodeAPI { false } } - if (originalMessageHashValues != receivedMessageHashValues) { + if (originalMessageHashValues != receivedMessageHashValues && updateStoredHashes) { database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) } return result @@ -575,11 +836,11 @@ object SnodeAPI { Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { - val hashes = json["deleted"] as List // Hashes of deleted messages + val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = (userPublicKey + timestamp.toString() + hashes.fold("") { a, v -> a + v }).toByteArray() + val message = (userPublicKey + timestamp.toString() + hashes.joinToString(separator = "")).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } } @@ -635,6 +896,10 @@ object SnodeAPI { Log.d("Loki", "Got a 421 without an associated public key.") } } + 404 -> { + Log.d("Loki", "404, probably no file found") + return Error.Generic + } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/libsession/src/main/java/org/session/libsession/utilities/Address.kt index 7b774602e1..c8cd11d4b6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -5,11 +5,11 @@ import android.os.Parcel import android.os.Parcelable import android.util.Pair import androidx.annotation.VisibleForTesting -import org.session.libsession.utilities.DelimiterUtil -import org.session.libsession.utilities.GroupUtil -import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Util -import java.util.* +import org.session.libsignal.utilities.guava.Optional +import java.util.Collections +import java.util.LinkedList import java.util.concurrent.atomic.AtomicReference import java.util.regex.Matcher import java.util.regex.Pattern @@ -27,6 +27,8 @@ class Address private constructor(address: String) : Parcelable, Comparable + fun persist(forConfigObject: ConfigBase, timestamp: Long) + + fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean + fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean +} + +interface ConfigFactoryUpdateListener { + fun notifyUpdates(forConfigObject: ConfigBase) +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt index b850baa253..27b6b244ba 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -4,7 +4,9 @@ import okhttp3.HttpUrl import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import java.io.* +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream object DownloadUtilities { @@ -14,7 +16,7 @@ object DownloadUtilities { @JvmStatic fun downloadFile(destination: File, url: String) { val outputStream = FileOutputStream(destination) // Throws - var remainingAttempts = 4 + var remainingAttempts = 2 var exception: Exception? = null while (remainingAttempts > 0) { remainingAttempts -= 1 diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt index 3458e06eb6..bfab2585de 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities +import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.Hex import java.io.IOException -import kotlin.jvm.Throws object GroupUtil { const val CLOSED_GROUP_PREFIX = "__textsecure_group__!" @@ -97,4 +97,28 @@ object GroupUtil { fun doubleDecodeGroupID(groupID: String): ByteArray { return getDecodedGroupIDAsData(getDecodedGroupID(groupID)) } + + @JvmStatic + @Throws(IOException::class) + fun doubleDecodeGroupId(groupID: String): String { + return Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) + } + + fun createConfigMemberMap( + members: Collection, + admins: Collection + ): Map { + // Start with admins + val memberMap = admins.associate { + it to true + }.toMutableMap() + + // Add the remaining members (there may be duplicates, so only add ones that aren't already in there from admins) + for (member in members) { + if (!memberMap.contains(member)) { + memberMap[member] = false + } + } + return memberMap + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java index 9e3842fc67..4550965ae7 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java @@ -1,23 +1,24 @@ package org.session.libsession.utilities; import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsignal.utilities.Base64; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; import java.io.IOException; public class ProfileKeyUtil { + public static final int PROFILE_KEY_BYTES = 32; + public static synchronized @NonNull byte[] getProfileKey(@NonNull Context context) { try { String encodedProfileKey = TextSecurePreferences.getProfileKey(context); if (encodedProfileKey == null) { - encodedProfileKey = Util.getSecret(32); + encodedProfileKey = Util.getSecret(PROFILE_KEY_BYTES); TextSecurePreferences.setProfileKey(context, encodedProfileKey); } @@ -36,7 +37,7 @@ public class ProfileKeyUtil { } public static synchronized @NonNull String generateEncodedProfileKey(@NonNull Context context) { - return Util.getSecret(32); + return Util.getSecret(PROFILE_KEY_BYTES); } public static synchronized void setEncodedProfileKey(@NonNull Context context, @Nullable String key) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt index b750b39404..f647cc0f48 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities import android.content.Context +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient class SSKEnvironment( @@ -30,10 +30,10 @@ class SSKEnvironment( } fun setNickname(context: Context, recipient: Recipient, nickname: String?) - fun setName(context: Context, recipient: Recipient, name: String) - fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) - fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) + fun setName(context: Context, recipient: Recipient, name: String?) + fun setProfilePicture(context: Context, recipient: Recipient, profilePictureURL: String?, profileKey: ByteArray?) fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) + fun contactUpdatedInternal(contact: Contact): String? } interface MessageExpirationManagerProtocol { diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 807c40b43b..d6ed963735 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -12,7 +12,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.session.libsession.BuildConfig import org.session.libsession.R import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED @@ -103,6 +102,8 @@ interface TextSecurePreferences { fun setUpdateApkDigest(value: String?) fun getUpdateApkDigest(): String? fun getLocalNumber(): String? + fun getHasLegacyConfig(): Boolean + fun setHasLegacyConfig(newValue: Boolean) fun setLocalNumber(localNumber: String) fun removeLocalNumber() fun isEnterSendsEnabled(): Boolean @@ -178,6 +179,7 @@ interface TextSecurePreferences { fun setThemeStyle(themeStyle: String) fun setFollowSystemSettings(followSystemSettings: Boolean) fun autoplayAudioMessages(): Boolean + fun hasForcedNewConfig(): Boolean fun hasPreference(key: String): Boolean fun clearAll() @@ -264,6 +266,10 @@ interface TextSecurePreferences { const val AUTOPLAY_AUDIO_MESSAGES = "pref_autoplay_audio" const val FINGERPRINT_KEY_GENERATED = "fingerprint_key_generated" const val SELECTED_ACCENT_COLOR = "selected_accent_color" + + const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" + const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" + const val GREEN_ACCENT = "accent_green" const val BLUE_ACCENT = "accent_blue" const val PURPLE_ACCENT = "accent_purple" @@ -625,6 +631,17 @@ interface TextSecurePreferences { return getStringPreference(context, LOCAL_NUMBER_PREF, null) } + @JvmStatic + fun getHasLegacyConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, false) + } + + @JvmStatic + fun setHasLegacyConfig(context: Context, newValue: Boolean) { + setBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, newValue) + _events.tryEmit(HAS_RECEIVED_LEGACY_CONFIG) + } + fun setLocalNumber(context: Context, localNumber: String) { setStringPreference(context, LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -795,6 +812,11 @@ interface TextSecurePreferences { setIntegerPreference(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + @JvmStatic + fun hasForcedNewConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_FORCED_NEW_CONFIG, false) + } + @JvmStatic fun getBooleanPreference(context: Context, key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) @@ -1279,6 +1301,15 @@ class AppTextSecurePreferences @Inject constructor( return getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null) } + override fun getHasLegacyConfig(): Boolean { + return getBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, false) + } + + override fun setHasLegacyConfig(newValue: Boolean) { + setBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, newValue) + TextSecurePreferences._events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) + } + override fun setLocalNumber(localNumber: String) { setStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -1422,6 +1453,9 @@ class AppTextSecurePreferences @Inject constructor( setIntegerPreference(TextSecurePreferences.NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + override fun hasForcedNewConfig(): Boolean = + getBooleanPreference(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, false) + override fun getBooleanPreference(key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index a7fa75dd2b..e2d193a934 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -99,6 +99,7 @@ public class Recipient implements RecipientModifiedListener { private boolean profileSharing; private String notificationChannel; private boolean forceSmsSelection; + private String wrapperHash; private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; @@ -279,6 +280,7 @@ public class Recipient implements RecipientModifiedListener { this.profileSharing = details.profileSharing; this.unidentifiedAccessMode = details.unidentifiedAccessMode; this.forceSmsSelection = details.forceSmsSelection; + this.wrapperHash = details.wrapperHash; this.participants.addAll(details.participants); this.resolving = false; @@ -325,7 +327,7 @@ public class Recipient implements RecipientModifiedListener { return contact.displayName(Contact.ContactContext.REGULAR); } else { Contact contact = storage.getContactWithSessionID(sessionID); - if (contact == null) { return sessionID; } + if (contact == null) { return null; } return contact.displayName(Contact.ContactContext.REGULAR); } } @@ -440,6 +442,10 @@ public class Recipient implements RecipientModifiedListener { return address.isOpenGroup(); } + public boolean isOpenGroupOutboxRecipient() { + return address.isOpenGroupOutbox(); + } + public boolean isOpenGroupInboxRecipient() { return address.isOpenGroupInbox(); } @@ -483,7 +489,13 @@ public class Recipient implements RecipientModifiedListener { public synchronized String toShortString() { String name = getName(); - return (name != null ? name : address.serialize()); + if (name != null) return name; + String sessionId = address.serialize(); + if (sessionId.length() < 4) return sessionId; // so substrings don't throw out of bounds exceptions + int takeAmount = 4; + String start = sessionId.substring(0, takeAmount); + String end = sessionId.substring(sessionId.length()-takeAmount); + return start+"..."+end; } public synchronized @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { @@ -717,6 +729,14 @@ public class Recipient implements RecipientModifiedListener { return unidentifiedAccessMode; } + public String getWrapperHash() { + return wrapperHash; + } + + public void setWrapperHash(String wrapperHash) { + this.wrapperHash = wrapperHash; + } + public void setUnidentifiedAccessMode(@NonNull UnidentifiedAccessMode unidentifiedAccessMode) { synchronized (this) { this.unidentifiedAccessMode = unidentifiedAccessMode; @@ -739,12 +759,12 @@ public class Recipient implements RecipientModifiedListener { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Recipient recipient = (Recipient) o; - return resolving == recipient.resolving && mutedUntil == recipient.mutedUntil && notifyType == recipient.notifyType && blocked == recipient.blocked && approved == recipient.approved && approvedMe == recipient.approvedMe && expireMessages == recipient.expireMessages && address.equals(recipient.address) && Objects.equals(name, recipient.name) && Objects.equals(customLabel, recipient.customLabel) && Objects.equals(groupAvatarId, recipient.groupAvatarId) && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar); + return resolving == recipient.resolving && mutedUntil == recipient.mutedUntil && notifyType == recipient.notifyType && blocked == recipient.blocked && approved == recipient.approved && approvedMe == recipient.approvedMe && expireMessages == recipient.expireMessages && address.equals(recipient.address) && Objects.equals(name, recipient.name) && Objects.equals(customLabel, recipient.customLabel) && Objects.equals(groupAvatarId, recipient.groupAvatarId) && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar) && Objects.equals(wrapperHash, recipient.wrapperHash); } @Override public int hashCode() { - int result = Objects.hash(address, name, customLabel, resolving, groupAvatarId, mutedUntil, notifyType, blocked, approved, approvedMe, expireMessages, profileName, profileAvatar); + int result = Objects.hash(address, name, customLabel, resolving, groupAvatarId, mutedUntil, notifyType, blocked, approved, approvedMe, expireMessages, profileName, profileAvatar, wrapperHash); result = 31 * result + Arrays.hashCode(profileKey); return result; } @@ -848,6 +868,7 @@ public class Recipient implements RecipientModifiedListener { private final String notificationChannel; private final UnidentifiedAccessMode unidentifiedAccessMode; private final boolean forceSmsSelection; + private final String wrapperHash; public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil, int notifyType, @@ -869,7 +890,8 @@ public class Recipient implements RecipientModifiedListener { boolean profileSharing, @Nullable String notificationChannel, @NonNull UnidentifiedAccessMode unidentifiedAccessMode, - boolean forceSmsSelection) + boolean forceSmsSelection, + String wrapperHash) { this.blocked = blocked; this.approved = approved; @@ -895,6 +917,7 @@ public class Recipient implements RecipientModifiedListener { this.notificationChannel = notificationChannel; this.unidentifiedAccessMode = unidentifiedAccessMode; this.forceSmsSelection = forceSmsSelection; + this.wrapperHash = wrapperHash; } public @Nullable MaterialColor getColor() { @@ -992,6 +1015,11 @@ public class Recipient implements RecipientModifiedListener { public boolean isForceSmsSelection() { return forceSmsSelection; } + + public String getWrapperHash() { + return wrapperHash; + } + } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index 03c225e207..75ebd837b6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -177,6 +177,7 @@ class RecipientProvider { @Nullable final String notificationChannel; @NonNull final UnidentifiedAccessMode unidentifiedAccessMode; final boolean forceSmsSelection; + final String wrapperHash; RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, boolean systemContact, boolean isLocalNumber, @Nullable RecipientSettings settings, @@ -209,6 +210,7 @@ class RecipientProvider { this.notificationChannel = settings != null ? settings.getNotificationChannel() : null; this.unidentifiedAccessMode = settings != null ? settings.getUnidentifiedAccessMode() : UnidentifiedAccessMode.DISABLED; this.forceSmsSelection = settings != null && settings.isForceSmsSelection(); + this.wrapperHash = settings != null ? settings.getWrapperHash() : null; if (name == null && settings != null) this.name = settings.getSystemDisplayName(); else this.name = name; diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 59e987c54f..64fab0950e 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -220,18 +220,6 @@ - - - - - - - - - - - - diff --git a/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt similarity index 98% rename from libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt rename to libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt index 38a244699d..64d1c21fb4 100644 --- a/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt +++ b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* -class OpenGroupUrlParserTest { +class CommunityUrlParserTest { @Test fun parseUrlTest() { diff --git a/libsignal/protobuf/SignalService.proto b/libsignal/protobuf/SignalService.proto index 50c5218334..68dd35ce61 100644 --- a/libsignal/protobuf/SignalService.proto +++ b/libsignal/protobuf/SignalService.proto @@ -51,6 +51,7 @@ message Content { optional DataExtractionNotification dataExtractionNotification = 8; optional UnsendRequest unsendRequest = 9; optional MessageRequestResponse messageRequestResponse = 10; + optional SharedConfigMessage sharedConfigMessage = 11; } message KeyPair { @@ -238,6 +239,25 @@ message MessageRequestResponse { optional DataMessage.LokiProfile profile = 3; } +message SharedConfigMessage { + enum Kind { + USER_PROFILE = 1; + CONTACTS = 2; + CONVO_INFO_VOLATILE = 3; + GROUPS = 4; + CLOSED_GROUP_INFO = 5; + CLOSED_GROUP_MEMBERS = 6; + ENCRYPTION_KEYS = 7; + } + + // @required + required Kind kind = 1; + // @required + required int64 seqno = 2; + // @required + required bytes data = 3; +} + message ReceiptMessage { enum Type { diff --git a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java index 7c44087f83..8e26b05d92 100644 --- a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java +++ b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java @@ -2468,6 +2468,20 @@ public final class SignalServiceProtos { * optional .signalservice.MessageRequestResponse messageRequestResponse = 10; */ org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponseOrBuilder getMessageRequestResponseOrBuilder(); + + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + boolean hasSharedConfigMessage(); + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage(); + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder(); } /** * Protobuf type {@code signalservice.Content} @@ -2624,6 +2638,19 @@ public final class SignalServiceProtos { bitField0_ |= 0x00000080; break; } + case 90: { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder subBuilder = null; + if (((bitField0_ & 0x00000100) == 0x00000100)) { + subBuilder = sharedConfigMessage_.toBuilder(); + } + sharedConfigMessage_ = input.readMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.PARSER, extensionRegistry); + if (subBuilder != null) { + subBuilder.mergeFrom(sharedConfigMessage_); + sharedConfigMessage_ = subBuilder.buildPartial(); + } + bitField0_ |= 0x00000100; + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -2840,6 +2867,28 @@ public final class SignalServiceProtos { return messageRequestResponse_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + public static final int SHAREDCONFIGMESSAGE_FIELD_NUMBER = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + return sharedConfigMessage_; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + return sharedConfigMessage_; + } + private void initFields() { dataMessage_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.getDefaultInstance(); callMessage_ = org.session.libsignal.protos.SignalServiceProtos.CallMessage.getDefaultInstance(); @@ -2849,6 +2898,7 @@ public final class SignalServiceProtos { dataExtractionNotification_ = org.session.libsignal.protos.SignalServiceProtos.DataExtractionNotification.getDefaultInstance(); unsendRequest_ = org.session.libsignal.protos.SignalServiceProtos.UnsendRequest.getDefaultInstance(); messageRequestResponse_ = org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponse.getDefaultInstance(); + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -2903,6 +2953,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + memoizedIsInitialized = 0; + return false; + } + } memoizedIsInitialized = 1; return true; } @@ -2934,6 +2990,9 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000080) == 0x00000080)) { output.writeMessage(10, messageRequestResponse_); } + if (((bitField0_ & 0x00000100) == 0x00000100)) { + output.writeMessage(11, sharedConfigMessage_); + } getUnknownFields().writeTo(output); } @@ -2975,6 +3034,10 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeMessageSize(10, messageRequestResponse_); } + if (((bitField0_ & 0x00000100) == 0x00000100)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(11, sharedConfigMessage_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -3091,6 +3154,7 @@ public final class SignalServiceProtos { getDataExtractionNotificationFieldBuilder(); getUnsendRequestFieldBuilder(); getMessageRequestResponseFieldBuilder(); + getSharedConfigMessageFieldBuilder(); } } private static Builder create() { @@ -3147,6 +3211,12 @@ public final class SignalServiceProtos { messageRequestResponseBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000080); + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } else { + sharedConfigMessageBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000100); return this; } @@ -3239,6 +3309,14 @@ public final class SignalServiceProtos { } else { result.messageRequestResponse_ = messageRequestResponseBuilder_.build(); } + if (((from_bitField0_ & 0x00000100) == 0x00000100)) { + to_bitField0_ |= 0x00000100; + } + if (sharedConfigMessageBuilder_ == null) { + result.sharedConfigMessage_ = sharedConfigMessage_; + } else { + result.sharedConfigMessage_ = sharedConfigMessageBuilder_.build(); + } result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -3279,6 +3357,9 @@ public final class SignalServiceProtos { if (other.hasMessageRequestResponse()) { mergeMessageRequestResponse(other.getMessageRequestResponse()); } + if (other.hasSharedConfigMessage()) { + mergeSharedConfigMessage(other.getSharedConfigMessage()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -3332,6 +3413,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + + return false; + } + } return true; } @@ -4290,6 +4377,123 @@ public final class SignalServiceProtos { return messageRequestResponseBuilder_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> sharedConfigMessageBuilder_; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + return sharedConfigMessage_; + } else { + return sharedConfigMessageBuilder_.getMessage(); + } + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder setSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + sharedConfigMessage_ = value; + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder setSharedConfigMessage( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder builderForValue) { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = builderForValue.build(); + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder mergeSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (((bitField0_ & 0x00000100) == 0x00000100) && + sharedConfigMessage_ != org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) { + sharedConfigMessage_ = + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder(sharedConfigMessage_).mergeFrom(value).buildPartial(); + } else { + sharedConfigMessage_ = value; + } + onChanged(); + } else { + sharedConfigMessageBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder clearSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + onChanged(); + } else { + sharedConfigMessageBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000100); + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder getSharedConfigMessageBuilder() { + bitField0_ |= 0x00000100; + onChanged(); + return getSharedConfigMessageFieldBuilder().getBuilder(); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + if (sharedConfigMessageBuilder_ != null) { + return sharedConfigMessageBuilder_.getMessageOrBuilder(); + } else { + return sharedConfigMessage_; + } + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> + getSharedConfigMessageFieldBuilder() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessageBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder>( + sharedConfigMessage_, + getParentForChildren(), + isClean()); + sharedConfigMessage_ = null; + } + return sharedConfigMessageBuilder_; + } + // @@protoc_insertion_point(builder_scope:signalservice.Content) } @@ -22196,6 +22400,823 @@ public final class SignalServiceProtos { // @@protoc_insertion_point(class_scope:signalservice.MessageRequestResponse) } + public interface SharedConfigMessageOrBuilder + extends com.google.protobuf.MessageOrBuilder { + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + boolean hasKind(); + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind(); + + // required int64 seqno = 2; + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + boolean hasSeqno(); + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + long getSeqno(); + + // required bytes data = 3; + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + boolean hasData(); + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + com.google.protobuf.ByteString getData(); + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class SharedConfigMessage extends + com.google.protobuf.GeneratedMessage + implements SharedConfigMessageOrBuilder { + // Use SharedConfigMessage.newBuilder() to construct. + private SharedConfigMessage(com.google.protobuf.GeneratedMessage.Builder builder) { + super(builder); + this.unknownFields = builder.getUnknownFields(); + } + private SharedConfigMessage(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } + + private static final SharedConfigMessage defaultInstance; + public static SharedConfigMessage getDefaultInstance() { + return defaultInstance; + } + + public SharedConfigMessage getDefaultInstanceForType() { + return defaultInstance; + } + + private final com.google.protobuf.UnknownFieldSet unknownFields; + @java.lang.Override + public final com.google.protobuf.UnknownFieldSet + getUnknownFields() { + return this.unknownFields; + } + private SharedConfigMessage( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + initFields(); + int mutable_bitField0_ = 0; + com.google.protobuf.UnknownFieldSet.Builder unknownFields = + com.google.protobuf.UnknownFieldSet.newBuilder(); + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + default: { + if (!parseUnknownField(input, unknownFields, + extensionRegistry, tag)) { + done = true; + } + break; + } + case 8: { + int rawValue = input.readEnum(); + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.valueOf(rawValue); + if (value == null) { + unknownFields.mergeVarintField(1, rawValue); + } else { + bitField0_ |= 0x00000001; + kind_ = value; + } + break; + } + case 16: { + bitField0_ |= 0x00000002; + seqno_ = input.readInt64(); + break; + } + case 26: { + bitField0_ |= 0x00000004; + data_ = input.readBytes(); + break; + } + } + } + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(this); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException( + e.getMessage()).setUnfinishedMessage(this); + } finally { + this.unknownFields = unknownFields.build(); + makeExtensionsImmutable(); + } + } + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + public static com.google.protobuf.Parser PARSER = + new com.google.protobuf.AbstractParser() { + public SharedConfigMessage parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return new SharedConfigMessage(input, extensionRegistry); + } + }; + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + /** + * Protobuf enum {@code signalservice.SharedConfigMessage.Kind} + */ + public enum Kind + implements com.google.protobuf.ProtocolMessageEnum { + /** + * USER_PROFILE = 1; + */ + USER_PROFILE(0, 1), + /** + * CONTACTS = 2; + */ + CONTACTS(1, 2), + /** + * CONVO_INFO_VOLATILE = 3; + */ + CONVO_INFO_VOLATILE(2, 3), + /** + * GROUPS = 4; + */ + GROUPS(3, 4), + /** + * CLOSED_GROUP_INFO = 5; + */ + CLOSED_GROUP_INFO(4, 5), + /** + * CLOSED_GROUP_MEMBERS = 6; + */ + CLOSED_GROUP_MEMBERS(5, 6), + /** + * ENCRYPTION_KEYS = 7; + */ + ENCRYPTION_KEYS(6, 7), + ; + + /** + * USER_PROFILE = 1; + */ + public static final int USER_PROFILE_VALUE = 1; + /** + * CONTACTS = 2; + */ + public static final int CONTACTS_VALUE = 2; + /** + * CONVO_INFO_VOLATILE = 3; + */ + public static final int CONVO_INFO_VOLATILE_VALUE = 3; + /** + * GROUPS = 4; + */ + public static final int GROUPS_VALUE = 4; + /** + * CLOSED_GROUP_INFO = 5; + */ + public static final int CLOSED_GROUP_INFO_VALUE = 5; + /** + * CLOSED_GROUP_MEMBERS = 6; + */ + public static final int CLOSED_GROUP_MEMBERS_VALUE = 6; + /** + * ENCRYPTION_KEYS = 7; + */ + public static final int ENCRYPTION_KEYS_VALUE = 7; + + + public final int getNumber() { return value; } + + public static Kind valueOf(int value) { + switch (value) { + case 1: return USER_PROFILE; + case 2: return CONTACTS; + case 3: return CONVO_INFO_VOLATILE; + case 4: return GROUPS; + case 5: return CLOSED_GROUP_INFO; + case 6: return CLOSED_GROUP_MEMBERS; + case 7: return ENCRYPTION_KEYS; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static com.google.protobuf.Internal.EnumLiteMap + internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public Kind findValueByNumber(int number) { + return Kind.valueOf(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + return getDescriptor().getValues().get(index); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDescriptor().getEnumTypes().get(0); + } + + private static final Kind[] VALUES = values(); + + public static Kind valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + return VALUES[desc.getIndex()]; + } + + private final int index; + private final int value; + + private Kind(int index, int value) { + this.index = index; + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:signalservice.SharedConfigMessage.Kind) + } + + private int bitField0_; + // required .signalservice.SharedConfigMessage.Kind kind = 1; + public static final int KIND_FIELD_NUMBER = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + + // required int64 seqno = 2; + public static final int SEQNO_FIELD_NUMBER = 2; + private long seqno_; + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + public long getSeqno() { + return seqno_; + } + + // required bytes data = 3; + public static final int DATA_FIELD_NUMBER = 3; + private com.google.protobuf.ByteString data_; + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + public com.google.protobuf.ByteString getData() { + return data_; + } + + private void initFields() { + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + seqno_ = 0L; + data_ = com.google.protobuf.ByteString.EMPTY; + } + private byte memoizedIsInitialized = -1; + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized != -1) return isInitialized == 1; + + if (!hasKind()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasSeqno()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasData()) { + memoizedIsInitialized = 0; + return false; + } + memoizedIsInitialized = 1; + return true; + } + + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + getSerializedSize(); + if (((bitField0_ & 0x00000001) == 0x00000001)) { + output.writeEnum(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + output.writeInt64(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + output.writeBytes(3, data_); + } + getUnknownFields().writeTo(output); + } + + private int memoizedSerializedSize = -1; + public int getSerializedSize() { + int size = memoizedSerializedSize; + if (size != -1) return size; + + size = 0; + if (((bitField0_ & 0x00000001) == 0x00000001)) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(3, data_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSerializedSize = size; + return size; + } + + private static final long serialVersionUID = 0L; + @java.lang.Override + protected java.lang.Object writeReplace() + throws java.io.ObjectStreamException { + return super.writeReplace(); + } + + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + + public static Builder newBuilder() { return Builder.create(); } + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage prototype) { + return newBuilder().mergeFrom(prototype); + } + public Builder toBuilder() { return newBuilder(this); } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessage.Builder + implements org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + // Construct using org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { + } + } + private static Builder create() { + return new Builder(); + } + + public Builder clear() { + super.clear(); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + bitField0_ = (bitField0_ & ~0x00000001); + seqno_ = 0L; + bitField0_ = (bitField0_ & ~0x00000002); + data_ = com.google.protobuf.ByteString.EMPTY; + bitField0_ = (bitField0_ & ~0x00000004); + return this; + } + + public Builder clone() { + return create().mergeFrom(buildPartial()); + } + + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getDefaultInstanceForType() { + return org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage build() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage buildPartial() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = new org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage(this); + int from_bitField0_ = bitField0_; + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000001) == 0x00000001)) { + to_bitField0_ |= 0x00000001; + } + result.kind_ = kind_; + if (((from_bitField0_ & 0x00000002) == 0x00000002)) { + to_bitField0_ |= 0x00000002; + } + result.seqno_ = seqno_; + if (((from_bitField0_ & 0x00000004) == 0x00000004)) { + to_bitField0_ |= 0x00000004; + } + result.data_ = data_; + result.bitField0_ = to_bitField0_; + onBuilt(); + return result; + } + + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) { + return mergeFrom((org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage other) { + if (other == org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) return this; + if (other.hasKind()) { + setKind(other.getKind()); + } + if (other.hasSeqno()) { + setSeqno(other.getSeqno()); + } + if (other.hasData()) { + setData(other.getData()); + } + this.mergeUnknownFields(other.getUnknownFields()); + return this; + } + + public final boolean isInitialized() { + if (!hasKind()) { + + return false; + } + if (!hasSeqno()) { + + return false; + } + if (!hasData()) { + + return false; + } + return true; + } + + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parsedMessage = null; + try { + parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + parsedMessage = (org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) e.getUnfinishedMessage(); + throw e; + } finally { + if (parsedMessage != null) { + mergeFrom(parsedMessage); + } + } + return this; + } + private int bitField0_; + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public Builder setKind(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + kind_ = value; + onChanged(); + return this; + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public Builder clearKind() { + bitField0_ = (bitField0_ & ~0x00000001); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + onChanged(); + return this; + } + + // required int64 seqno = 2; + private long seqno_ ; + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public long getSeqno() { + return seqno_; + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public Builder setSeqno(long value) { + bitField0_ |= 0x00000002; + seqno_ = value; + onChanged(); + return this; + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public Builder clearSeqno() { + bitField0_ = (bitField0_ & ~0x00000002); + seqno_ = 0L; + onChanged(); + return this; + } + + // required bytes data = 3; + private com.google.protobuf.ByteString data_ = com.google.protobuf.ByteString.EMPTY; + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public com.google.protobuf.ByteString getData() { + return data_; + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public Builder setData(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000004; + data_ = value; + onChanged(); + return this; + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public Builder clearData() { + bitField0_ = (bitField0_ & ~0x00000004); + data_ = getDefaultInstance().getData(); + onChanged(); + return this; + } + + // @@protoc_insertion_point(builder_scope:signalservice.SharedConfigMessage) + } + + static { + defaultInstance = new SharedConfigMessage(true); + defaultInstance.initFields(); + } + + // @@protoc_insertion_point(class_scope:signalservice.SharedConfigMessage) + } + public interface ReceiptMessageOrBuilder extends com.google.protobuf.MessageOrBuilder { @@ -26081,6 +27102,11 @@ public final class SignalServiceProtos { private static com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_signalservice_MessageRequestResponse_fieldAccessorTable; + private static com.google.protobuf.Descriptors.Descriptor + internal_static_signalservice_SharedConfigMessage_descriptor; + private static + com.google.protobuf.GeneratedMessage.FieldAccessorTable + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable; private static com.google.protobuf.Descriptors.Descriptor internal_static_signalservice_ReceiptMessage_descriptor; private static @@ -26115,7 +27141,7 @@ public final class SignalServiceProtos { "\002(\004\0223\n\006action\030\002 \002(\0162#.signalservice.Typi" + "ngMessage.Action\"\"\n\006Action\022\013\n\007STARTED\020\000\022" + "\013\n\007STOPPED\020\001\"2\n\rUnsendRequest\022\021\n\ttimesta", - "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\345\003\n\007Content\022/\n\013" + + "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\246\004\n\007Content\022/\n\013" + "dataMessage\030\001 \001(\0132\032.signalservice.DataMe" + "ssage\022/\n\013callMessage\030\003 \001(\0132\032.signalservi" + "ce.CallMessage\0225\n\016receiptMessage\030\005 \001(\0132\035" + @@ -26127,96 +27153,104 @@ public final class SignalServiceProtos { "e.DataExtractionNotification\0223\n\runsendRe", "quest\030\t \001(\0132\034.signalservice.UnsendReques" + "t\022E\n\026messageRequestResponse\030\n \001(\0132%.sign" + - "alservice.MessageRequestResponse\"0\n\007KeyP" + - "air\022\021\n\tpublicKey\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002" + - "(\014\"\226\001\n\032DataExtractionNotification\022<\n\004typ" + - "e\030\001 \002(\0162..signalservice.DataExtractionNo" + - "tification.Type\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Ty" + - "pe\022\016\n\nSCREENSHOT\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013" + - "DataMessage\022\014\n\004body\030\001 \001(\t\0225\n\013attachments" + - "\030\002 \003(\0132 .signalservice.AttachmentPointer", - "\022*\n\005group\030\003 \001(\0132\033.signalservice.GroupCon" + - "text\022\r\n\005flags\030\004 \001(\r\022\023\n\013expireTimer\030\005 \001(\r" + - "\022\022\n\nprofileKey\030\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022" + - "/\n\005quote\030\010 \001(\0132 .signalservice.DataMessa" + - "ge.Quote\0223\n\007preview\030\n \003(\0132\".signalservic" + - "e.DataMessage.Preview\0225\n\010reaction\030\013 \001(\0132" + - "#.signalservice.DataMessage.Reaction\0227\n\007" + - "profile\030e \001(\0132&.signalservice.DataMessag" + - "e.LokiProfile\022K\n\023openGroupInvitation\030f \001" + - "(\0132..signalservice.DataMessage.OpenGroup", - "Invitation\022W\n\031closedGroupControlMessage\030" + - "h \001(\01324.signalservice.DataMessage.Closed" + - "GroupControlMessage\022\022\n\nsyncTarget\030i \001(\t\032" + - "\225\002\n\005Quote\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n" + - "\004text\030\003 \001(\t\022F\n\013attachments\030\004 \003(\01321.signa" + - "lservice.DataMessage.Quote.QuotedAttachm" + - "ent\032\231\001\n\020QuotedAttachment\022\023\n\013contentType\030" + - "\001 \001(\t\022\020\n\010fileName\030\002 \001(\t\0223\n\tthumbnail\030\003 \001" + - "(\0132 .signalservice.AttachmentPointer\022\r\n\005" + - "flags\030\004 \001(\r\"\032\n\005Flags\022\021\n\rVOICE_MESSAGE\020\001\032", - "V\n\007Preview\022\013\n\003url\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/" + - "\n\005image\030\003 \001(\0132 .signalservice.Attachment" + - "Pointer\032:\n\013LokiProfile\022\023\n\013displayName\030\001 " + - "\001(\t\022\026\n\016profilePicture\030\002 \001(\t\0320\n\023OpenGroup" + - "Invitation\022\013\n\003url\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003" + - "\n\031ClosedGroupControlMessage\022G\n\004type\030\001 \002(" + - "\01629.signalservice.DataMessage.ClosedGrou" + - "pControlMessage.Type\022\021\n\tpublicKey\030\002 \001(\014\022" + - "\014\n\004name\030\003 \001(\t\0221\n\021encryptionKeyPair\030\004 \001(\013" + - "2\026.signalservice.KeyPair\022\017\n\007members\030\005 \003(", - "\014\022\016\n\006admins\030\006 \003(\014\022U\n\010wrappers\030\007 \003(\0132C.si" + - "gnalservice.DataMessage.ClosedGroupContr" + - "olMessage.KeyPairWrapper\022\027\n\017expirationTi" + - "mer\030\010 \001(\r\032=\n\016KeyPairWrapper\022\021\n\tpublicKey" + - "\030\001 \002(\014\022\030\n\020encryptedKeyPair\030\002 \002(\014\"r\n\004Type" + - "\022\007\n\003NEW\020\001\022\027\n\023ENCRYPTION_KEY_PAIR\020\003\022\017\n\013NA" + - "ME_CHANGE\020\004\022\021\n\rMEMBERS_ADDED\020\005\022\023\n\017MEMBER" + - "S_REMOVED\020\006\022\017\n\013MEMBER_LEFT\020\007\032\222\001\n\010Reactio" + - "n\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003" + - " \001(\t\022:\n\006action\030\004 \002(\0162*.signalservice.Dat", - "aMessage.Reaction.Action\"\037\n\006Action\022\t\n\005RE" + - "ACT\020\000\022\n\n\006REMOVE\020\001\"$\n\005Flags\022\033\n\027EXPIRATION" + - "_TIMER_UPDATE\020\002\"\352\001\n\013CallMessage\022-\n\004type\030" + - "\001 \002(\0162\037.signalservice.CallMessage.Type\022\014" + - "\n\004sdps\030\002 \003(\t\022\027\n\017sdpMLineIndexes\030\003 \003(\r\022\017\n" + - "\007sdpMids\030\004 \003(\t\022\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\t" + - "PRE_OFFER\020\006\022\t\n\005OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PR" + - "OVISIONAL_ANSWER\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014" + - "\n\010END_CALL\020\005\"\245\004\n\024ConfigurationMessage\022E\n" + - "\014closedGroups\030\001 \003(\0132/.signalservice.Conf", - "igurationMessage.ClosedGroup\022\022\n\nopenGrou" + - "ps\030\002 \003(\t\022\023\n\013displayName\030\003 \001(\t\022\026\n\016profile" + - "Picture\030\004 \001(\t\022\022\n\nprofileKey\030\005 \001(\014\022=\n\010con" + - "tacts\030\006 \003(\0132+.signalservice.Configuratio" + - "nMessage.Contact\032\233\001\n\013ClosedGroup\022\021\n\tpubl" + - "icKey\030\001 \001(\014\022\014\n\004name\030\002 \001(\t\0221\n\021encryptionK" + - "eyPair\030\003 \001(\0132\026.signalservice.KeyPair\022\017\n\007" + - "members\030\004 \003(\014\022\016\n\006admins\030\005 \003(\014\022\027\n\017expirat" + - "ionTimer\030\006 \001(\r\032\223\001\n\007Contact\022\021\n\tpublicKey\030" + - "\001 \002(\014\022\014\n\004name\030\002 \002(\t\022\026\n\016profilePicture\030\003 ", - "\001(\t\022\022\n\nprofileKey\030\004 \001(\014\022\022\n\nisApproved\030\005 " + - "\001(\010\022\021\n\tisBlocked\030\006 \001(\010\022\024\n\014didApproveMe\030\007" + - " \001(\010\"y\n\026MessageRequestResponse\022\022\n\nisAppr" + - "oved\030\001 \002(\010\022\022\n\nprofileKey\030\002 \001(\014\0227\n\007profil" + - "e\030\003 \001(\0132&.signalservice.DataMessage.Loki" + - "Profile\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\0162" + - "\".signalservice.ReceiptMessage.Type\022\021\n\tt" + - "imestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n\004" + - "READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(\006" + - "\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004si", - "ze\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006 " + - "\001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n\005" + - "width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030\013" + - " \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MESS" + - "AGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004ty" + - "pe\030\002 \001(\0162 .signalservice.GroupContext.Ty" + - "pe\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006ava" + - "tar\030\005 \001(\0132 .signalservice.AttachmentPoin" + - "ter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020\000" + - "\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014R", - "EQUEST_INFO\020\004B3\n\034org.session.libsignal.p" + - "rotosB\023SignalServiceProtos" + "alservice.MessageRequestResponse\022?\n\023shar" + + "edConfigMessage\030\013 \001(\0132\".signalservice.Sh" + + "aredConfigMessage\"0\n\007KeyPair\022\021\n\tpublicKe" + + "y\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014\"\226\001\n\032DataExtr" + + "actionNotification\022<\n\004type\030\001 \002(\0162..signa" + + "lservice.DataExtractionNotification.Type" + + "\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type\022\016\n\nSCREENSHO" + + "T\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013DataMessage\022\014\n\004", + "body\030\001 \001(\t\0225\n\013attachments\030\002 \003(\0132 .signal" + + "service.AttachmentPointer\022*\n\005group\030\003 \001(\013" + + "2\033.signalservice.GroupContext\022\r\n\005flags\030\004" + + " \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022\n\nprofileKey\030" + + "\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n\005quote\030\010 \001(\0132" + + " .signalservice.DataMessage.Quote\0223\n\007pre" + + "view\030\n \003(\0132\".signalservice.DataMessage.P" + + "review\0225\n\010reaction\030\013 \001(\0132#.signalservice" + + ".DataMessage.Reaction\0227\n\007profile\030e \001(\0132&" + + ".signalservice.DataMessage.LokiProfile\022K", + "\n\023openGroupInvitation\030f \001(\0132..signalserv" + + "ice.DataMessage.OpenGroupInvitation\022W\n\031c" + + "losedGroupControlMessage\030h \001(\01324.signals" + + "ervice.DataMessage.ClosedGroupControlMes" + + "sage\022\022\n\nsyncTarget\030i \001(\t\032\225\002\n\005Quote\022\n\n\002id" + + "\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004text\030\003 \001(\t\022F\n\013" + + "attachments\030\004 \003(\01321.signalservice.DataMe" + + "ssage.Quote.QuotedAttachment\032\231\001\n\020QuotedA" + + "ttachment\022\023\n\013contentType\030\001 \001(\t\022\020\n\010fileNa" + + "me\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\0132 .signalserv", + "ice.AttachmentPointer\022\r\n\005flags\030\004 \001(\r\"\032\n\005" + + "Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n\007Preview\022\013\n\003u" + + "rl\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005image\030\003 \001(\0132 " + + ".signalservice.AttachmentPointer\032:\n\013Loki" + + "Profile\022\023\n\013displayName\030\001 \001(\t\022\026\n\016profileP" + + "icture\030\002 \001(\t\0320\n\023OpenGroupInvitation\022\013\n\003u" + + "rl\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031ClosedGroupCo" + + "ntrolMessage\022G\n\004type\030\001 \002(\01629.signalservi" + + "ce.DataMessage.ClosedGroupControlMessage" + + ".Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n\004name\030\003 \001(\t\0221", + "\n\021encryptionKeyPair\030\004 \001(\0132\026.signalservic" + + "e.KeyPair\022\017\n\007members\030\005 \003(\014\022\016\n\006admins\030\006 \003" + + "(\014\022U\n\010wrappers\030\007 \003(\0132C.signalservice.Dat" + + "aMessage.ClosedGroupControlMessage.KeyPa" + + "irWrapper\022\027\n\017expirationTimer\030\010 \001(\r\032=\n\016Ke" + + "yPairWrapper\022\021\n\tpublicKey\030\001 \002(\014\022\030\n\020encry" + + "ptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007\n\003NEW\020\001\022\027\n\023EN" + + "CRYPTION_KEY_PAIR\020\003\022\017\n\013NAME_CHANGE\020\004\022\021\n\r" + + "MEMBERS_ADDED\020\005\022\023\n\017MEMBERS_REMOVED\020\006\022\017\n\013" + + "MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022\n\n\002id\030\001 \002(\004\022\016", + "\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001(\t\022:\n\006action\030" + + "\004 \002(\0162*.signalservice.DataMessage.Reacti" + + "on.Action\"\037\n\006Action\022\t\n\005REACT\020\000\022\n\n\006REMOVE" + + "\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_TIMER_UPDATE\020\002" + + "\"\352\001\n\013CallMessage\022-\n\004type\030\001 \002(\0162\037.signals" + + "ervice.CallMessage.Type\022\014\n\004sdps\030\002 \003(\t\022\027\n" + + "\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007sdpMids\030\004 \003(\t\022" + + "\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPRE_OFFER\020\006\022\t\n\005" + + "OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROVISIONAL_ANSWE" + + "R\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010END_CALL\020\005\"\245\004", + "\n\024ConfigurationMessage\022E\n\014closedGroups\030\001" + + " \003(\0132/.signalservice.ConfigurationMessag" + + "e.ClosedGroup\022\022\n\nopenGroups\030\002 \003(\t\022\023\n\013dis" + + "playName\030\003 \001(\t\022\026\n\016profilePicture\030\004 \001(\t\022\022" + + "\n\nprofileKey\030\005 \001(\014\022=\n\010contacts\030\006 \003(\0132+.s" + + "ignalservice.ConfigurationMessage.Contac" + + "t\032\233\001\n\013ClosedGroup\022\021\n\tpublicKey\030\001 \001(\014\022\014\n\004" + + "name\030\002 \001(\t\0221\n\021encryptionKeyPair\030\003 \001(\0132\026." + + "signalservice.KeyPair\022\017\n\007members\030\004 \003(\014\022\016" + + "\n\006admins\030\005 \003(\014\022\027\n\017expirationTimer\030\006 \001(\r\032", + "\223\001\n\007Contact\022\021\n\tpublicKey\030\001 \002(\014\022\014\n\004name\030\002" + + " \002(\t\022\026\n\016profilePicture\030\003 \001(\t\022\022\n\nprofileK" + + "ey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(\010\022\021\n\tisBlocke" + + "d\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001(\010\"y\n\026Message" + + "RequestResponse\022\022\n\nisApproved\030\001 \002(\010\022\022\n\np" + + "rofileKey\030\002 \001(\014\0227\n\007profile\030\003 \001(\0132&.signa" + + "lservice.DataMessage.LokiProfile\"\375\001\n\023Sha" + + "redConfigMessage\0225\n\004kind\030\001 \002(\0162\'.signals" + + "ervice.SharedConfigMessage.Kind\022\r\n\005seqno" + + "\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Kind\022\020\n\014USER_PR", + "OFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CONVO_INFO_VOLA" + + "TILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSED_GROUP_INFO\020" + + "\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006\022\023\n\017ENCRYPTIO" + + "N_KEYS\020\007\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\016" + + "2\".signalservice.ReceiptMessage.Type\022\021\n\t" + + "timestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n" + + "\004READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(" + + "\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004s" + + "ize\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006" + + " \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n", + "\005width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030" + + "\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MES" + + "SAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004t" + + "ype\030\002 \001(\0162 .signalservice.GroupContext.T" + + "ype\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006av" + + "atar\030\005 \001(\0132 .signalservice.AttachmentPoi" + + "nter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020" + + "\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014" + + "REQUEST_INFO\020\004B3\n\034org.session.libsignal." + + "protosB\023SignalServiceProtos" }; com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { @@ -26246,7 +27280,7 @@ public final class SignalServiceProtos { internal_static_signalservice_Content_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_Content_descriptor, - new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", }); + new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", }); internal_static_signalservice_KeyPair_descriptor = getDescriptor().getMessageTypes().get(4); internal_static_signalservice_KeyPair_fieldAccessorTable = new @@ -26343,20 +27377,26 @@ public final class SignalServiceProtos { com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_MessageRequestResponse_descriptor, new java.lang.String[] { "IsApproved", "ProfileKey", "Profile", }); - internal_static_signalservice_ReceiptMessage_descriptor = + internal_static_signalservice_SharedConfigMessage_descriptor = getDescriptor().getMessageTypes().get(10); + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_signalservice_SharedConfigMessage_descriptor, + new java.lang.String[] { "Kind", "Seqno", "Data", }); + internal_static_signalservice_ReceiptMessage_descriptor = + getDescriptor().getMessageTypes().get(11); internal_static_signalservice_ReceiptMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_ReceiptMessage_descriptor, new java.lang.String[] { "Type", "Timestamp", }); internal_static_signalservice_AttachmentPointer_descriptor = - getDescriptor().getMessageTypes().get(11); + getDescriptor().getMessageTypes().get(12); internal_static_signalservice_AttachmentPointer_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_AttachmentPointer_descriptor, new java.lang.String[] { "Id", "ContentType", "Key", "Size", "Thumbnail", "Digest", "FileName", "Flags", "Width", "Height", "Caption", "Url", }); internal_static_signalservice_GroupContext_descriptor = - getDescriptor().getMessageTypes().get(12); + getDescriptor().getMessageTypes().get(13); internal_static_signalservice_GroupContext_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_GroupContext_descriptor, diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt index 154b91ee20..26c62ba50d 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt @@ -1,12 +1,15 @@ package org.session.libsignal.utilities enum class IdPrefix(val value: String) { - STANDARD("05"), BLINDED("15"), UN_BLINDED("00"); + STANDARD("05"), BLINDED("15"), UN_BLINDED("00"), BLINDEDV2("25"); + + fun isBlinded() = value == BLINDED.value || value == BLINDEDV2.value companion object { fun fromValue(rawValue: String): IdPrefix? = when(rawValue.take(2)) { STANDARD.value -> STANDARD BLINDED.value -> BLINDED + BLINDEDV2.value -> BLINDEDV2 UN_BLINDED.value -> UN_BLINDED else -> null } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt index 1c635d9934..ba04e516aa 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt @@ -1,7 +1,7 @@ package org.session.libsignal.utilities object Namespace { + const val ALL = "all" const val DEFAULT = 0 const val UNAUTHENTICATED_CLOSED_GROUP = -10 - const val CONFIGURATION = 5 } \ No newline at end of file diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index cfbedb7338..28f8aeb03b 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -5,12 +5,16 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { public enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), - GetMessages("retrieve"), + Retrieve("retrieve"), SendMessage("store"), DeleteMessage("delete"), OxenDaemonRPCCall("oxend_request"), Info("info"), - DeleteAll("delete_all") + DeleteAll("delete_all"), + Batch("batch"), + Sequence("sequence"), + Expire("expire"), + GetExpiries("get_expiries") } data class KeySet(val ed25519Key: String, val x25519Key: String) diff --git a/settings.gradle b/settings.gradle index 3a42510472..7ab26e097c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ rootProject.name = "session-android" include ':app' include ':liblazysodium' include ':libsession' -include ':libsignal' \ No newline at end of file +include ':libsignal' +include ':libsession-util'