diff --git a/app/build.gradle b/app/build.gradle index ed28a9823..06fea0c67 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,18 +4,17 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.3' + classpath 'com.android.tools.build:gradle:4.2.2' classpath files('libs/gradle-witness.jar') classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" - classpath "com.google.gms:google-services:4.3.3" - classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1' + classpath "com.google.gms:google-services:4.3.10" + classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion" } } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' apply plugin: 'witness' apply plugin: 'kotlin-kapt' apply plugin: 'com.google.gms.google-services' @@ -32,16 +31,16 @@ dependencies { implementation 'com.google.android.material:material:1.2.1' implementation 'androidx.legacy:legacy-support-v13:1.0.0' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.preference:preference:1.1.1' + implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.legacy:legacy-preference-v14:1.0.0' implementation 'androidx.gridlayout:gridlayout:1.0.0' - implementation 'androidx.exifinterface:exifinterface:1.2.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.1' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' + implementation 'androidx.exifinterface:exifinterface:1.3.3' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" implementation 'androidx.activity:activity-ktx:1.2.2' implementation 'androidx.fragment:fragment-ktx:1.3.2' implementation "androidx.core:core-ktx:1.3.2" @@ -62,9 +61,9 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' implementation 'commons-net:commons-net:3.7.2' implementation 'com.github.chrisbanes:PhotoView:2.1.3' - implementation 'com.github.bumptech.glide:glide:4.11.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' - kapt 'com.github.bumptech.glide:compiler:4.11.0' + implementation "com.github.bumptech.glide:glide:$glideVersion" + annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" + kapt "com.github.bumptech.glide:compiler:$glideVersion" implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.pnikosis:materialish-progress:1.5' implementation 'org.greenrobot:eventbus:3.0.0' @@ -72,8 +71,8 @@ dependencies { implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0' implementation 'com.melnykov:floatingactionbutton:1.3.0' implementation 'com.google.zxing:android-integration:3.1.0' - implementation "com.google.dagger:hilt-android:2.38.1" - kapt "com.google.dagger:hilt-compiler:2.38.1" + implementation "com.google.dagger:hilt-android:$daggerVersion" + kapt "com.google.dagger:hilt-compiler:$daggerVersion" implementation 'mobi.upod:time-duration-picker:1.1.3' implementation 'com.google.zxing:core:3.2.1' implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { @@ -103,7 +102,7 @@ dependencies { } implementation project(":libsignal") implementation project(":libsession") - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "org.whispersystems:curve25519-java:$curve25519Version" implementation 'com.goterl:lazysodium-android:5.0.2@aar' implementation "net.java.dev.jna:jna:5.8.0@aar" @@ -111,7 +110,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2" + 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" @@ -122,12 +121,16 @@ dependencies { implementation "com.opencsv:opencsv:4.6" testImplementation 'junit:junit:4.12' testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation 'org.mockito:mockito-core:1.10.8' + 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:1.3.0' + testImplementation "androidx.arch.core:core-testing:2.1.0" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // Core library androidTestImplementation 'androidx.test:core:1.4.0' @@ -154,8 +157,8 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.4' } -def canonicalVersionCode = 246 -def canonicalVersionName = "1.11.15" +def canonicalVersionCode = 249 +def canonicalVersionName = "1.11.16" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -231,6 +234,12 @@ android { } } + sourceSets { + String sharedTestDir = 'src/sharedTest/java' + test.java.srcDirs += sharedTestDir + androidTest.java.srcDirs += sharedTestDir + } + buildTypes { release { minifyEnabled false @@ -279,6 +288,7 @@ android { buildFeatures { dataBinding true + viewBinding true } } diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 68f81f6f8..deab87dd6 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="network.loki.messenger.test"> diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 46db01b13..e59b49669 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -15,6 +15,7 @@ import androidx.test.platform.app.InstrumentationRegistry import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.NewConversationButtonDrawableMatcher.Companion.newConversationButtonWithDrawable import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -73,7 +74,7 @@ class HomeActivityTests { onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click()) onView(withId(R.id.copyButton)).perform(ViewActions.click()) pressBack() - onView(withId(R.id.seedReminderView)).check(matches(withEffectiveVisibility(Visibility.GONE))) + onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } @Test @@ -85,7 +86,7 @@ class HomeActivityTests { @Test fun testIsVisible_alreadyDismissed_seedView() { setupLoggedInState(hasViewedSeed = true) - onView(withId(R.id.seedReminderView)).check(doesNotExist()) + onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } @Test diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index e28b41988..b21c7dac8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -27,6 +27,8 @@ import android.os.Build; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.view.GestureDetector; import android.view.LayoutInflater; import android.view.Menu; @@ -36,6 +38,8 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; @@ -92,6 +96,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im private final static String TAG = MediaPreviewActivity.class.getSimpleName(); + private static final int UI_ANIMATION_DELAY = 300; + public static final String ADDRESS_EXTRA = "address"; public static final String DATE_EXTRA = "date"; public static final String SIZE_EXTRA = "size"; @@ -99,6 +105,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im public static final String OUTGOING_EXTRA = "outgoing"; public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent"; + private View rootContainer; private ViewPager mediaPager; private View detailsContainer; private TextView caption; @@ -118,6 +125,26 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im private int restartItem = -1; + private boolean isFullscreen = false; + private final Handler hideHandler = new Handler(Looper.myLooper()); + private final Runnable showRunnable = () -> { + getSupportActionBar().show(); + }; + private final Runnable hideRunnable = () -> { + if (VERSION.SDK_INT >= 30) { + rootContainer.getWindowInsetsController().hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); + rootContainer.getWindowInsetsController().setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } else { + rootContainer.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LOW_PROFILE | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + } + }; + public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) { Intent previewIntent = null; if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { @@ -147,6 +174,32 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im initializeObservers(); } + private void toggleFullscreen() { + if (isFullscreen) { + exitFullscreen(); + } else { + enterFullscreen(); + } + } + + private void enterFullscreen() { + getSupportActionBar().hide(); + isFullscreen = true; + hideHandler.removeCallbacks(showRunnable); + hideHandler.postDelayed(hideRunnable, UI_ANIMATION_DELAY); + } + + private void exitFullscreen() { + if (Build.VERSION.SDK_INT >= 30) { + rootContainer.getWindowInsetsController().show(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); + } else { + rootContainer.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + } + isFullscreen = false; + hideHandler.removeCallbacks(hideRunnable); + hideHandler.postDelayed(showRunnable, UI_ANIMATION_DELAY); + } + @Override public boolean dispatchTouchEvent(MotionEvent ev) { clickDetector.onTouchEvent(ev); @@ -223,6 +276,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } private void initializeViews() { + rootContainer = findViewById(R.id.media_preview_root); mediaPager = findViewById(R.id.media_pager); mediaPager.setOffscreenPageLimit(1); @@ -295,12 +349,13 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } }); - clickDetector = new GestureDetector(new GestureDetector.SimpleOnGestureListener() { + clickDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapUp(MotionEvent e) { if (e.getY() < detailsContainer.getTop()) { detailsContainer.setVisibility(detailsContainer.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); } + toggleFullscreen(); return super.onSingleTapUp(e); } }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java index ff3b0b809..699e9ba97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java @@ -35,12 +35,10 @@ public class AudioCodec { public AudioCodec() throws IOException { this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); - this.audioRecord = createAudioRecord(this.bufferSize); this.mediaCodec = createMediaCodec(this.bufferSize); - - this.mediaCodec.start(); - try { + this.audioRecord = createAudioRecord(this.bufferSize); + this.mediaCodec.start(); audioRecord.startRecording(); } catch (Exception e) { Log.w(TAG, e); @@ -167,7 +165,7 @@ public class AudioCodec { return adtsHeader; } - private AudioRecord createAudioRecord(int bufferSize) { + private AudioRecord createAudioRecord(int bufferSize) throws SecurityException { return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt index 34273e565..df36719db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt @@ -7,13 +7,14 @@ import android.graphics.Path import android.util.AttributeSet import android.view.LayoutInflater import android.widget.RelativeLayout -import kotlinx.android.synthetic.main.view_separator.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewSeparatorBinding import org.thoughtcrime.securesms.util.toPx import org.session.libsession.utilities.ThemeUtil class LabeledSeparatorView : RelativeLayout { + private lateinit var binding: ViewSeparatorBinding private val path = Path() private val paint: Paint by lazy { @@ -43,10 +44,9 @@ class LabeledSeparatorView : RelativeLayout { } private fun setUpViewHierarchy() { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_separator, null) + binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context)) val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - addView(contentView, layoutParams) + addView(binding.root, layoutParams) setWillNotDraw(false) } // endregion @@ -59,9 +59,9 @@ class LabeledSeparatorView : RelativeLayout { val hMargin = toPx(16, resources).toFloat() path.reset() path.moveTo(0.0f, h / 2) - path.lineTo(titleTextView.left - hMargin, h / 2) - path.addRoundRect(titleTextView.left - hMargin, toPx(1, resources).toFloat(), titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW) - path.moveTo(titleTextView.right + hMargin, h / 2) + path.lineTo(binding.titleTextView.left - hMargin, h / 2) + path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW) + path.moveTo(binding.titleTextView.right + hMargin, h / 2) path.lineTo(w, h / 2) path.close() c.drawPath(path, paint) 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 59dfd6c96..df8544a39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -8,8 +8,8 @@ import android.widget.ImageView import android.widget.RelativeLayout import androidx.annotation.DimenRes import com.bumptech.glide.load.engine.DiskCacheStrategy -import kotlinx.android.synthetic.main.view_profile_picture.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewProfilePictureBinding import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator class ProfilePictureView : RelativeLayout { + private lateinit var binding: ViewProfilePictureBinding lateinit var glide: GlideRequests var publicKey: String? = null var displayName: String? = null @@ -35,14 +36,12 @@ class ProfilePictureView : RelativeLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { initialize() } private fun initialize() { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_profile_picture, null) - addView(contentView) + binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this, true) } // endregion // region Updating - fun update(recipient: Recipient, threadID: Long) { + fun update(recipient: Recipient) { fun getUserDisplayName(publicKey: String): String { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey @@ -75,27 +74,27 @@ class ProfilePictureView : RelativeLayout { val publicKey = publicKey ?: return val additionalPublicKey = additionalPublicKey if (additionalPublicKey != null) { - setProfilePictureIfNeeded(doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size) - setProfilePictureIfNeeded(doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size) - doubleModeImageViewContainer.visibility = View.VISIBLE + setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size) + setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size) + binding.doubleModeImageViewContainer.visibility = View.VISIBLE } else { - glide.clear(doubleModeImageView1) - glide.clear(doubleModeImageView2) - doubleModeImageViewContainer.visibility = View.INVISIBLE + glide.clear(binding.doubleModeImageView1) + glide.clear(binding.doubleModeImageView2) + binding.doubleModeImageViewContainer.visibility = View.INVISIBLE } if (additionalPublicKey == null && !isLarge) { - setProfilePictureIfNeeded(singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size) - singleModeImageView.visibility = View.VISIBLE + setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size) + binding.singleModeImageView.visibility = View.VISIBLE } else { - glide.clear(singleModeImageView) - singleModeImageView.visibility = View.INVISIBLE + glide.clear(binding.singleModeImageView) + binding.singleModeImageView.visibility = View.INVISIBLE } if (additionalPublicKey == null && isLarge) { - setProfilePictureIfNeeded(largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size) - largeSingleModeImageView.visibility = View.VISIBLE + setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size) + binding.largeSingleModeImageView.visibility = View.VISIBLE } else { - glide.clear(largeSingleModeImageView) - largeSingleModeImageView.visibility = View.INVISIBLE + glide.clear(binding.largeSingleModeImageView) + binding.largeSingleModeImageView.visibility = View.INVISIBLE } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index ecd94cff4..d560247fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -66,25 +66,18 @@ public class ContactAccessor { public List getNumbersForThreadSearchFilter(Context context, String constraint) { LinkedList numberList = new LinkedList<>(); - GroupDatabase.Reader reader = null; GroupRecord record; - - try { - reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint); - + try (GroupDatabase.Reader reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint)) { while ((record = reader.getNext()) != null) { numberList.add(record.getEncodedId()); } - } finally { - if (reader != null) - reader.close(); } - if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && - !numberList.contains(TextSecurePreferences.getLocalNumber(context))) - { - numberList.add(TextSecurePreferences.getLocalNumber(context)); - } +// if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && +// !numberList.contains(TextSecurePreferences.getLocalNumber(context))) +// { +// numberList.add(TextSecurePreferences.getLocalNumber(context)); +// } return numberList; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt index 73be89270..d7a45c317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt @@ -1,15 +1,12 @@ package org.thoughtcrime.securesms.contacts import android.content.Context -import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import kotlinx.android.synthetic.main.contact_selection_list_divider.view.* -import network.loki.messenger.R -import org.thoughtcrime.securesms.contacts.UserView -import org.thoughtcrime.securesms.mms.GlideRequests +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.databinding.ContactSelectionListDividerBinding import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.mms.GlideRequests class ContactSelectionListAdapter(private val context: Context, private val multiSelect: Boolean) : RecyclerView.Adapter() { lateinit var glide: GlideRequests @@ -24,7 +21,15 @@ class ContactSelectionListAdapter(private val context: Context, private val mult } class UserViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) - class DividerViewHolder(val view: View) : RecyclerView.ViewHolder(view) + class DividerViewHolder( + private val binding: ContactSelectionListDividerBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: ContactSelectionListItem.Header) { + with(binding){ + label.text = item.name + } + } + } override fun getItemCount(): Int { return items.size @@ -41,8 +46,9 @@ class ContactSelectionListAdapter(private val context: Context, private val mult return if (viewType == ViewType.Contact) { UserViewHolder(UserView(context)) } else { - val view = LayoutInflater.from(context).inflate(R.layout.contact_selection_list_divider, parent, false) - DividerViewHolder(view) + DividerViewHolder( + ContactSelectionListDividerBinding.inflate(LayoutInflater.from(context), parent, false) + ) } } @@ -58,8 +64,7 @@ class ContactSelectionListAdapter(private val context: Context, private val mult if (multiSelect) UserView.ActionIndicator.Tick else UserView.ActionIndicator.None, isSelected) } else if (viewHolder is DividerViewHolder) { - item as ContactSelectionListItem.Header - viewHolder.view.label.text = item.name + viewHolder.bind(item as ContactSelectionListItem.Header) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt index b32e5a20b..24637c434 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt @@ -1,23 +1,21 @@ package org.thoughtcrime.securesms.contacts import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import androidx.recyclerview.widget.LinearLayoutManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import kotlinx.android.synthetic.main.contact_selection_list_fragment.* -import network.loki.messenger.R +import androidx.fragment.app.Fragment +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import network.loki.messenger.databinding.ContactSelectionListFragmentBinding +import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.mms.GlideApp -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.contacts.ContactSelectionListItem -import org.thoughtcrime.securesms.contacts.ContactSelectionListLoader class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks>, ContactClickListener { + private lateinit var binding: ContactSelectionListFragmentBinding private var cursorFilter: String? = null var onContactSelectedListener: OnContactSelectedListener? = null @@ -46,20 +44,21 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks> { @@ -107,8 +106,8 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks> { + private lateinit var binding: ActivitySelectContactsBinding private var members = listOf() set(value) { field = value; selectContactsAdapter.members = value } private lateinit var usersToExclude: Set @@ -36,18 +33,18 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - - setContentView(R.layout.activity_select_contacts) + binding = ActivitySelectContactsBinding.inflate(layoutInflater) + setContentView(binding.root) supportActionBar!!.title = resources.getString(R.string.activity_select_contacts_title) usersToExclude = intent.getStringArrayExtra(usersToExcludeKey)?.toSet() ?: setOf() val emptyStateText = intent.getStringExtra(emptyStateTextKey) if (emptyStateText != null) { - emptyStateMessageTextView.text = emptyStateText + binding.emptyStateMessageTextView.text = emptyStateText } - recyclerView.adapter = selectContactsAdapter - recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = selectContactsAdapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) LoaderManager.getInstance(this).initLoader(0, null, this) } @@ -73,8 +70,8 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana private fun update(members: List) { this.members = members - mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE - emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE + binding.mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE + binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE invalidateOptionsMenu() } // endregion 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 ebd8c93c6..7597be474 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -5,9 +5,8 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout -import kotlinx.android.synthetic.main.view_conversation.view.profilePictureView -import kotlinx.android.synthetic.main.view_user.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewUserBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities @@ -15,6 +14,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests class UserView : LinearLayout { + private lateinit var binding: ViewUserBinding var openGroupThreadID: Long = -1 // FIXME: This is a bit ugly enum class ActionIndicator { @@ -41,9 +41,7 @@ class UserView : LinearLayout { } private fun setUpViewHierarchy() { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_user, null) - addView(contentView) + binding = ViewUserBinding.inflate(LayoutInflater.from(context), this, true) } // endregion @@ -56,28 +54,32 @@ class UserView : LinearLayout { 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() - profilePictureView.glide = glide - profilePictureView.update(user, threadID) - actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) - nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) + binding.profilePictureView.glide = glide + binding.profilePictureView.update(user) + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) + binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { ActionIndicator.None -> { - actionIndicatorImageView.visibility = View.GONE + binding.actionIndicatorImageView.visibility = View.GONE } ActionIndicator.Menu -> { - actionIndicatorImageView.visibility = View.VISIBLE - actionIndicatorImageView.setImageResource(R.drawable.ic_more_horiz_white) + binding.actionIndicatorImageView.visibility = View.VISIBLE + binding.actionIndicatorImageView.setImageResource(R.drawable.ic_more_horiz_white) } ActionIndicator.Tick -> { - actionIndicatorImageView.visibility = View.VISIBLE - actionIndicatorImageView.setImageResource(if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle) + binding.actionIndicatorImageView.visibility = View.VISIBLE + binding.actionIndicatorImageView.setImageResource( + if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle + ) } } } fun toggleCheckbox(isSelected: Boolean = false) { - actionIndicatorImageView.visibility = View.VISIBLE - actionIndicatorImageView.setImageResource(if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle) + binding.actionIndicatorImageView.visibility = View.VISIBLE + binding.actionIndicatorImageView.setImageResource( + if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle + ) } fun unbind() { 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 267403ff1..fc181d79c 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,65 +3,65 @@ 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.text.TextUtils import android.util.Log 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.view.children import androidx.core.view.isVisible import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.lifecycleScope import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.activity_conversation_v2.* -import kotlinx.android.synthetic.main.activity_conversation_v2.view.* -import kotlinx.android.synthetic.main.activity_conversation_v2_action_bar.* -import kotlinx.android.synthetic.main.activity_home.* -import kotlinx.android.synthetic.main.view_conversation.view.* -import kotlinx.android.synthetic.main.view_input_bar.view.* -import kotlinx.android.synthetic.main.view_input_bar_recording.* -import kotlinx.android.synthetic.main.view_input_bar_recording.view.* -import kotlinx.android.synthetic.main.view_visible_message.view.* import network.loki.messenger.R -import nl.komponents.kovenant.ui.failUi +import network.loki.messenger.databinding.ActivityConversationV2ActionBarBinding +import network.loki.messenger.databinding.ActivityConversationV2Binding import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage -import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.snode.SnodeAPI 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.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask @@ -71,14 +71,14 @@ import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey -import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.audio.AudioRecorder -import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher -import org.thoughtcrime.securesms.conversation.v2.dialogs.* +import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog +import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog +import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate @@ -86,14 +86,27 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCand import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper -import org.thoughtcrime.securesms.conversation.v2.messages.* +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +import org.thoughtcrime.securesms.conversation.v2.messages.VoiceMessageViewDelegate 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.DraftDatabase.Drafts +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.SessionContactDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.giph.ui.GiphyActivity @@ -103,17 +116,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.notifications.MarkReadReceiver +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.util.* -import java.util.* +import org.thoughtcrime.securesms.util.ActivityDispatcher +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.toPx +import java.util.Locale import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set -import kotlin.math.* +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt // Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually // part of the conversation activity layout. This is just because it makes the layout a lot simpler. The @@ -124,23 +151,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener, SearchBottomBar.EventListener, VoiceMessageViewDelegate { + private lateinit var binding: ActivityConversationV2Binding + private lateinit var actionBarBinding: ActivityConversationV2ActionBarBinding + + @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDb: MmsSmsDatabase - @Inject lateinit var draftDb: DraftDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var groupDb: GroupDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase - @Inject lateinit var recipientDb: RecipientDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase - - + @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory private val screenWidth = Resources.getSystem().displayMetrics.widthPixels - private var linkPreviewViewModel: LinkPreviewViewModel? = null - private var threadID: Long = -1 + private val linkPreviewViewModel: LinkPreviewViewModel by lazy { + ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository(this))) + .get(LinkPreviewViewModel::class.java) + } + private val viewModel: ConversationViewModel by viewModels { + var threadId = intent.getLongExtra(THREAD_ID, -1L) + if (threadId == -1L) { + intent.getParcelableExtra
(ADDRESS)?.let { address -> + val recipient = Recipient.from(this, address, false) + threadId = threadDb.getOrCreateThreadIdFor(recipient) + } ?: finish() + } + viewModelFactory.create(threadId) + } private var actionMode: ActionMode? = null private var unreadCount = 0 // Attachments @@ -157,7 +197,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var currentMentionStartIndex = -1 private var isShowingMentionCandidatesView = false // Search - var searchViewModel: SearchViewModel? = null + val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null private val isScrolledToBottom: Boolean @@ -167,7 +207,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val layoutManager: LinearLayoutManager - get() { return conversationRecyclerView.layoutManager as LinearLayoutManager } + get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager } private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) @@ -181,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val adapter by lazy { - val cursor = mmsSmsDb.getConversation(threadID) + val cursor = mmsSmsDb.getConversation(viewModel.threadId) val adapter = ConversationAdapter( this, cursor, @@ -194,28 +234,33 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe onItemLongPress = { message, position -> handleLongPress(message, position) }, - glide + glide, + onDeselect = { message, position -> + actionMode?.let { + onDeselect(message, position, it) + } + } ) adapter.visibleMessageContentViewDelegate = this adapter } - private val thread by lazy { - threadDb.getRecipientForThreadId(threadID)!! - } - private val glide by lazy { GlideApp.with(this) } private val lockViewHitMargin by lazy { toPx(40, resources) } private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) } private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } 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) // region Settings companion object { // Extras const val THREAD_ID = "thread_id" const val ADDRESS = "address" + const val SCROLL_MESSAGE_ID = "scroll_message_id" + const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" // Request codes const val PICK_DOCUMENT = 2 const val TAKE_PHOTO = 7 @@ -231,14 +276,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_conversation_v2) - threadID = intent.getLongExtra(THREAD_ID, -1L) - if (threadID == -1L) { - val address = intent.getParcelableExtra
(ADDRESS) ?: return finish() - val recipient = Recipient.from(this, address, false) - threadID = threadDb.getOrCreateThreadIdFor(recipient) - } - val thread = threadDb.getRecipientForThreadId(threadID) + binding = ActivityConversationV2Binding.inflate(layoutInflater) + setContentView(binding.root) + // messageIdToScroll + messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) + messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) + val thread = threadDb.getRecipientForThreadId(viewModel.threadId) if (thread == null) { Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() return finish() @@ -248,28 +291,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpInputBar() setUpLinkPreviewObserver() restoreDraftIfNeeded() - addOpenGroupGuidelinesIfNeeded() - scrollToBottomButton.setOnClickListener { - val layoutManager = conversationRecyclerView.layoutManager ?: return@setOnClickListener + setUpUiStateObserver() + binding.scrollToBottomButton.setOnClickListener { + val layoutManager = binding.conversationRecyclerView.layoutManager ?: return@setOnClickListener if (layoutManager.isSmoothScrolling) { - conversationRecyclerView.scrollToPosition(0) + binding.conversationRecyclerView.scrollToPosition(0) } else { - conversationRecyclerView.smoothScrollToPosition(0) + binding.conversationRecyclerView.smoothScrollToPosition(0) } } - unreadCount = mmsSmsDb.getUnreadCount(threadID) + unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) updateUnreadCountIndicator() setUpTypingObserver() setUpRecipientObserver() updateSubtitle() getLatestOpenGroupInfoIfNeeded() setUpBlockedBanner() - searchBottomBar.setEventListener(this) + binding.searchBottomBar.setEventListener(this) setUpSearchResultObserver() scrollToFirstUnreadMessageIfNeeded() showOrHideInputIfNeeded() - if (this.thread.isOpenGroupRecipient) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) + if (viewModel.recipient.isOpenGroupRecipient) { + val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) if (openGroup == null) { Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() return finish() @@ -279,8 +322,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onResume() { super.onResume() - ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(threadID) - markAllAsRead() + ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) + threadDb.markAllAsRead(viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) } override fun onPause() { @@ -305,25 +348,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpRecyclerView() { - conversationRecyclerView.adapter = adapter + binding.conversationRecyclerView.adapter = adapter val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) - conversationRecyclerView.layoutManager = layoutManager + 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, object : LoaderManager.LoaderCallbacks { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(threadID, this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { adapter.changeCursor(cursor) + if (cursor != null) { + val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) + val author = messageToScrollAuthor.getAndSet(null) + if (author != null && messageTimestamp >= 0) { + jumpToMessage(author, messageTimestamp, null) + } + } } override fun onLoaderReset(cursor: Loader) { adapter.changeCursor(null) } }) - conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { handleRecyclerViewScrolled() @@ -333,42 +383,43 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun setUpToolBar() { val actionBar = supportActionBar!! - actionBar.setCustomView(R.layout.activity_conversation_v2_action_bar) + actionBarBinding = ActivityConversationV2ActionBarBinding.inflate(layoutInflater) + actionBar.title = "" + actionBar.customView = actionBarBinding.root actionBar.setDisplayShowCustomEnabled(true) - conversationTitleView.text = thread.toShortString() - @DimenRes val sizeID: Int - if (thread.isClosedGroupRecipient) { - sizeID = R.dimen.medium_profile_picture_size + actionBarBinding.conversationTitleView.text = viewModel.recipient.toShortString() + @DimenRes val sizeID: Int = if (viewModel.recipient.isClosedGroupRecipient) { + R.dimen.medium_profile_picture_size } else { - sizeID = R.dimen.small_profile_picture_size + R.dimen.small_profile_picture_size } val size = resources.getDimension(sizeID).roundToInt() - profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) - profilePictureView.glide = glide - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, this) - profilePictureView.update(thread, threadID) + actionBarBinding.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) + actionBarBinding.profilePictureView.glide = glide + MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) + actionBarBinding.profilePictureView.update(viewModel.recipient) } private fun setUpInputBar() { - inputBar.delegate = this - inputBarRecordingView.delegate = this + binding.inputBar.delegate = this + binding.inputBarRecordingView.delegate = this // GIF button - gifButtonContainer.addView(gifButton) + binding.gifButtonContainer.addView(gifButton) gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } gifButton.snIsEnabled = false // Document button - documentButtonContainer.addView(documentButton) + binding.documentButtonContainer.addView(documentButton) documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } documentButton.snIsEnabled = false // Library button - libraryButtonContainer.addView(libraryButton) + binding.libraryButtonContainer.addView(libraryButton) libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } libraryButton.snIsEnabled = false // Camera button - cameraButtonContainer.addView(cameraButton) + binding.cameraButtonContainer.addView(cameraButton) cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } cameraButton.snIsEnabled = false @@ -380,7 +431,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (mediaURI != null && mediaType != null) { if (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) { val media = Media(mediaURI, MediaUtil.getMimeType(this, mediaURI)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), thread, ""), ConversationActivityV2.PICK_FROM_LIBRARY) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient, ""), PICK_FROM_LIBRARY) return } else { prepMediaForSending(mediaURI, mediaType).addListener(object : ListenableFuture.Listener { @@ -397,98 +448,107 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } else if (intent.hasExtra(Intent.EXTRA_TEXT)) { val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "" - inputBar.text = dataTextExtra.toString() + binding.inputBar.text = dataTextExtra.toString() } else { - val drafts = draftDb.getDrafts(threadID) - draftDb.clearDrafts(threadID) - val text = drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value ?: return - inputBar.text = text + viewModel.getDraft()?.let { text -> + binding.inputBar.text = text + } } } - private fun addOpenGroupGuidelinesIfNeeded() { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return - val isOxenHostedOpenGroup = openGroup.room == "session" || openGroup.room == "oxen" - || openGroup.room == "lokinet" || openGroup.room == "crypto" + private fun addOpenGroupGuidelinesIfNeeded(isOxenHostedOpenGroup: Boolean) { if (!isOxenHostedOpenGroup) { return } - openGroupGuidelinesView.visibility = View.VISIBLE - val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams + binding.openGroupGuidelinesView.visibility = View.VISIBLE + val recyclerViewLayoutParams = binding.conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams recyclerViewLayoutParams.topMargin = toPx(57, resources) // The height of the open group guidelines view is hardcoded to this - conversationRecyclerView.layoutParams = recyclerViewLayoutParams + binding.conversationRecyclerView.layoutParams = recyclerViewLayoutParams } private fun setUpTypingObserver() { - ApplicationContext.getInstance(this).typingStatusRepository.getTypists(threadID).observe(this) { state -> + ApplicationContext.getInstance(this).typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> val recipients = if (state != null) state.typists else listOf() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up - typingIndicatorViewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom - typingIndicatorViewContainer.setTypists(recipients) - inputBarHeightChanged(inputBar.height) + binding.typingIndicatorViewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom + binding.typingIndicatorViewContainer.setTypists(recipients) + inputBarHeightChanged(binding.inputBar.height) } - if (TextSecurePreferences.isTypingIndicatorsEnabled(this)) { - inputBar.inputBarEditText.addTextChangedListener(object : SimpleTextWatcher() { + if (textSecurePreferences.isTypingIndicatorsEnabled()) { + binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() { override fun onTextChanged(text: String?) { - ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(threadID) + ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId) } }) } } private fun setUpRecipientObserver() { - thread.addListener(this) + viewModel.recipient.addListener(this) } private fun getLatestOpenGroupInfoIfNeeded() { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return + val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } } private fun setUpBlockedBanner() { - if (thread.isGroupRecipient) { return } - val contactDB = sessionContactDb - val sessionID = thread.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) + if (viewModel.recipient.isGroupRecipient) { return } + val sessionID = viewModel.recipient.address.toString() + val contact = sessionContactDb.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) - blockedBanner.isVisible = thread.isBlocked - blockedBanner.setOnClickListener { unblock() } + binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) + binding.blockedBanner.isVisible = viewModel.recipient.isBlocked + binding.blockedBanner.setOnClickListener { viewModel.unblock() } } private fun setUpLinkPreviewObserver() { - val linkPreviewViewModel = ViewModelProviders.of(this, LinkPreviewViewModel.Factory(LinkPreviewRepository(this)))[LinkPreviewViewModel::class.java] - this.linkPreviewViewModel = linkPreviewViewModel - if (!TextSecurePreferences.isLinkPreviewsEnabled(this)) { + if (!textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onUserCancel(); return } - linkPreviewViewModel.linkPreviewState.observe(this, { previewState: LinkPreviewState? -> + linkPreviewViewModel.linkPreviewState.observe(this) { previewState: LinkPreviewState? -> if (previewState == null) return@observe - if (previewState.isLoading) { - inputBar.draftLinkPreview() - } else if (previewState.linkPreview.isPresent) { - inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) - } else { - inputBar.cancelLinkPreviewDraft() + when { + previewState.isLoading -> { + binding.inputBar.draftLinkPreview() + } + previewState.linkPreview.isPresent -> { + binding.inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) + } + else -> { + binding.inputBar.cancelLinkPreviewDraft() + } } - }) + } + } + + private fun setUpUiStateObserver() { + lifecycleScope.launchWhenStarted { + viewModel.uiState.collect { uiState -> + uiState.uiMessages.firstOrNull()?.let { + Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show() + viewModel.messageShown(it.id) + } + addOpenGroupGuidelinesIfNeeded(uiState.isOxenHostedOpenGroup) + } + } } private fun scrollToFirstUnreadMessageIfNeeded() { - val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(threadID).first() + val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return if (lastSeenItemPosition <= 3) { return } - conversationRecyclerView.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition) } override fun onPrepareOptionsMenu(menu: Menu): Boolean { - ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, thread, threadID, this) { onOptionsItemSelected(it) } + ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, viewModel.recipient, viewModel.threadId, this) { onOptionsItemSelected(it) } super.onPrepareOptionsMenu(menu) return true } override fun onDestroy() { - saveDraft() + viewModel.saveDraft(binding.inputBar.text.trim()) super.onDestroy() } // endregion @@ -496,76 +556,41 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region Animation & Updating override fun onModified(recipient: Recipient) { runOnUiThread { - if (thread.isContactRecipient) { - blockedBanner.isVisible = thread.isBlocked + if (viewModel.recipient.isContactRecipient) { + binding.blockedBanner.isVisible = viewModel.recipient.isBlocked } updateSubtitle() showOrHideInputIfNeeded() - profilePictureView.update(recipient, threadID) + actionBarBinding.profilePictureView.update(recipient) } } private fun showOrHideInputIfNeeded() { - if (thread.isClosedGroupRecipient) { - val group = groupDb.getGroup(thread.address.toGroupString()).orNull() + if (viewModel.recipient.isClosedGroupRecipient) { + val group = groupDb.getGroup(viewModel.recipient.address.toGroupString()).orNull() val isActive = (group?.isActive == true) - inputBar.showInput = isActive + binding.inputBar.showInput = isActive } else { - inputBar.showInput = true + binding.inputBar.showInput = true } } - private fun markAllAsRead() { - val messages = threadDb.setRead(threadID, true) - if (thread.isGroupRecipient) { - for (message in messages) { - MarkReadReceiver.scheduleDeletion(this, message.expirationInfo) - } - } else { - MarkReadReceiver.process(this, messages) - } - ApplicationContext.getInstance(this).messageNotifier.updateNotification(this, false, 0) - } - override fun inputBarHeightChanged(newValue: Int) { - @Suppress("NAME_SHADOWING") val newValue = max(newValue, resources.getDimension(R.dimen.input_bar_height).roundToInt()) - // 36 DP is the exact height of the typing indicator view. It's also exactly 18 * 2, and 18 is the large message - // corner radius. This makes 36 DP look "correct" in the context of other messages on the screen. - val typingIndicatorHeight = if (typingIndicatorViewContainer.isVisible) toPx(36, resources) else 0 - // Recycler view - val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams - recyclerViewLayoutParams.bottomMargin = newValue + typingIndicatorHeight - conversationRecyclerView.layoutParams = recyclerViewLayoutParams - // Additional content container - val additionalContentContainerLayoutParams = additionalContentContainer.layoutParams as RelativeLayout.LayoutParams - additionalContentContainerLayoutParams.bottomMargin = newValue - additionalContentContainer.layoutParams = additionalContentContainerLayoutParams - // Attachment options - val attachmentButtonHeight = inputBar.attachmentsButtonContainer.height - val bottomMargin = (newValue - inputBar.additionalContentHeight - attachmentButtonHeight) / 2 - val margin = toPx(8, resources) - val attachmentOptionsContainerLayoutParams = attachmentOptionsContainer.layoutParams as RelativeLayout.LayoutParams - attachmentOptionsContainerLayoutParams.bottomMargin = bottomMargin + attachmentButtonHeight + margin - attachmentOptionsContainer.layoutParams = attachmentOptionsContainerLayoutParams - // Scroll to bottom button - val scrollToBottomButtonLayoutParams = scrollToBottomButton.layoutParams as RelativeLayout.LayoutParams - scrollToBottomButtonLayoutParams.bottomMargin = newValue + additionalContentContainer.height + toPx(12, resources) - scrollToBottomButton.layoutParams = scrollToBottomButtonLayoutParams } override fun inputBarEditTextContentChanged(newContent: CharSequence) { - if (TextSecurePreferences.isLinkPreviewsEnabled(this)) { - linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0) + if (textSecurePreferences.isLinkPreviewsEnabled()) { + linkPreviewViewModel.onTextChanged(this, binding.inputBar.text, 0, 0) } showOrHideMentionCandidatesIfNeeded(newContent) if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() - && !TextSecurePreferences.isLinkPreviewsEnabled(this) && !TextSecurePreferences.hasSeenLinkPreviewSuggestionDialog(this)) { + && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { LinkPreviewDialog { setUpLinkPreviewObserver() - linkPreviewViewModel?.onEnabled() - linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0) + linkPreviewViewModel.onEnabled() + linkPreviewViewModel.onTextChanged(this, binding.inputBar.text, 0, 0) }.show(supportFragmentManager, "Link Preview Dialog") - TextSecurePreferences.setHasSeenLinkPreviewSuggestionDialog(this) + textSecurePreferences.setHasSeenLinkPreviewSuggestionDialog() } } @@ -603,14 +628,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { if (!isShowingMentionCandidatesView) { - additionalContentContainer.removeAllViews() + binding.additionalContentContainer.removeAllViews() val view = MentionCandidatesView(this) view.glide = glide view.onCandidateSelected = { handleMentionSelected(it) } - additionalContentContainer.addView(view) - val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + binding.additionalContentContainer.addView(view) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) this.mentionCandidatesView = view - view.show(candidates, threadID) + view.show(candidates, viewModel.threadId) view.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, 1.0f) animation.duration = 250L @@ -619,7 +644,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } animation.start() } else { - val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) this.mentionCandidatesView!!.setMentionCandidates(candidates) } isShowingMentionCandidatesView = true @@ -632,7 +657,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe animation.duration = 250L animation.addUpdateListener { animator -> mentionCandidatesView.alpha = animator.animatedValue as Float - if (animator.animatedFraction == 1.0f) { additionalContentContainer.removeAllViews() } + if (animator.animatedFraction == 1.0f) { binding.additionalContentContainer.removeAllViews() } } animation.start() } @@ -641,7 +666,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f - val allButtonContainers = listOf( cameraButtonContainer, libraryButtonContainer, documentButtonContainer, gifButtonContainer) + val allButtonContainers = listOf( binding.cameraButtonContainer, binding.libraryButtonContainer, binding.documentButtonContainer, binding.gifButtonContainer) val isReversed = isShowingAttachmentOptions // Run the animation in reverse val count = allButtonContainers.size allButtonContainers.indices.forEach { index -> @@ -660,39 +685,39 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun showVoiceMessageUI() { - inputBarRecordingView.show() - inputBar.alpha = 0.0f + binding.inputBarRecordingView.show() + binding.inputBar.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) animation.duration = 250L animation.addUpdateListener { animator -> - inputBar.alpha = animator.animatedValue as Float + binding.inputBar.alpha = animator.animatedValue as Float } animation.start() } private fun expandVoiceMessageLockView() { - val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) + val animation = ValueAnimator.ofObject(FloatEvaluator(), binding.inputBarRecordingView.lockView.scaleX, 1.10f) animation.duration = 250L animation.addUpdateListener { animator -> - lockView.scaleX = animator.animatedValue as Float - lockView.scaleY = animator.animatedValue as Float + binding.inputBarRecordingView.lockView.scaleX = animator.animatedValue as Float + binding.inputBarRecordingView.lockView.scaleY = animator.animatedValue as Float } animation.start() } private fun collapseVoiceMessageLockView() { - val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) + val animation = ValueAnimator.ofObject(FloatEvaluator(), binding.inputBarRecordingView.lockView.scaleX, 1.0f) animation.duration = 250L animation.addUpdateListener { animator -> - lockView.scaleX = animator.animatedValue as Float - lockView.scaleY = animator.animatedValue as Float + binding.inputBarRecordingView.lockView.scaleX = animator.animatedValue as Float + binding.inputBarRecordingView.lockView.scaleY = animator.animatedValue as Float } animation.start() } private fun hideVoiceMessageUI() { - val chevronImageView = inputBarRecordingView.inputBarChevronImageView - val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView listOf( chevronImageView, slideToCancelTextView ).forEach { view -> val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) animation.duration = 250L @@ -701,15 +726,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } animation.start() } - inputBarRecordingView.hide() + binding.inputBarRecordingView.hide() } override fun handleVoiceMessageUIHidden() { - inputBar.alpha = 1.0f + binding.inputBar.alpha = 1.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) animation.duration = 250L animation.addUpdateListener { animator -> - inputBar.alpha = animator.animatedValue as Float + binding.inputBar.alpha = animator.animatedValue as Float } animation.start() } @@ -717,45 +742,45 @@ 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 wasTypingIndicatorVisibleBefore = typingIndicatorViewContainer.isVisible - typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom - val isTypingIndicatorVisibleAfter = typingIndicatorViewContainer.isVisible + val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible + binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom + val isTypingIndicatorVisibleAfter = binding.typingIndicatorViewContainer.isVisible if (isTypingIndicatorVisibleAfter != wasTypingIndicatorVisibleBefore) { - inputBarHeightChanged(inputBar.height) + inputBarHeightChanged(binding.inputBar.height) } - scrollToBottomButton.isVisible = !isScrolledToBottom + binding.scrollToBottomButton.isVisible = !isScrolledToBottom unreadCount = min(unreadCount, layoutManager.findFirstVisibleItemPosition()) updateUnreadCountIndicator() } private fun updateUnreadCountIndicator() { - val formattedUnreadCount = if (unreadCount < 100) unreadCount.toString() else "99+" - unreadCountTextView.text = formattedUnreadCount - val textSize = if (unreadCount < 100) 12.0f else 9.0f - unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) - unreadCountIndicator.isVisible = (unreadCount != 0) + val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" + binding.unreadCountTextView.text = formattedUnreadCount + val textSize = if (unreadCount < 10000) 12.0f else 9.0f + binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + binding.unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) + binding.unreadCountIndicator.isVisible = (unreadCount != 0) } private fun updateSubtitle() { - muteIconImageView.isVisible = thread.isMuted - conversationSubtitleView.isVisible = true - if (thread.isMuted) { - if (thread.mutedUntil != Long.MAX_VALUE) { - conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(thread.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) + actionBarBinding.muteIconImageView.isVisible = viewModel.recipient.isMuted + actionBarBinding.conversationSubtitleView.isVisible = true + if (viewModel.recipient.isMuted) { + if (viewModel.recipient.mutedUntil != Long.MAX_VALUE) { + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(viewModel.recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } else { - conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) } - } else if (thread.isGroupRecipient) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) + } else if (viewModel.recipient.isGroupRecipient) { + val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) if (openGroup != null) { val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 - conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) } else { - conversationSubtitleView.isVisible = false + actionBarBinding.conversationSubtitleView.isVisible = false } } else { - conversationSubtitleView.isVisible = false + actionBarBinding.conversationSubtitleView.isVisible = false } } // endregion @@ -765,21 +790,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (item.itemId == android.R.id.home) { return false } - return ConversationMenuHelper.onOptionItemSelected(this, item, thread) + return ConversationMenuHelper.onOptionItemSelected(this, item, viewModel.recipient) } // `position` is the adapter position; not the visual position private fun handlePress(message: MessageRecord, position: Int, view: VisibleMessageView, event: MotionEvent) { val actionMode = this.actionMode if (actionMode != null) { - adapter.toggleSelection(message, position) - val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this) - actionModeCallback.delegate = this - actionModeCallback.updateActionModeMenu(actionMode.menu) - if (adapter.selectedItems.isEmpty()) { - actionMode.finish() - this.actionMode = null - } + onDeselect(message, position, actionMode) } else { // NOTE: // We have to use onContentClick (rather than a click listener directly on @@ -789,24 +807,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + private fun onDeselect(message: MessageRecord, position: Int, actionMode: ActionMode) { + adapter.toggleSelection(message, position) + val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) + actionModeCallback.delegate = this + actionModeCallback.updateActionModeMenu(actionMode.menu) + if (adapter.selectedItems.isEmpty()) { + actionMode.finish() + this.actionMode = null + } + } + // `position` is the adapter position; not the visual position private fun handleSwipeToReply(message: MessageRecord, position: Int) { - inputBar.draftQuote(thread, message, glide) + binding.inputBar.draftQuote(viewModel.recipient, message, glide) } // `position` is the adapter position; not the visual position private fun handleLongPress(message: MessageRecord, position: Int) { val actionMode = this.actionMode - val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this) + val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) actionModeCallback.delegate = this searchViewItem?.collapseActionView() if (actionMode == null) { // Nothing should be selected if this is the case adapter.toggleSelection(message, position) - this.actionMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) - } else { - startActionMode(actionModeCallback) - } + this.actionMode = startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) } else { adapter.toggleSelection(message, position) actionModeCallback.updateActionModeMenu(actionMode.menu) @@ -819,8 +844,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onMicrophoneButtonMove(event: MotionEvent) { val rawX = event.rawX - val chevronImageView = inputBarRecordingView.inputBarChevronImageView - val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView if (rawX < screenWidth / 2) { val translationX = rawX - screenWidth / 2 val sign = -1.0f @@ -855,9 +880,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val x = event.rawX.roundToInt() val y = event.rawY.roundToInt() if (isValidLockViewLocation(x, y)) { - inputBarRecordingView.lock() + binding.inputBarRecordingView.lock() } else { - val recordButtonOverlay = inputBarRecordingView.recordButtonOverlay + val recordButtonOverlay = binding.inputBarRecordingView.recordButtonOverlay val location = IntArray(2) { 0 } recordButtonOverlay.getLocationOnScreen(location) val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) @@ -873,24 +898,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` // to the side) val lockViewLocation = IntArray(2) { 0 } - lockView.getLocationOnScreen(lockViewLocation) + binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation) val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, - lockViewLocation[0] + lockView.width + lockViewHitMargin, lockViewLocation[1] + lockView.height) + lockViewLocation[0] + binding.inputBarRecordingView.lockView.width + lockViewHitMargin, lockViewLocation[1] + binding.inputBarRecordingView.lockView.height) return hitRect.contains(x, y) } - private fun unblock() { - if (!thread.isContactRecipient) { return } - recipientDb.setBlocked(thread, false) - } - private fun handleMentionSelected(mention: Mention) { if (currentMentionStartIndex == -1) { return } mentions.add(mention) - val previousText = inputBar.text + val previousText = binding.inputBar.text val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " - inputBar.text = newText - inputBar.inputBarEditText.setSelection(newText.length) + binding.inputBar.text = newText + binding.inputBar.setSelection(newText.length) currentMentionStartIndex = -1 hideMentionCandidates() this.previousText = newText @@ -898,27 +918,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return - conversationRecyclerView.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition) } override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) { if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } - val viewHolder = conversationRecyclerView.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder - val nextVisibleMessageView = viewHolder?.view ?: return - nextVisibleMessageView.messageContentView.mainContainer.children.forEach { view -> - if (view is VoiceMessageView) { - return@forEach view.togglePlayback() - } - } + val viewHolder = binding.conversationRecyclerView.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder + viewHolder?.view?.playVoiceMessage() } override fun sendMessage() { - if (thread.isContactRecipient && thread.isBlocked) { - BlockedDialog(thread).show(supportFragmentManager, "Blocked Dialog") + if (viewModel.recipient.isContactRecipient && viewModel.recipient.isBlocked) { + BlockedDialog(viewModel.recipient).show(supportFragmentManager, "Blocked Dialog") return } - if (inputBar.linkPreview != null || inputBar.quote != null) { - sendAttachments(listOf(), getMessageBody(), inputBar.quote, inputBar.linkPreview) + if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { + sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { sendTextOnlyMessage() } @@ -926,13 +941,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun commitInputContent(contentUri: Uri) { val media = Media(contentUri, MediaUtil.getMimeType(this, contentUri)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), thread, getMessageBody()), ConversationActivityV2.PICK_FROM_LIBRARY) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient, getMessageBody()), PICK_FROM_LIBRARY) } private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { val text = getMessageBody() - val userPublicKey = TextSecurePreferences.getLocalNumber(this) - val isNoteToSelf = (thread.isContactRecipient && thread.address.toString() == userPublicKey) + val userPublicKey = textSecurePreferences.getLocalNumber() + val isNoteToSelf = (viewModel.recipient.isContactRecipient && viewModel.recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } return dialog.show(supportFragmentManager, "Send Seed Dialog") @@ -941,21 +956,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val message = VisibleMessage() message.sentTimestamp = System.currentTimeMillis() message.text = text - val outgoingTextMessage = OutgoingTextMessage.from(message, thread) + val outgoingTextMessage = OutgoingTextMessage.from(message, viewModel.recipient) // Clear the input bar - inputBar.text = "" - inputBar.cancelQuoteDraft() - inputBar.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Clear mentions previousText = "" currentMentionStartIndex = -1 mentions.clear() // Put the message in the database - message.id = smsDb.insertMessageOutbox(threadID, outgoingTextMessage, false, message.sentTimestamp!!) { } + message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!) { } // Send it - MessageSender.send(message, thread.address) + MessageSender.send(message, viewModel.recipient.address) // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) } private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { @@ -965,14 +980,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe message.text = body val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() - val sender = if (it.isOutgoing) fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) else it.individualRecipient.address + val sender = if (it.isOutgoing) fromSerialized(textSecurePreferences.getLocalNumber()!!) else it.individualRecipient.address QuoteModel(it.dateSent, sender, it.body, false, quotedAttachments) } - val outgoingTextMessage = OutgoingMediaMessage.from(message, thread, attachments, quote, linkPreview) + val outgoingTextMessage = OutgoingMediaMessage.from(message, viewModel.recipient, attachments, quote, linkPreview) // Clear the input bar - inputBar.text = "" - inputBar.cancelQuoteDraft() - inputBar.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Clear mentions previousText = "" currentMentionStartIndex = -1 @@ -982,43 +997,43 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Reset attachments button if needed if (isShowingAttachmentOptions) { toggleAttachmentOptions() } // Put the message in the database - message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, threadID, false) { } + message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false) { } // Send it - MessageSender.send(message, thread.address, attachments, quote, linkPreview) + MessageSender.send(message, viewModel.recipient.address, attachments, quote, linkPreview) // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) } private fun showGIFPicker() { - val hasSeenGIFMetaDataWarning: Boolean = TextSecurePreferences.hasSeenGIFMetaDataWarning(this) + val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { val builder = AlertDialog.Builder(this) builder.setTitle("Search GIFs?") builder.setMessage("You will not have full metadata protection when sending GIFs.") - builder.setPositiveButton("OK") { dialog: DialogInterface, which: Int -> - TextSecurePreferences.setHasSeenGIFMetaDataWarning(this) - AttachmentManager.selectGif(this, ConversationActivityV2.PICK_GIF) + builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int -> + textSecurePreferences.setHasSeenGIFMetaDataWarning() + AttachmentManager.selectGif(this, PICK_GIF) dialog.dismiss() } builder.setNegativeButton( "Cancel" - ) { dialog: DialogInterface, which: Int -> dialog.dismiss() } + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } builder.create().show() } else { - AttachmentManager.selectGif(this, ConversationActivityV2.PICK_GIF) + AttachmentManager.selectGif(this, PICK_GIF) } } private fun showDocumentPicker() { - AttachmentManager.selectDocument(this, ConversationActivityV2.PICK_DOCUMENT) + AttachmentManager.selectDocument(this, PICK_DOCUMENT) } private fun pickFromLibrary() { - AttachmentManager.selectGallery(this, ConversationActivityV2.PICK_FROM_LIBRARY, thread, inputBar.text.trim()) + AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, viewModel.recipient, binding.inputBar.text.trim()) } private fun showCamera() { - attachmentManager.capturePhoto(this, ConversationActivityV2.TAKE_PHOTO, thread); + attachmentManager.capturePhoto(this, TAKE_PHOTO, viewModel.recipient); } override fun onAttachmentChanged() { @@ -1080,23 +1095,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendAttachments(slideDeck.asAttachments(), body) } INVITE_CONTACTS -> { - if (!thread.isOpenGroupRecipient) { return } + if (!viewModel.recipient.isOpenGroupRecipient) { return } val extras = intent?.extras ?: return - if (!intent.hasExtra(SelectContactsActivity.selectedContactsKey)) { return } + if (!intent.hasExtra(selectedContactsKey)) { return } val selectedContacts = extras.getStringArray(selectedContactsKey)!! - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) - for (contact in selectedContacts) { - val recipient = Recipient.from(this, fromSerialized(contact), true) - val message = VisibleMessage() - message.sentTimestamp = System.currentTimeMillis() - val openGroupInvitation = OpenGroupInvitation() - openGroupInvitation.name = openGroup!!.name - openGroupInvitation.url = openGroup!!.joinURL - message.openGroupInvitation = openGroupInvitation - val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation(openGroupInvitation, recipient, message.sentTimestamp) - smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!) - MessageSender.send(message, recipient.address) + val recipients = selectedContacts.map { contact -> + Recipient.from(this, fromSerialized(contact), true) } + viewModel.inviteContacts(recipients) } } } @@ -1151,91 +1157,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) } - private fun buildUnsendRequest(message: MessageRecord): UnsendRequest? { - if (this.thread.isOpenGroupRecipient) return null - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - messageDataProvider.getServerHashForMessage(message.id) ?: return null - val unsendRequest = UnsendRequest() - if (message.isOutgoing) { - unsendRequest.author = TextSecurePreferences.getLocalNumber(this) - } else { - unsendRequest.author = message.individualRecipient.address.contactIdentifier() - } - unsendRequest.timestamp = message.timestamp - - return unsendRequest - } - - private fun deleteLocally(message: MessageRecord) { - buildUnsendRequest(message)?.let { unsendRequest -> - TextSecurePreferences.getLocalNumber(this@ConversationActivityV2)?.let { - MessageSender.send(unsendRequest, Address.fromSerialized(it)) - } - } - MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(message.id, !message.isMms) - } - - private fun deleteForEveryone(message: MessageRecord) { - buildUnsendRequest(message)?.let { unsendRequest -> - MessageSender.send(unsendRequest, thread.address) - } - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) - if (openGroup != null) { - lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> - OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - }.failUi { error -> - Toast.makeText(this@ConversationActivityV2, "Couldn't delete message due to error: $error", Toast.LENGTH_LONG).show() - } - } - } else { - messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id)?.let { serverHash -> - var publicKey = thread.address.serialize() - if (thread.isClosedGroupRecipient) { publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() } - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) - .failUi { error -> - Toast.makeText(this@ConversationActivityV2, "Couldn't delete message due to error: $error", Toast.LENGTH_LONG).show() - } - } - } - } - // Remove this after the unsend request is enabled fun deleteMessagesWithoutUnsendRequest(messages: Set) { val messageCount = messages.size - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val builder = AlertDialog.Builder(this) builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) builder.setCancelable(true) - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) builder.setPositiveButton(R.string.delete) { _, _ -> - if (openGroup != null) { - val messageServerIDs = mutableMapOf() - for (message in messages) { - val messageServerID = lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue - messageServerIDs[messageServerID] = message - } - for ((messageServerID, message) in messageServerIDs) { - OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - }.failUi { error -> - Toast.makeText(this@ConversationActivityV2, "Couldn't delete message due to error: $error", Toast.LENGTH_LONG).show() - } - } - } else { - for (message in messages) { - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } + viewModel.deleteMessagesWithoutUnsendRequest(messages) endActionMode() } builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> @@ -1252,7 +1182,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } val allSentByCurrentUser = messages.all { it.isOutgoing } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } - if (thread.isOpenGroupRecipient) { + if (viewModel.recipient.isOpenGroupRecipient) { val messageCount = messages.size val builder = AlertDialog.Builder(this) builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) @@ -1260,7 +1190,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe builder.setCancelable(true) builder.setPositiveButton(R.string.delete) { _, _ -> for (message in messages) { - this.deleteForEveryone(message) + viewModel.deleteForEveryone(message) } endActionMode() } @@ -1271,17 +1201,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe builder.show() } else if (allSentByCurrentUser && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = thread + bottomSheet.recipient = viewModel.recipient bottomSheet.onDeleteForMeTapped = { for (message in messages) { - this.deleteLocally(message) + viewModel.deleteLocally(message) } bottomSheet.dismiss() endActionMode() } bottomSheet.onDeleteForEveryoneTapped = { for (message in messages) { - this.deleteForEveryone(message) + viewModel.deleteForEveryone(message) } bottomSheet.dismiss() endActionMode() @@ -1299,7 +1229,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe builder.setCancelable(true) builder.setPositiveButton(R.string.delete) { _, _ -> for (message in messages) { - this.deleteLocally(message) + viewModel.deleteLocally(message) } endActionMode() } @@ -1313,17 +1243,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun banUser(messages: Set) { val builder = AlertDialog.Builder(this) - val sessionID = messages.first().individualRecipient.address.toString() builder.setTitle(R.string.ConversationFragment_ban_selected_user) builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms.") builder.setCancelable(true) - val openGroup = lokiThreadDb.getOpenGroupChat(threadID)!! builder.setPositiveButton(R.string.ban) { _, _ -> - OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server).successUi { - Toast.makeText(this@ConversationActivityV2, "Successfully banned user", Toast.LENGTH_LONG).show() - }.failUi { error -> - Toast.makeText(this@ConversationActivityV2, "Couldn't ban user due to error: $error", Toast.LENGTH_LONG).show() - } + viewModel.banUser(messages.first().individualRecipient) endActionMode() } builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> @@ -1335,17 +1259,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun banAndDeleteAll(messages: Set) { val builder = AlertDialog.Builder(this) - val sessionID = messages.first().individualRecipient.address.toString() builder.setTitle(R.string.ConversationFragment_ban_selected_user) builder.setMessage("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") builder.setCancelable(true) - val openGroup = lokiThreadDb.getOpenGroupChat(threadID)!! builder.setPositiveButton(R.string.ban) { _, _ -> - OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server).successUi { - Toast.makeText(this@ConversationActivityV2, "Successfully banned user and deleted all their messages", Toast.LENGTH_LONG).show() - }.failUi { error -> - Toast.makeText(this@ConversationActivityV2, "Couldn't execute request due to error: $error", Toast.LENGTH_LONG).show() - } + viewModel.banAndDeleteAll(messages.first().individualRecipient) endActionMode() } builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> @@ -1362,7 +1280,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() - val body = MentionUtilities.highlightMentions(message.body, threadID, this) + val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) @@ -1394,7 +1312,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun resendMessage(messages: Set) { - messages.forEach { messageRecord -> + messages.iterator().forEach { messageRecord -> ResendMessageUtilities.resend(messageRecord) } endActionMode() @@ -1442,16 +1360,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun reply(messages: Set) { - inputBar.draftQuote(thread, messages.first(), glide) + binding.inputBar.draftQuote(viewModel.recipient, messages.first(), glide) endActionMode() } private fun sendMediaSavedNotification() { - if (thread.isGroupRecipient) { return } + if (viewModel.recipient.isGroupRecipient) { return } val timestamp = System.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, thread.address) + MessageSender.send(message, viewModel.recipient.address) } private fun endActionMode() { @@ -1462,7 +1380,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region General private fun getMessageBody(): String { - var result = inputBar.inputBarEditText.text?.trim() ?: "" + var result = binding.inputBar.text.trim() for (mention in mentions) { try { val startIndex = result.indexOf("@" + mention.displayName) @@ -1472,22 +1390,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.d("Loki", "Failed to process mention due to error: $exception") } } - return result.toString() - } - - private fun saveDraft() { - val text = inputBar?.text?.trim() ?: return - if (text.isEmpty()) { return } - val drafts = Drafts() - drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) - draftDb.insertDrafts(threadID, drafts) + return result } // endregion // region Search private fun setUpSearchResultObserver() { - val searchViewModel = ViewModelProvider(this).get(SearchViewModel::class.java) - this.searchViewModel = searchViewModel searchViewModel.searchResults.observe(this, Observer { result: SearchViewModel.SearchResult? -> if (result == null) return@Observer if (result.getResults().isNotEmpty()) { @@ -1495,31 +1403,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs, Runnable { searchViewModel.onMissingResult() }) } } - this.searchBottomBar.setData(result.position, result.getResults().size) + binding.searchBottomBar.setData(result.position, result.getResults().size) }) } - fun onSearchQueryUpdated(query: String?) { + fun onSearchOpened() { + searchViewModel.onSearchOpened() + binding.searchBottomBar.visibility = View.VISIBLE + binding.searchBottomBar.setData(0, 0) + binding.inputBar.visibility = View.GONE + } + + fun onSearchClosed() { + searchViewModel.onSearchClosed() + binding.searchBottomBar.visibility = View.GONE + binding.inputBar.visibility = View.VISIBLE + adapter.onSearchQueryUpdated(null) + invalidateOptionsMenu() + } + + fun onSearchQueryUpdated(query: String) { + searchViewModel.onQueryUpdated(query, viewModel.threadId) + binding.searchBottomBar.showLoading() adapter.onSearchQueryUpdated(query) } override fun onSearchMoveUpPressed() { - this.searchViewModel?.onMoveUp() + this.searchViewModel.onMoveUp() } override fun onSearchMoveDownPressed() { - this.searchViewModel?.onMoveDown() + this.searchViewModel.onMoveDown() } private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { SimpleTask.run(lifecycle, { - mmsSmsDb.getMessagePositionInConversation(threadID, timestamp, author) + mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) }) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } } private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { if (position >= 0) { - conversationRecyclerView.scrollToPosition(position) + binding.conversationRecyclerView.scrollToPosition(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 c033009ad..0c8bda33f 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 @@ -4,9 +4,7 @@ import android.content.Context import android.database.Cursor import android.view.MotionEvent import android.view.ViewGroup -import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView.ViewHolder -import kotlinx.android.synthetic.main.view_visible_message.view.* import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView @@ -17,7 +15,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit, - private val glide: GlideRequests) + private val glide: GlideRequests, private val onDeselect: (MessageRecord, Int) -> Unit) : CursorRecyclerViewAdapter(context, cursor) { private val messageDB = DatabaseComponent.get(context).mmsSmsDatabase() var selectedItems = mutableSetOf() @@ -49,15 +47,9 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @Suppress("NAME_SHADOWING") val viewType = ViewType.allValues[viewType] - when (viewType) { - ViewType.Visible -> { - val view = VisibleMessageView(context) - return VisibleMessageViewHolder(view) - } - ViewType.Control -> { - val view = ControlMessageView(context) - return ControlMessageViewHolder(view) - } + return when (viewType) { + ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context)) + ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context)) else -> throw IllegalStateException("Unexpected view type: $viewType.") } } @@ -71,13 +63,16 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr val view = viewHolder.view val isSelected = selectedItems.contains(message) view.snIsSelected = isSelected - view.messageTimestampTextView.isVisible = isSelected view.indexInAdapter = position view.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery) if (!message.isDeleted) { view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) } view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) } + } else { + view.onPress = null + view.onSwipeToReply = null + view.onLongPress = null } view.contentViewDelegate = visibleMessageContentViewDelegate } @@ -111,6 +106,27 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr return messageDB.readerFor(cursor).current } + override fun changeCursor(cursor: Cursor?) { + super.changeCursor(cursor) + val toRemove = mutableSetOf() + val toDeselect = mutableSetOf>() + for (selected in selectedItems) { + val position = getItemPositionForTimestamp(selected.timestamp) + if (position == null || position == -1) { + toRemove += selected + } else { + val item = getMessage(getCursorAtPositionOrThrow(position)) + if (item == null || item.isDeleted) { + toDeselect += position to selected + } + } + } + selectedItems -= toRemove + toDeselect.iterator().forEach { (pos, record) -> + onDeselect(record, pos) + } + } + fun toggleSelection(message: MessageRecord, position: Int) { if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message) notifyItemChanged(position) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt index 475efaba3..f0e8db7e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt @@ -2,16 +2,12 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.util.AttributeSet -import android.util.Log import android.view.MotionEvent import android.view.VelocityTracker -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.activity_conversation_v2.* import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx import kotlin.math.abs -import kotlin.math.max class ConversationRecyclerView : RecyclerView { private val maxLongPressVelocityY = toPx(10, resources) @@ -37,10 +33,10 @@ class ConversationRecyclerView : RecyclerView { if (abs(vy) > maxLongPressVelocityY && abs(vx) < minSwipeVelocityX) { return super.onInterceptTouchEvent(e) } // Return false if abs(v.x) > abs(v.y) so that only swipes that are more horizontal than vertical // get passed on to the message view - if (abs(vx) > abs(vy)) { - return false + return if (abs(vx) > abs(vy)) { + false } else { - return super.onInterceptTouchEvent(e) + super.onInterceptTouchEvent(e) } } 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 new file mode 100644 index 000000000..852d0a998 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.repository.ConversationRepository +import java.util.UUID + +class ConversationViewModel( + val threadId: Long, + private val repository: ConversationRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConversationUiState()) + val uiState: StateFlow = _uiState + + val recipient: Recipient by lazy { + repository.getRecipientForThreadId(threadId) + } + + init { + _uiState.update { + it.copy(isOxenHostedOpenGroup = repository.isOxenHostedOpenGroup(threadId)) + } + } + + fun saveDraft(text: String) { + repository.saveDraft(threadId, text) + } + + fun getDraft(): String? { + return repository.getDraft(threadId) + } + + fun inviteContacts(contacts: List) { + repository.inviteContacts(threadId, contacts) + } + + fun unblock() { + if (recipient.isContactRecipient) { + repository.unblock(recipient) + } + } + + fun deleteLocally(message: MessageRecord) { + repository.deleteLocally(recipient, message) + } + + fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { + repository.deleteForEveryone(threadId, recipient, message) + .onFailure { + showMessage("Couldn't delete message due to error: $it") + } + } + + fun deleteMessagesWithoutUnsendRequest(messages: Set) = viewModelScope.launch { + repository.deleteMessageWithoutUnsendRequest(threadId, messages) + .onFailure { + showMessage("Couldn't delete message due to error: $it") + } + } + + fun banUser(recipient: Recipient) = viewModelScope.launch { + repository.banUser(threadId, recipient) + .onSuccess { + showMessage("Successfully banned user") + } + .onFailure { + showMessage("Couldn't ban user due to error: $it") + } + } + + fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch { + repository.banAndDeleteAll(threadId, recipient) + .onSuccess { + showMessage("Successfully banned user and deleted all their messages") + } + .onFailure { + showMessage("Couldn't execute request due to error: $it") + } + } + + private fun showMessage(message: String) { + _uiState.update { currentUiState -> + val messages = currentUiState.uiMessages + UiMessage( + id = UUID.randomUUID().mostSignificantBits, + message = message + ) + currentUiState.copy(uiMessages = messages) + } + } + + fun messageShown(messageId: Long) { + _uiState.update { currentUiState -> + val messages = currentUiState.uiMessages.filterNot { it.id == messageId } + currentUiState.copy(uiMessages = messages) + } + } + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val repository: ConversationRepository + ) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return ConversationViewModel(threadId, repository) as T + } + } +} + +data class UiMessage(val id: Long, val message: String) + +data class ConversationUiState( + val isOxenHostedOpenGroup: Boolean = false, + val uiMessages: List = emptyList() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index 6272a9c22..66f33cf29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -7,8 +7,8 @@ import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.fragment_delete_message_bottom_sheet.* import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentDeleteMessageBottomSheetBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.SessionContactDatabase @@ -22,6 +22,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen lateinit var contactDatabase: SessionContactDatabase lateinit var recipient: Recipient + private lateinit var binding: FragmentDeleteMessageBottomSheetBinding val contact by lazy { val senderId = recipient.address.serialize() // this dialog won't show for open group contacts @@ -37,15 +38,16 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_delete_message_bottom_sheet, container, false) + ): View { + binding = FragmentDeleteMessageBottomSheetBinding.inflate(inflater, container, false) + return binding.root } override fun onClick(v: View?) { when (v) { - deleteForMeTextView -> onDeleteForMeTapped?.invoke() - deleteForEveryoneTextView -> onDeleteForEveryoneTapped?.invoke() - cancelTextView -> onCancelTapped?.invoke() + binding.deleteForMeTextView -> onDeleteForMeTapped?.invoke() + binding.deleteForEveryoneTextView -> onDeleteForEveryoneTapped?.invoke() + binding.cancelTextView -> onCancelTapped?.invoke() } } @@ -55,13 +57,13 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen return dismiss() } if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { - deleteForEveryoneTextView.text = + binding.deleteForEveryoneTextView.text = resources.getString(R.string.delete_message_for_me_and_recipient, contact) } - deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient - deleteForMeTextView.setOnClickListener(this) - deleteForEveryoneTextView.setOnClickListener(this) - cancelTextView.setOnClickListener(this) + binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient + binding.deleteForMeTextView.setOnClickListener(this) + binding.deleteForEveryoneTextView.setOnClickListener(this) + binding.cancelTextView.setOnClickListener(this) } override fun onStart() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 7899a2e47..72757ce25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.conversation.v2 import android.os.Bundle import android.view.View -import kotlinx.android.synthetic.main.activity_message_detail.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityMessageDetailBinding import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.TextSecurePreferences @@ -13,11 +13,11 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.DateUtils import java.text.SimpleDateFormat -import java.util.* - +import java.util.Date +import java.util.Locale class MessageDetailActivity: PassphraseRequiredActionBarActivity() { - + private lateinit var binding: ActivityMessageDetailBinding var messageRecord: MessageRecord? = null // region Settings @@ -29,7 +29,8 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - setContentView(R.layout.activity_message_detail) + binding = ActivityMessageDetailBinding.inflate(layoutInflater) + setContentView(binding.root) title = resources.getString(R.string.conversation_context__menu_message_details) val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) // We only show this screen for messages fail to send, @@ -37,7 +38,7 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() { val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) updateContent() - resend_button.setOnClickListener { + binding.resendButton.setOnClickListener { ResendMessageUtilities.resend(messageRecord!!) finish() } @@ -46,20 +47,20 @@ class MessageDetailActivity: PassphraseRequiredActionBarActivity() { fun updateContent() { val dateLocale = Locale.getDefault() val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale) - sent_time.text = dateFormatter.format(Date(messageRecord!!.dateSent)) + binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent)) val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) ?: "Message failed to send." - error_message.text = errorMessage + binding.errorMessage.text = errorMessage - if (messageRecord!!.getExpiresIn() <= 0 || messageRecord!!.getExpireStarted() <= 0) { - expires_container.visibility = View.GONE + if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) { + binding.expiresContainer.visibility = View.GONE } else { - expires_container.visibility = View.VISIBLE + binding.expiresContainer.visibility = View.VISIBLE val elapsed = System.currentTimeMillis() - messageRecord!!.expireStarted val remaining = messageRecord!!.expiresIn - elapsed val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1)) - expires_in.text = duration + binding.expiresIn.text = duration } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt index 859f208a5..28c86b331 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt @@ -15,14 +15,16 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.android.synthetic.main.fragment_modal_url_bottom_sheet.* import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentModalUrlBottomSheetBinding import org.thoughtcrime.securesms.util.UiModeUtilities class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), View.OnClickListener { - - override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_modal_url_bottom_sheet, container, false) + private lateinit var binding: FragmentModalUrlBottomSheetBinding + + override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?): View { + binding = FragmentModalUrlBottomSheetBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -31,10 +33,10 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(url) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + url.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - openURLExplanationTextView.text = spannable - cancelButton.setOnClickListener(this) - copyButton.setOnClickListener(this) - openURLButton.setOnClickListener(this) + binding.openURLExplanationTextView.text = spannable + binding.cancelButton.setOnClickListener(this) + binding.copyButton.setOnClickListener(this) + binding.openURLButton.setOnClickListener(this) } private fun open() { @@ -64,9 +66,9 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), override fun onClick(v: View?) { when (v) { - openURLButton -> open() - copyButton -> copy() - cancelButton -> dismiss() + binding.openURLButton -> open() + binding.copyButton -> copy() + binding.cancelButton -> dismiss() } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index b1283e1a2..46387d9c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -11,28 +11,25 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.core.view.children import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.album_thumbnail_view.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.AlbumThumbnailViewBinding import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.components.CornerMask -import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView -import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.longmessage.LongMessageActivity import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.util.ActivityDispatcher -import kotlin.math.roundToInt class AlbumThumbnailView : FrameLayout { + private lateinit var binding: AlbumThumbnailViewBinding + companion object { const val MAX_ALBUM_DISPLAY_SIZE = 5 } @@ -55,7 +52,7 @@ class AlbumThumbnailView : FrameLayout { private var slideSize: Int = 0 private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.album_thumbnail_view, this) + binding = AlbumThumbnailViewBinding.inflate(LayoutInflater.from(context), this, true) } override fun dispatchDraw(canvas: Canvas?) { @@ -70,26 +67,9 @@ class AlbumThumbnailView : FrameLayout { val rawXInt = event.rawX.toInt() val rawYInt = event.rawY.toInt() val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) - // Z-check in specific order val testRect = Rect() - // test "Read More" - albumCellBodyTextReadMore.getGlobalVisibleRect(testRect) - if (testRect.contains(eventRect)) { - // dispatch to activity view - ActivityDispatcher.get(context)?.dispatchIntent { context -> - LongMessageActivity.getIntent(context, mms.recipient.address, mms.getId(), true) - } - return - } - val intersectedSpans = albumCellBodyText.getIntersectedModalSpans(eventRect) - if (intersectedSpans.isNotEmpty()) { - intersectedSpans.forEach { span -> - span.onClick(albumCellBodyText) - } - return - } // test each album child - albumCellContainer.findViewById(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> + binding.albumCellContainer.findViewById(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> child.getGlobalVisibleRect(testRect) if (testRect.contains(eventRect)) { // hit intersects with this particular child @@ -111,6 +91,11 @@ class AlbumThumbnailView : FrameLayout { } } + fun clearViews() { + binding.albumCellContainer.removeAllViews() + slideSize = -1 + } + fun bind(glideRequests: GlideRequests, message: MmsMessageRecord, isStart: Boolean, isEnd: Boolean) { slides = message.slideDeck.thumbnailSlides @@ -122,10 +107,10 @@ class AlbumThumbnailView : FrameLayout { // recreate cell views if different size to what we have already (for recycling) if (slides.size != this.slideSize) { - albumCellContainer.removeAllViews() - LayoutInflater.from(context).inflate(layoutRes(slides.size), albumCellContainer) + binding.albumCellContainer.removeAllViews() + LayoutInflater.from(context).inflate(layoutRes(slides.size), binding.albumCellContainer) val overflowed = slides.size > MAX_ALBUM_DISPLAY_SIZE - albumCellContainer.findViewById(R.id.album_cell_overflow_text)?.let { overflowText -> + binding.albumCellContainer.findViewById(R.id.album_cell_overflow_text)?.let { overflowText -> // overflowText will be null if !overflowed overflowText.isVisible = overflowed // more than max album size overflowText.text = context.getString(R.string.AlbumThumbnailView_plus, slides.size - MAX_ALBUM_DISPLAY_SIZE) @@ -137,19 +122,6 @@ class AlbumThumbnailView : FrameLayout { val thumbnailView = getThumbnailView(position) thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) } - albumCellBodyParent.isVisible = message.body.isNotEmpty() - val body = VisibleMessageContentView.getBodySpans(context, message, null) - albumCellBodyText.text = body - post { - // post to await layout of text - albumCellBodyText.layout?.let { layout -> - val maxEllipsis = (0 until layout.lineCount).maxByOrNull { lineNum -> layout.getEllipsisCount(lineNum) } - ?: 0 - // show read more text if at least one line is ellipsized - ViewUtil.setPaddingTop(albumCellBodyTextParent, if (maxEllipsis > 0) resources.getDimension(R.dimen.small_spacing).roundToInt() else resources.getDimension(R.dimen.medium_spacing).roundToInt()) - albumCellBodyTextReadMore.isVisible = maxEllipsis > 0 - } - } } // endregion @@ -165,11 +137,11 @@ class AlbumThumbnailView : FrameLayout { } fun getThumbnailView(position: Int): KThumbnailView = when (position) { - 0 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_1) - 1 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_2) - 2 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_3) - 3 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_4) - 4 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_5) + 0 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_1) + 1 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_2) + 2 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_3) + 3 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_4) + 4 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_5) else -> throw Exception("Can't get thumbnail view for non-existent thumbnail at position: $position") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt index b056c4f9a..c1fce3f50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -5,14 +5,14 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.view_link_preview_draft.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewLinkPreviewDraftBinding import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.util.toPx class LinkPreviewDraftView : LinearLayout { + private lateinit var binding: ViewLinkPreviewDraftBinding var delegate: LinkPreviewDraftViewDelegate? = null constructor(context: Context) : super(context) { initialize() } @@ -21,22 +21,22 @@ class LinkPreviewDraftView : LinearLayout { private fun initialize() { // Start out with the loader showing and the content view hidden - LayoutInflater.from(context).inflate(R.layout.view_link_preview_draft, this) - linkPreviewDraftContainer.isVisible = false - thumbnailImageView.clipToOutline = true - linkPreviewDraftCancelButton.setOnClickListener { cancel() } + binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true) + binding.linkPreviewDraftContainer.isVisible = false + binding.thumbnailImageView.clipToOutline = true + binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() } } fun update(glide: GlideRequests, linkPreview: LinkPreview) { // Hide the loader and show the content view - linkPreviewDraftContainer.isVisible = true - linkPreviewDraftLoader.isVisible = false - thumbnailImageView.radius = toPx(4, resources) + binding.linkPreviewDraftContainer.isVisible = true + binding.linkPreviewDraftLoader.isVisible = false + binding.thumbnailImageView.radius = toPx(4, resources) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) + binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) } - linkPreviewDraftTitleTextView.text = linkPreview.title + binding.linkPreviewDraftTitleTextView.text = linkPreview.title } private fun cancel() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt index ee1e40846..303c17c5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt @@ -45,7 +45,7 @@ class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defS } override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView.inflate(LayoutInflater.from(context), parent) + val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context) val mentionCandidate = getItem(position) cell.glide = glide cell.mentionCandidate = mentionCandidate diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index 7c7d8b624..5486cf0e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -4,32 +4,29 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.LinearLayout -import kotlinx.android.synthetic.main.view_mention_candidate.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewMentionCandidateBinding import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.thoughtcrime.securesms.mms.GlideRequests -class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : LinearLayout(context, attrs, defStyleAttr) { +class MentionCandidateView : LinearLayout { + private lateinit var binding: ViewMentionCandidateBinding var mentionCandidate = Mention("", "") set(newValue) { field = newValue; update() } var glide: GlideRequests? = null var openGroupServer: String? = null var openGroupRoom: String? = null - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - companion object { - - fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateView { - return layoutInflater.inflate(R.layout.view_mention_candidate, parent, false) as MentionCandidateView - } + private fun initialize() { + binding = ViewMentionCandidateBinding.inflate(LayoutInflater.from(context), this, true) } - private fun update() { + private fun update() = with(binding) { mentionCandidateNameTextView.text = mentionCandidate.displayName profilePictureView.publicKey = mentionCandidate.publicKey profilePictureView.displayName = mentionCandidate.displayName diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt index d6cffd08d..c07154283 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt @@ -5,8 +5,7 @@ import android.content.Intent import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout -import kotlinx.android.synthetic.main.view_open_group_guidelines.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewOpenGroupGuidelinesBinding import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.groups.OpenGroupGuidelinesActivity import org.thoughtcrime.securesms.util.push @@ -18,13 +17,12 @@ class OpenGroupGuidelinesView : FrameLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_open_group_guidelines, null) - addView(contentView) - readButton.setOnClickListener { - val activity = context as ConversationActivityV2 - val intent = Intent(activity, OpenGroupGuidelinesActivity::class.java) - activity.push(intent) + ViewOpenGroupGuidelinesBinding.inflate(LayoutInflater.from(context), this, true).apply { + readButton.setOnClickListener { + val activity = context as ConversationActivityV2 + val intent = Intent(activity, OpenGroupGuidelinesActivity::class.java) + activity.push(intent) + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt index 0628b63b7..768d49146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -4,22 +4,22 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout -import kotlinx.android.synthetic.main.view_conversation_typing_container.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewConversationTypingContainerBinding import org.session.libsession.utilities.recipients.Recipient class TypingIndicatorViewContainer : LinearLayout { + private lateinit var binding: ViewConversationTypingContainerBinding constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_conversation_typing_container, this) + binding = ViewConversationTypingContainerBinding.inflate(LayoutInflater.from(context), this, true) } fun setTypists(typists: List) { - if (typists.isEmpty()) { typingIndicator.stopAnimation(); return } - typingIndicator.startAnimation() + if (typists.isEmpty()) { binding.typingIndicator.stopAnimation(); return } + binding.typingIndicator.startAnimation() } } \ No newline at end of file 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 fb4501c13..39ca7c691 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 @@ -6,8 +6,8 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog -import kotlinx.android.synthetic.main.dialog_blocked.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.DialogBlockedBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog @@ -17,21 +17,21 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent class BlockedDialog(private val recipient: Recipient) : BaseDialog() { override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_blocked, null) + val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext())) val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val title = resources.getString(R.string.dialog_blocked_title, name) - contentView.blockedTitleTextView.text = title + binding.blockedTitleTextView.text = title val explanation = resources.getString(R.string.dialog_blocked_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - contentView.blockedExplanationTextView.text = spannable - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.unblockButton.setOnClickListener { unblock() } - builder.setView(contentView) + binding.blockedExplanationTextView.text = spannable + binding.cancelButton.setOnClickListener { dismiss() } + binding.unblockButton.setOnClickListener { unblock() } + builder.setView(binding.root) } private fun unblock() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 72033be87..42cca1ad3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -7,8 +7,8 @@ import android.text.style.StyleSpan import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.dialog_download.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.DialogDownloadBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue @@ -26,20 +26,20 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() { @Inject lateinit var contactDB: SessionContactDatabase override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_download, null) + val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext())) val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val title = resources.getString(R.string.dialog_download_title, name) - contentView.downloadTitleTextView.text = title + binding.downloadTitleTextView.text = title val explanation = resources.getString(R.string.dialog_download_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - contentView.downloadExplanationTextView.text = spannable - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.downloadButton.setOnClickListener { trust() } - builder.setView(contentView) + binding.downloadExplanationTextView.text = spannable + binding.cancelButton.setOnClickListener { dismiss() } + binding.downloadButton.setOnClickListener { trust() } + builder.setView(binding.root) } private fun trust() { 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 0d4c30508..5c4c7444d 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 @@ -7,8 +7,8 @@ import android.text.style.StyleSpan import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import kotlinx.android.synthetic.main.dialog_join_open_group.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.DialogJoinOpenGroupBinding import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog @@ -19,17 +19,17 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() { override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_join_open_group, null) + val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext())) val title = resources.getString(R.string.dialog_join_open_group_title, name) - contentView.joinOpenGroupTitleTextView.text = title + binding.joinOpenGroupTitleTextView.text = title val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - contentView.joinOpenGroupExplanationTextView.text = spannable - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.joinButton.setOnClickListener { join() } - builder.setView(contentView) + binding.joinOpenGroupExplanationTextView.text = spannable + binding.cancelButton.setOnClickListener { dismiss() } + binding.joinButton.setOnClickListener { join() } + builder.setView(binding.root) } private fun join() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt index f9fa6c381..a16ca86f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt @@ -2,8 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog -import kotlinx.android.synthetic.main.dialog_link_preview.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.DialogLinkPreviewBinding import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog @@ -12,10 +11,10 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_link_preview, null) - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.enableLinkPreviewsButton.setOnClickListener { enable() } - builder.setView(contentView) + val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext())) + binding.cancelButton.setOnClickListener { dismiss() } + binding.enableLinkPreviewsButton.setOnClickListener { enable() } + builder.setView(binding.root) } private fun enable() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt index b215e1f65..f51261d49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt @@ -2,18 +2,17 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog -import kotlinx.android.synthetic.main.dialog_send_seed.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.DialogSendSeedBinding import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog /** Shown if the user is about to send their recovery phrase to someone. */ class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() { override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_send_seed, null) - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.sendSeedButton.setOnClickListener { send() } - builder.setView(contentView) + val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext())) + binding.cancelButton.setOnClickListener { dismiss() } + binding.sendSeedButton.setOnClickListener { send() } + builder.setView(binding.root) } private fun send() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 3d57dfcea..6e0ea32fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -4,13 +4,14 @@ import android.content.Context import android.content.res.Resources import android.net.Uri import android.text.InputType +import android.text.TextWatcher import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.widget.RelativeLayout import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.view_input_bar.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewInputBarBinding import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient @@ -27,6 +28,7 @@ import kotlin.math.max import kotlin.math.roundToInt class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate { + private lateinit var binding: ViewInputBarBinding private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val vMargin by lazy { toDp(4, resources) } private val minHeight by lazy { toPx(56, resources) } @@ -39,8 +41,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li set(value) { field = value; showOrHideInputIfNeeded() } var text: String - get() { return inputBarEditText.text?.toString() ?: "" } - set(value) { inputBarEditText.setText(value) } + get() { return binding.inputBarEditText.text?.toString() ?: "" } + set(value) { binding.inputBarEditText.setText(value) } + + val attachmentButtonsContainerHeight: Int + get() = binding.attachmentsButtonContainer.height private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24) } private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone) } @@ -52,37 +57,28 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_input_bar, this) + binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true) // Attachments button - attachmentsButtonContainer.addView(attachmentsButton) - attachmentsButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.attachmentsButtonContainer.addView(attachmentsButton) + attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) attachmentsButton.onPress = { toggleAttachmentOptions() } // Microphone button - microphoneOrSendButtonContainer.addView(microphoneButton) - microphoneButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.microphoneOrSendButtonContainer.addView(microphoneButton) + microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) microphoneButton.onLongPress = { startRecordingVoiceMessage() } microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) } // Send button - microphoneOrSendButtonContainer.addView(sendButton) - sendButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.microphoneOrSendButtonContainer.addView(sendButton) + sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) sendButton.isVisible = false sendButton.onUp = { delegate?.sendMessage() } // Edit text val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 - inputBarEditText.imeOptions = inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled - inputBarEditText.inputType = inputBarEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - inputBarEditText.delegate = this - } - // endregion - - // region General - private fun setHeight(newHeight: Int) { - val layoutParams = inputBarLinearLayout.layoutParams as LayoutParams - layoutParams.height = newHeight - inputBarLinearLayout.layoutParams = layoutParams - delegate?.inputBarHeightChanged(newHeight) + binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled + binding.inputBarEditText.inputType = binding.inputBarEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + binding.inputBarEditText.delegate = this } // endregion @@ -94,8 +90,6 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li } override fun inputBarEditTextHeightChanged(newValue: Int) { - val newHeight = max(newValue + 2 * vMargin, minHeight) + inputBarAdditionalContentContainer.height - setHeight(newHeight) } override fun commitInputContent(contentUri: Uri) { @@ -117,45 +111,31 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li quote = message linkPreview = null linkPreviewDraftView = null - inputBarAdditionalContentContainer.removeAllViews() + binding.inputBarAdditionalContentContainer.removeAllViews() val quoteView = QuoteView(context, QuoteView.Mode.Draft) quoteView.delegate = this - inputBarAdditionalContentContainer.addView(quoteView) + binding.inputBarAdditionalContentContainer.addView(quoteView) val attachments = (message as? MmsMessageRecord)?.slideDeck - // The max content width is the screen width - 2 times the horizontal input bar padding - the - // quote view content area's start and end margins. This unfortunately has to be calculated manually - // here to get the layout right. - val maxContentWidth = (screenWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources) - toPx(30, resources)).roundToInt() val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() quoteView.bind(sender, message.body, attachments, - thread, true, maxContentWidth, message.isOpenGroupInvitation, message.threadId, false, glide) - // The 6 DP below is the padding the quote view applies to itself, which isn't included in the - // intrinsic height calculation. - val quoteViewIntrinsicHeight = quoteView.getIntrinsicHeight(maxContentWidth) + toPx(6, resources) - val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + quoteViewIntrinsicHeight - additionalContentHeight = quoteViewIntrinsicHeight - setHeight(newHeight) + thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) + requestLayout() } override fun cancelQuoteDraft() { quote = null - inputBarAdditionalContentContainer.removeAllViews() - val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) - additionalContentHeight = 0 - setHeight(newHeight) + binding.inputBarAdditionalContentContainer.removeAllViews() + requestLayout() } fun draftLinkPreview() { quote = null - val linkPreviewDraftHeight = toPx(88, resources) - inputBarAdditionalContentContainer.removeAllViews() + binding.inputBarAdditionalContentContainer.removeAllViews() val linkPreviewDraftView = LinkPreviewDraftView(context) linkPreviewDraftView.delegate = this this.linkPreviewDraftView = linkPreviewDraftView - inputBarAdditionalContentContainer.addView(linkPreviewDraftView) - val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + linkPreviewDraftHeight - additionalContentHeight = linkPreviewDraftHeight - setHeight(newHeight) + binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) + requestLayout() } fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) { @@ -167,24 +147,30 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li override fun cancelLinkPreviewDraft() { if (quote != null) { return } linkPreview = null - inputBarAdditionalContentContainer.removeAllViews() - val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) - additionalContentHeight = 0 - setHeight(newHeight) + binding.inputBarAdditionalContentContainer.removeAllViews() + requestLayout() } private fun showOrHideInputIfNeeded() { if (showInput) { - setOf( inputBarEditText, attachmentsButton ).forEach { it.isVisible = true } + setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true } microphoneButton.isVisible = text.isEmpty() sendButton.isVisible = text.isNotEmpty() } else { cancelQuoteDraft() cancelLinkPreviewDraft() - val views = setOf( inputBarEditText, attachmentsButton, microphoneButton, sendButton ) + val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton ) views.forEach { it.isVisible = false } } } + + fun addTextChangedListener(textWatcher: TextWatcher) { + binding.inputBarEditText.addTextChangedListener(textWatcher) + } + + fun setSelection(index: Int) { + binding.inputBarEditText.setSelection(index) + } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt index 1d1446100..90afabb80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt @@ -45,8 +45,8 @@ class InputBarEditText : AppCompatEditText { delegate?.inputBarEditTextHeightChanged(constrainedHeight.roundToInt()) } - override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { - val ic: InputConnection = super.onCreateInputConnection(editorInfo) + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? { + val ic = super.onCreateInputConnection(editorInfo) ?: return null EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/png", "image/gif", "image/jpg")) val callback = diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index cc8166212..ec45b6ca8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -8,40 +8,56 @@ import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.RelativeLayout +import android.widget.TextView import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.view_input_bar_recording.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewInputBarRecordingBinding +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.animateSizeChange import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx -import org.thoughtcrime.securesms.util.DateUtils -import java.util.* +import java.util.Date class InputBarRecordingView : RelativeLayout { + private lateinit var binding: ViewInputBarRecordingBinding private var startTimestamp = 0L private val snHandler = Handler(Looper.getMainLooper()) private var dotViewAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null var delegate: InputBarRecordingViewDelegate? = null + val lockView: LinearLayout + get() = binding.lockView + + val chevronImageView: ImageView + get() = binding.inputBarChevronImageView + + val slideToCancelTextView: TextView + get() = binding.inputBarSlideToCancelTextView + + val recordButtonOverlay: RelativeLayout + get() = binding.recordButtonOverlay + constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_input_bar_recording, this) - inputBarMiddleContentContainer.disableClipping() - inputBarCancelButton.setOnClickListener { hide() } + binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) + binding.inputBarMiddleContentContainer.disableClipping() + binding.inputBarCancelButton.setOnClickListener { hide() } } fun show() { startTimestamp = Date().time - recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) - inputBarCancelButton.alpha = 0.0f - inputBarMiddleContentContainer.alpha = 1.0f - lockView.alpha = 1.0f + binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) + binding.inputBarCancelButton.alpha = 0.0f + binding.inputBarMiddleContentContainer.alpha = 1.0f + binding.lockView.alpha = 1.0f isVisible = true alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) @@ -77,7 +93,7 @@ class InputBarRecordingView : RelativeLayout { dotViewAnimation = animation animation.duration = 500L animation.addUpdateListener { animator -> - dotView.alpha = animator.animatedValue as Float + binding.dotView.alpha = animator.animatedValue as Float } animation.repeatCount = ValueAnimator.INFINITE animation.repeatMode = ValueAnimator.REVERSE @@ -87,12 +103,12 @@ class InputBarRecordingView : RelativeLayout { private fun pulse() { val collapsedSize = toPx(80.0f, resources) val expandedSize = toPx(104.0f, resources) - pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) + binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f) pulseAnimation = animation animation.duration = 1000L animation.addUpdateListener { animator -> - pulseView.alpha = animator.animatedValue as Float + binding.pulseView.alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f && isVisible) { pulse() } } animation.start() @@ -101,21 +117,21 @@ class InputBarRecordingView : RelativeLayout { private fun animateLockViewUp() { val startMarginBottom = toPx(32, resources) val endMarginBottom = toPx(72, resources) - val layoutParams = lockView.layoutParams as LayoutParams + val layoutParams = binding.lockView.layoutParams as LayoutParams layoutParams.bottomMargin = startMarginBottom - lockView.layoutParams = layoutParams + binding.lockView.layoutParams = layoutParams val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom) animation.duration = 250L animation.addUpdateListener { animator -> layoutParams.bottomMargin = animator.animatedValue as Int - lockView.layoutParams = layoutParams + binding.lockView.layoutParams = layoutParams } animation.start() } private fun updateTimer() { val duration = (Date().time - startTimestamp) / 1000L - recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) + binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) snHandler.postDelayed({ updateTimer() }, 500) } @@ -123,19 +139,19 @@ class InputBarRecordingView : RelativeLayout { val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) fadeOutAnimation.duration = 250L fadeOutAnimation.addUpdateListener { animator -> - inputBarMiddleContentContainer.alpha = animator.animatedValue as Float - lockView.alpha = animator.animatedValue as Float + binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float + binding.lockView.alpha = animator.animatedValue as Float } fadeOutAnimation.start() val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) fadeInAnimation.duration = 250L fadeInAnimation.addUpdateListener { animator -> - inputBarCancelButton.alpha = animator.animatedValue as Float + binding.inputBarCancelButton.alpha = animator.animatedValue as Float } fadeInAnimation.start() - recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) - recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } - inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } + binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) + binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } + binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 2159a5dac..2266a1c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -4,33 +4,29 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout import android.widget.RelativeLayout -import kotlinx.android.synthetic.main.view_mention_candidate.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewMentionCandidateV2Binding import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.thoughtcrime.securesms.mms.GlideRequests -class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : RelativeLayout(context, attrs, defStyleAttr) { +class MentionCandidateView : RelativeLayout { + private lateinit var binding: ViewMentionCandidateV2Binding var candidate = Mention("", "") set(newValue) { field = newValue; update() } var glide: GlideRequests? = null var openGroupServer: String? = null var openGroupRoom: String? = null - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - companion object { - - fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateView { - return layoutInflater.inflate(R.layout.view_mention_candidate_v2, parent, false) as MentionCandidateView - } + private fun initialize() { + binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true) } - private fun update() { + private fun update() = with(binding) { mentionCandidateNameTextView.text = candidate.displayName profilePictureView.publicKey = candidate.publicKey profilePictureView.displayName = candidate.displayName diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt index 6e8f07741..401ccaa3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter @@ -42,7 +41,7 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr override fun getItem(position: Int): Mention { return candidates[position] } override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView.inflate(LayoutInflater.from(context), parent) + val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context) val mentionCandidate = getItem(position) cell.glide = glide cell.candidate = mentionCandidate 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 adfbf297e..bb581f25f 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 @@ -12,7 +12,6 @@ import android.os.AsyncTask import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View import android.widget.ImageView import android.widget.TextView import android.widget.Toast @@ -24,7 +23,6 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat -import kotlinx.android.synthetic.main.activity_conversation_v2.* import network.loki.messenger.R import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.MessageSender @@ -35,7 +33,12 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.* +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.ExpirationDialog +import org.thoughtcrime.securesms.MediaOverviewActivity +import org.thoughtcrime.securesms.MuteDialog +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils @@ -101,15 +104,12 @@ object ConversationMenuHelper { val searchViewItem = menu.findItem(R.id.menu_search) (context as ConversationActivityV2).searchViewItem = searchViewItem val searchView = searchViewItem.actionView as SearchView - val searchViewModel = context.searchViewModel!! val queryListener = object : OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { return true } override fun onQueryTextChange(query: String): Boolean { - searchViewModel.onQueryUpdated(query, threadId) - context.searchBottomBar.showLoading() context.onSearchQueryUpdated(query) return true } @@ -117,10 +117,7 @@ object ConversationMenuHelper { searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { searchView.setOnQueryTextListener(queryListener) - searchViewModel.onSearchOpened() - context.searchBottomBar.visibility = View.VISIBLE - context.searchBottomBar.setData(0, 0) - context.inputBar.visibility = View.GONE + context.onSearchOpened() for (i in 0 until menu.size()) { if (menu.getItem(i) != searchViewItem) { menu.getItem(i).isVisible = false @@ -131,11 +128,7 @@ object ConversationMenuHelper { override fun onMenuItemActionCollapse(item: MenuItem): Boolean { searchView.setOnQueryTextListener(null) - searchViewModel.onSearchClosed() - context.searchBottomBar.visibility = View.GONE - context.inputBar.visibility = View.VISIBLE - context.onSearchQueryUpdated(null) - context.invalidateOptionsMenu() + context.onSearchClosed() return true } }) @@ -169,7 +162,7 @@ object ConversationMenuHelper { } private fun search(context: Context) { - val searchViewModel = (context as ConversationActivityV2).searchViewModel!! + val searchViewModel = (context as ConversationActivityV2).searchViewModel searchViewModel.onSearchOpened() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 2b3a545b0..73e5ac338 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -7,35 +7,41 @@ import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.view_control_message.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewControlMessageBinding import org.thoughtcrime.securesms.database.model.MessageRecord class ControlMessageView : LinearLayout { + private lateinit var binding: ViewControlMessageBinding + // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_control_message, this) + binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } // endregion // region Updating fun bind(message: MessageRecord, previous: MessageRecord?) { - dateBreakTextView.showDateBreak(message, previous) - iconImageView.visibility = View.GONE + binding.dateBreakTextView.showDateBreak(message, previous) + binding.iconImageView.visibility = View.GONE if (message.isExpirationTimerUpdate) { - iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme)) - iconImageView.visibility = View.VISIBLE + binding.iconImageView.setImageDrawable( + ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme) + ) + binding.iconImageView.visibility = View.VISIBLE } else if (message.isMediaSavedNotification) { - iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)) - iconImageView.visibility = View.VISIBLE + binding.iconImageView.setImageDrawable( + ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) + ) + binding.iconImageView.visibility = View.VISIBLE } - textView.text = message.getDisplayBody(context) + binding.textView.text = message.getDisplayBody(context) } fun recycle() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index a91473352..99af8c2ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -6,32 +6,28 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt -import kotlinx.android.synthetic.main.fragment_conversation_bottom_sheet.view.* -import kotlinx.android.synthetic.main.view_deleted_message.view.* -import kotlinx.android.synthetic.main.view_document.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewDeletedMessageBinding import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import java.util.* class DeletedMessageView : LinearLayout { - + private lateinit var binding: ViewDeletedMessageBinding // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_deleted_message, this) + binding = ViewDeletedMessageBinding.inflate(LayoutInflater.from(context), this, true) } // endregion // region Updating fun bind(message: MessageRecord, @ColorInt textColor: Int) { assert(message.isDeleted) - deleteTitleTextView.text = context.getString(R.string.deleted_message) - deleteTitleTextView.setTextColor(textColor) - deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + binding.deleteTitleTextView.text = context.getString(R.string.deleted_message) + binding.deleteTitleTextView.setTextColor(textColor) + binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt index c9daca6c7..e0a0630e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt @@ -6,29 +6,27 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt -import kotlinx.android.synthetic.main.view_document.view.* -import network.loki.messenger.R -import org.thoughtcrime.securesms.database.model.MessageRecord +import network.loki.messenger.databinding.ViewDocumentBinding import org.thoughtcrime.securesms.database.model.MmsMessageRecord class DocumentView : LinearLayout { - + private lateinit var binding: ViewDocumentBinding // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_document, this) + binding = ViewDocumentBinding.inflate(LayoutInflater.from(context), this, true) } // endregion // region Updating fun bind(message: MmsMessageRecord, @ColorInt textColor: Int) { val document = message.slideDeck.documentSlide!! - documentTitleTextView.text = document.fileName.or("Untitled File") - documentTitleTextView.setTextColor(textColor) - documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + binding.documentTitleTextView.text = document.fileName.or("Untitled File") + binding.documentTitleTextView.setTextColor(textColor) + binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 45921122f..fbf40fe52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -11,8 +11,8 @@ import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.view_link_preview.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewLinkPreviewBinding import org.thoughtcrime.securesms.components.CornerMask import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.util.UiModeUtilities class LinkPreviewView : LinearLayout { + private lateinit var binding: ViewLinkPreviewBinding private val cornerMask by lazy { CornerMask(this) } private var url: String? = null lateinit var bodyTextView: TextView @@ -33,31 +34,35 @@ class LinkPreviewView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_link_preview, this) + binding = ViewLinkPreviewBinding.inflate(LayoutInflater.from(context), this, true) } // endregion // region Updating - fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, searchQuery: String?) { + fun bind( + message: MmsMessageRecord, + glide: GlideRequests, + isStartOfMessageCluster: Boolean, + isEndOfMessageCluster: Boolean + ) { val linkPreview = message.linkPreviews.first() url = linkPreview.url // Thumbnail if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) - thumbnailImageView.loadIndicator.isVisible = false + binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + binding.thumbnailImageView.loadIndicator.isVisible = false } // Title - titleTextView.text = linkPreview.title + binding.titleTextView.text = linkPreview.title val textColorID = if (message.isOutgoing && UiModeUtilities.isDayUiMode(context)) { R.color.white } else { if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.white } - titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme)) + binding.titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme)) // Body - bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) - mainLinkPreviewContainer.addView(bodyTextView) + binding.titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme)) // Corner radii val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) cornerMask.setTopLeftRadius(cornerRadii[0]) @@ -78,14 +83,14 @@ class LinkPreviewView : LinearLayout { val rawYInt = event.rawY.toInt() val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val previewRect = Rect() - mainLinkPreviewParent.getGlobalVisibleRect(previewRect) + binding.mainLinkPreviewParent.getGlobalVisibleRect(previewRect) if (previewRect.contains(hitRect)) { openURL() return } // intersectedModalSpans should only be a list of one item val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect) - hitSpans.forEach { span -> + hitSpans.iterator().forEach { span -> span.onClick(bodyTextView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt index ebcdcac2d..4cfc5b556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -6,15 +6,15 @@ import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity -import kotlinx.android.synthetic.main.view_open_group_invitation.view.* import network.loki.messenger.R -import org.session.libsession.messaging.open_groups.OpenGroupV2 +import network.loki.messenger.databinding.ViewOpenGroupInvitationBinding import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.OpenGroupUrlParser import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog import org.thoughtcrime.securesms.database.model.MessageRecord class OpenGroupInvitationView : LinearLayout { + private lateinit var binding: ViewOpenGroupInvitationBinding private var data: UpdateMessageData.Kind.OpenGroupInvitation? = null constructor(context: Context): super(context) { initialize() } @@ -22,7 +22,7 @@ class OpenGroupInvitationView : LinearLayout { constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_open_group_invitation, this) + binding = ViewOpenGroupInvitationBinding.inflate(LayoutInflater.from(context), this, true) } fun bind(message: MessageRecord, @ColorInt textColor: Int) { @@ -31,12 +31,14 @@ class OpenGroupInvitationView : LinearLayout { val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation this.data = data val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus - openGroupInvitationIconImageView.setImageResource(iconID) - openGroupTitleTextView.text = data.groupName - openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl) - openGroupTitleTextView.setTextColor(textColor) - openGroupJoinMessageTextView.setTextColor(textColor) - openGroupURLTextView.setTextColor(textColor) + with(binding){ + openGroupInvitationIconImageView.setImageResource(iconID) + openGroupTitleTextView.text = data.groupName + openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl) + openGroupTitleTextView.setTextColor(textColor) + openGroupJoinMessageTextView.setTextColor(textColor) + openGroupURLTextView.setTextColor(textColor) + } } fun joinOpenGroup() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index fee6ec02d..3df12a516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -2,22 +2,24 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.res.ColorStateList +import android.text.StaticLayout import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout -import android.widget.RelativeLayout import androidx.annotation.ColorInt import androidx.core.content.res.ResourcesCompat import androidx.core.text.toSpannable import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.view_quote.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewQuoteBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.model.Quote +import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.util.MediaUtil @@ -26,7 +28,6 @@ import org.thoughtcrime.securesms.util.toPx import javax.inject.Inject import kotlin.math.max import kotlin.math.min -import kotlin.math.roundToInt // There's quite some calculation going on here. It's a bit complex so don't make changes // if you don't need to. If you do then test: @@ -39,6 +40,7 @@ class QuoteView : LinearLayout { @Inject lateinit var contactDb: SessionContactDatabase + private lateinit var binding: ViewQuoteBinding private lateinit var mode: Mode private val vPadding by lazy { toPx(6, resources) } var delegate: QuoteViewDelegate? = null @@ -46,25 +48,20 @@ class QuoteView : LinearLayout { enum class Mode { Regular, Draft } // region Lifecycle - constructor(context: Context) : super(context) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } + constructor(context: Context) : this(context, Mode.Regular) + constructor(context: Context, attrs: AttributeSet) : this(context, Mode.Regular, attrs) - constructor(context: Context, mode: Mode) : super(context) { + constructor(context: Context, mode: Mode, attrs: AttributeSet? = null) : super(context, attrs) { this.mode = mode - LayoutInflater.from(context).inflate(R.layout.view_quote, this) - // Add padding here (not on mainQuoteViewContainer) to get a bit of a top inset while avoiding + binding = ViewQuoteBinding.inflate(LayoutInflater.from(context), this, true) + // Add padding here (not on binding.mainQuoteViewContainer) to get a bit of a top inset while avoiding // the clipping issue described in getIntrinsicHeight(maxContentWidth:). setPadding(0, toPx(6, resources), 0, 0) when (mode) { - Mode.Draft -> quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() } + Mode.Draft -> binding.quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() } Mode.Regular -> { - quoteViewCancelButton.isVisible = false - mainQuoteViewContainer.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.transparent, context.theme)) - val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams - // Since we're not showing the cancel button we can shorten the end margin - quoteViewMainContentContainerLayoutParams.marginEnd = resources.getDimension(R.dimen.medium_spacing).roundToInt() - quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams + binding.quoteViewCancelButton.isVisible = false + binding.mainQuoteViewContainer.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.transparent, context.theme)) } } } @@ -73,19 +70,19 @@ class QuoteView : LinearLayout { // region General fun getIntrinsicContentHeight(maxContentWidth: Int): Int { // If we're showing an attachment thumbnail, just constrain to the height of that - if (quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) } + if (binding.quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) } var result = 0 - var authorTextViewIntrinsicHeight = 0 - if (quoteViewAuthorTextView.isVisible) { - val author = quoteViewAuthorTextView.text - authorTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(author, quoteViewAuthorTextView.paint, maxContentWidth) + val authorTextViewIntrinsicHeight: Int + if (binding.quoteViewAuthorTextView.isVisible) { + val author = binding.quoteViewAuthorTextView.text + authorTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(author, binding.quoteViewAuthorTextView.paint, maxContentWidth) result += authorTextViewIntrinsicHeight } - val body = quoteViewBodyTextView.text - val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, quoteViewBodyTextView.paint, maxContentWidth) - val staticLayout = TextUtilities.getIntrinsicLayout(body, quoteViewBodyTextView.paint, maxContentWidth) + val body = binding.quoteViewBodyTextView.text + val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, binding.quoteViewBodyTextView.paint, maxContentWidth) + val staticLayout = TextUtilities.getIntrinsicLayout(body, binding.quoteViewBodyTextView.paint, maxContentWidth) result += bodyTextViewIntrinsicHeight - if (!quoteViewAuthorTextView.isVisible) { + if (!binding.quoteViewAuthorTextView.isVisible) { // We want to at least be as high as the cancel button 36DP, and no higher than 3 lines of text. // Height from intrinsic layout is the height of the text before truncation so we shorten // proportionally to our max lines setting. @@ -110,89 +107,114 @@ class QuoteView : LinearLayout { // region Updating fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, - isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean, threadID: Long, + isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long, isOriginalMissing: Boolean, glide: GlideRequests) { // Reduce the max body text view line count to 2 if this is a group thread because // we'll be showing the author text view and we don't want the overall quote view height // to get too big. - quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3 + binding.quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3 // Author if (thread.isGroupRecipient) { val author = contactDb.getContactWithSessionID(authorPublicKey) val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey - quoteViewAuthorTextView.text = authorDisplayName - quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) + binding.quoteViewAuthorTextView.text = authorDisplayName + binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) } - quoteViewAuthorTextView.isVisible = thread.isGroupRecipient + binding.quoteViewAuthorTextView.isVisible = thread.isGroupRecipient // Body - quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context); - quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) + binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context) + binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing - quoteViewAccentLine.isVisible = !hasAttachments - quoteViewAttachmentPreviewContainer.isVisible = hasAttachments + binding.quoteViewAccentLine.isVisible = !hasAttachments + binding.quoteViewAttachmentPreviewContainer.isVisible = hasAttachments if (!hasAttachments) { - val accentLineLayoutParams = quoteViewAccentLine.layoutParams as RelativeLayout.LayoutParams - accentLineLayoutParams.height = getIntrinsicContentHeight(maxContentWidth) // Match the intrinsic * content * height - quoteViewAccentLine.layoutParams = accentLineLayoutParams - quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) + binding.quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) } else if (attachments != null) { - quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme)) + binding.quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme)) val backgroundColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.accent val backgroundColor = ResourcesCompat.getColor(resources, backgroundColorID, context.theme) - quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) - quoteViewAttachmentPreviewImageView.isVisible = false - quoteViewAttachmentThumbnailImageView.isVisible = false - if (attachments.audioSlide != null) { - quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) - quoteViewAttachmentPreviewImageView.isVisible = true - quoteViewBodyTextView.text = resources.getString(R.string.Slide_audio) - } else if (attachments.documentSlide != null) { - quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) - quoteViewAttachmentPreviewImageView.isVisible = true - quoteViewBodyTextView.text = resources.getString(R.string.document) - } else if (attachments.thumbnailSlide != null) { - val slide = attachments.thumbnailSlide!! - // This internally fetches the thumbnail - quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) - quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) - quoteViewAttachmentThumbnailImageView.isVisible = true - quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) + binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) + binding.quoteViewAttachmentPreviewImageView.isVisible = false + binding.quoteViewAttachmentThumbnailImageView.isVisible = false + when { + attachments.audioSlide != null -> { + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) + binding.quoteViewAttachmentPreviewImageView.isVisible = true + binding.quoteViewBodyTextView.text = resources.getString(R.string.Slide_audio) + } + attachments.documentSlide != null -> { + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) + binding.quoteViewAttachmentPreviewImageView.isVisible = true + binding.quoteViewBodyTextView.text = resources.getString(R.string.document) + } + attachments.thumbnailSlide != null -> { + val slide = attachments.thumbnailSlide!! + // This internally fetches the thumbnail + binding.quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) + binding.quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) + binding.quoteViewAttachmentThumbnailImageView.isVisible = true + binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) + } } } - mainQuoteViewContainer.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, getIntrinsicHeight(maxContentWidth)) - val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams - // The start margin is different if we just show the accent line vs if we show an attachment thumbnail - quoteViewMainContentContainerLayoutParams.marginStart = if (!hasAttachments) toPx(16, resources) else toPx(48, resources) - quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams } // endregion // region Convenience @ColorInt private fun getLineColor(isOutgoingMessage: Boolean): Int { val isLightMode = UiModeUtilities.isDayUiMode(context) - if ((mode == Mode.Regular && isLightMode) || (mode == Mode.Draft && isLightMode)) { - return ResourcesCompat.getColor(resources, R.color.black, context.theme) - } else if (mode == Mode.Regular && !isLightMode) { - if (isOutgoingMessage) { - return ResourcesCompat.getColor(resources, R.color.black, context.theme) - } else { - return ResourcesCompat.getColor(resources, R.color.accent, context.theme) + return when { + mode == Mode.Regular && isLightMode || mode == Mode.Draft && isLightMode -> { + ResourcesCompat.getColor(resources, R.color.black, context.theme) + } + mode == Mode.Regular && !isLightMode -> { + if (isOutgoingMessage) { + ResourcesCompat.getColor(resources, R.color.black, context.theme) + } else { + ResourcesCompat.getColor(resources, R.color.accent, context.theme) + } + } + else -> { // Draft & dark mode + ResourcesCompat.getColor(resources, R.color.accent, context.theme) } - } else { // Draft & dark mode - return ResourcesCompat.getColor(resources, R.color.accent, context.theme) } } @ColorInt private fun getTextColor(isOutgoingMessage: Boolean): Int { if (mode == Mode.Draft) { return ResourcesCompat.getColor(resources, R.color.text, context.theme) } val isLightMode = UiModeUtilities.isDayUiMode(context) - if ((isOutgoingMessage && !isLightMode) || (!isOutgoingMessage && isLightMode)) { - return ResourcesCompat.getColor(resources, R.color.black, context.theme) + return if ((isOutgoingMessage && !isLightMode) || (!isOutgoingMessage && isLightMode)) { + ResourcesCompat.getColor(resources, R.color.black, context.theme) } else { - return ResourcesCompat.getColor(resources, R.color.white, context.theme) + ResourcesCompat.getColor(resources, R.color.white, context.theme) } } + + fun calculateWidth(quote: Quote, bodyWidth: Int, maxContentWidth: Int, thread: Recipient): Int { + binding.quoteViewAuthorTextView.isVisible = thread.isGroupRecipient + var paddingWidth = resources.getDimensionPixelSize(R.dimen.medium_spacing) * 5 // initial horizontal padding + with (binding) { + if (quoteViewAttachmentPreviewContainer.isVisible) { + paddingWidth += toPx(40, resources) + } + if (quoteViewAccentLine.isVisible) { + paddingWidth += resources.getDimensionPixelSize(R.dimen.accent_line_thickness) + } + } + val quoteBodyWidth = StaticLayout.getDesiredWidth(binding.quoteViewBodyTextView.text, binding.quoteViewBodyTextView.paint).toInt() + paddingWidth + + val quoteAuthorWidth = if (thread.isGroupRecipient) { + val authorPublicKey = quote.author.serialize() + val author = contactDb.getContactWithSessionID(authorPublicKey) + val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey + StaticLayout.getDesiredWidth(authorDisplayName, binding.quoteViewBodyTextView.paint).toInt() + paddingWidth + } else 0 + + val quoteWidth = max(quoteBodyWidth, quoteAuthorWidth) + val usedWidth = max(quoteWidth, bodyWidth) + return min(maxContentWidth, usedWidth) + } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt index 967e080d9..7e22da091 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt @@ -6,15 +6,15 @@ import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.core.content.ContextCompat -import kotlinx.android.synthetic.main.view_untrusted_attachment.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog import org.thoughtcrime.securesms.util.ActivityDispatcher -import java.util.* +import java.util.Locale class UntrustedAttachmentView: LinearLayout { - + private lateinit var binding: ViewUntrustedAttachmentBinding enum class AttachmentType { AUDIO, DOCUMENT, @@ -27,7 +27,7 @@ class UntrustedAttachmentView: LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_untrusted_attachment, this) + binding = ViewUntrustedAttachmentBinding.inflate(LayoutInflater.from(context), this, true) } // endregion @@ -42,8 +42,8 @@ class UntrustedAttachmentView: LinearLayout { iconDrawable.mutate().setTint(textColor) val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).toLowerCase(Locale.ROOT)) - untrustedAttachmentIcon.setImageDrawable(iconDrawable) - untrustedAttachmentTitle.text = text + binding.untrustedAttachmentIcon.setImageDrawable(iconDrawable) + binding.untrustedAttachmentTitle.text = text } // endregion 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 f98b3a23d..63cfbbda2 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 @@ -5,16 +5,17 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.Drawable import android.text.Spannable +import android.text.StaticLayout import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.URLSpan import android.text.util.Linkify import android.util.AttributeSet -import android.util.TypedValue import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout -import android.widget.TextView import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.appcompat.app.AppCompatActivity @@ -23,24 +24,22 @@ import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.text.getSpans import androidx.core.text.toSpannable -import kotlinx.android.synthetic.main.view_visible_message_content.view.* +import androidx.core.view.isVisible import network.loki.messenger.R +import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl import org.session.libsession.utilities.ThemeUtil -import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet -import org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.SearchUtil -import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.toPx @@ -48,7 +47,8 @@ import java.util.* import kotlin.math.roundToInt class VisibleMessageContentView : LinearLayout { - var onContentClick: ((event: MotionEvent) -> Unit)? = null + private lateinit var binding: ViewVisibleMessageContentBinding + var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageContentViewDelegate? = null var indexInAdapter: Int = -1 @@ -59,7 +59,7 @@ class VisibleMessageContentView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_visible_message_content, this) + binding = ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(context), this, true) } // endregion @@ -73,23 +73,42 @@ class VisibleMessageContentView : LinearLayout { val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) background.colorFilter = filter setBackground(background) - // Body - mainContainer.removeAllViews() - onContentClick = null + + val onlyBodyMessage = message is SmsMessageRecord + val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null + + // reset visibilities / containers + onContentClick.clear() + binding.albumThumbnailView.clearViews() onContentDoubleTap = null + if (message.isDeleted) { - val deletedMessageView = DeletedMessageView(context) - deletedMessageView.bind(message, VisibleMessageContentView.getTextColor(context,message)) - mainContainer.addView(deletedMessageView) - } else if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { - val linkPreviewView = LinkPreviewView(context) - linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster, searchQuery) - mainContainer.addView(linkPreviewView) - onContentClick = { event -> linkPreviewView.calculateHit(event) } - // Body text view is inside the link preview for layout convenience - } else if (message is MmsMessageRecord && message.quote != null) { + binding.deletedMessageView.isVisible = true + binding.deletedMessageView.bind(message, VisibleMessageContentView.getTextColor(context,message)) + return + } else { + binding.deletedMessageView.isVisible = false + } + + binding.quoteView.isVisible = message is MmsMessageRecord && message.quote != null + + binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() + + val linkPreviewLayout = binding.linkPreviewView.layoutParams + linkPreviewLayout.width = if (mediaThumbnailMessage) 0 else ViewGroup.LayoutParams.WRAP_CONTENT + binding.linkPreviewView.layoutParams = linkPreviewLayout + + binding.untrustedView.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null + binding.voiceMessageView.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null + binding.documentView.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null + binding.albumThumbnailView.isVisible = mediaThumbnailMessage + binding.openGroupInvitationView.isVisible = message.isOpenGroupInvitation + + var hideBody = false + + if (message is MmsMessageRecord && message.quote != null) { + binding.quoteView.isVisible = true val quote = message.quote!! - val quoteView = QuoteView(context, QuoteView.Mode.Regular) // The max content width is the max message bubble size - 2 times the horizontal padding - 2 // times the horizontal margin. This unfortunately has to be calculated manually // here to get the layout right. @@ -99,136 +118,161 @@ class VisibleMessageContentView : LinearLayout { } else { quote.text } - quoteView.bind(quote.author.toString(), quoteText, quote.attachment, thread, - message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId, + binding.quoteView.bind(quote.author.toString(), quoteText, quote.attachment, thread, + message.isOutgoing, message.isOpenGroupInvitation, message.threadId, quote.isOriginalMissing, glide) - mainContainer.addView(quoteView) - val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) - ViewUtil.setPaddingTop(bodyTextView, 0) - mainContainer.addView(bodyTextView) - onContentClick = { event -> + onContentClick.add { event -> val r = Rect() - quoteView.getGlobalVisibleRect(r) + binding.quoteView.getGlobalVisibleRect(r) if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { delegate?.scrollToMessageIfPossible(quote.id) - } else { - bodyTextView.getIntersectedModalSpans(event).forEach { span -> - span.onClick(bodyTextView) - } } } + } + + if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { + binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) + onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) } + // Body text view is inside the link preview for layout convenience } else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) { + hideBody = true // Audio attachment if (contactIsTrusted || message.isOutgoing) { - val voiceMessageView = VoiceMessageView(context) - voiceMessageView.indexInAdapter = indexInAdapter - voiceMessageView.delegate = context as? ConversationActivityV2 - voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) - mainContainer.addView(voiceMessageView) + binding.voiceMessageView.indexInAdapter = indexInAdapter + binding.voiceMessageView.delegate = context as? ConversationActivityV2 + binding.voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) // We have to use onContentClick (rather than a click listener directly on the voice // message view) so as to not interfere with all the other gestures. - onContentClick = { voiceMessageView.togglePlayback() } - onContentDoubleTap = { voiceMessageView.handleDoubleTap() } + onContentClick.add { binding.voiceMessageView.togglePlayback() } + onContentDoubleTap = { binding.voiceMessageView.handleDoubleTap() } } else { - val untrustedView = UntrustedAttachmentView(context) - untrustedView.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) - mainContainer.addView(untrustedView) - onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) } + // TODO: move this out to its own area + binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } } } else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) { + hideBody = true // Document attachment if (contactIsTrusted || message.isOutgoing) { - val documentView = DocumentView(context) - documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) - mainContainer.addView(documentView) + binding.documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) } else { - val untrustedView = UntrustedAttachmentView(context) - untrustedView.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) - mainContainer.addView(untrustedView) - onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) } + binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } } } else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) { - // Images/Video attachment + /* + * Images / Video attachment + */ if (contactIsTrusted || message.isOutgoing) { - val albumThumbnailView = AlbumThumbnailView(context) - mainContainer.addView(albumThumbnailView) // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind - albumThumbnailView.bind( + binding.albumThumbnailView.bind( glideRequests = glide, message = message, isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - onContentClick = { event -> - albumThumbnailView.calculateHitObject(event, message, thread) + onContentClick.add { event -> + binding.albumThumbnailView.calculateHitObject(event, message, thread) } } else { - val untrustedView = UntrustedAttachmentView(context) - untrustedView.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) - mainContainer.addView(untrustedView) - onContentClick = { untrustedView.showTrustDialog(message.individualRecipient) } + hideBody = true + binding.albumThumbnailView.clearViews() + binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } } } else if (message.isOpenGroupInvitation) { - val openGroupInvitationView = OpenGroupInvitationView(context) - openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message)) - mainContainer.addView(openGroupInvitationView) - onContentClick = { openGroupInvitationView.joinOpenGroup() } - } else { - val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) - mainContainer.addView(bodyTextView) - onContentClick = { event -> - // intersectedModalSpans should only be a list of one item - bodyTextView.getIntersectedModalSpans(event).forEach { span -> - span.onClick(bodyTextView) + hideBody = true + binding.openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message)) + onContentClick.add { binding.openGroupInvitationView.joinOpenGroup() } + } + + binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody + + // set it to use constraints if not only a text message, otherwise wrap content to whatever width it wants + val params = binding.bodyTextView.layoutParams + params.width = if (onlyBodyMessage || binding.barrierViewsGone()) ViewGroup.LayoutParams.WRAP_CONTENT else 0 + binding.bodyTextView.layoutParams = params + binding.bodyTextView.maxWidth = maxWidth + + val bodyWidth = with (binding.bodyTextView) { + StaticLayout.getDesiredWidth(text, paint).roundToInt() + } + + val quote = (message as? MmsMessageRecord)?.quote + val quoteLayoutParams = binding.quoteView.layoutParams + quoteLayoutParams.width = + if (mediaThumbnailMessage || quote == null) 0 + else binding.quoteView.calculateWidth(quote, bodyWidth, maxWidth, thread) + + binding.quoteView.layoutParams = quoteLayoutParams + + if (message.body.isNotEmpty() && !hideBody) { + val color = getTextColor(context, message) + binding.bodyTextView.setTextColor(color) + binding.bodyTextView.setLinkTextColor(color) + val body = getBodySpans(context, message, searchQuery) + binding.bodyTextView.text = body + onContentClick.add { e: MotionEvent -> + binding.bodyTextView.getIntersectedModalSpans(e).iterator().forEach { span -> + span.onClick(binding.bodyTextView) } } } } + private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = + listOf(albumThumbnailView, linkPreviewView, voiceMessageView, quoteView).none { it.isVisible } + private fun getBackground(isOutgoing: Boolean, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean): Drawable { val isSingleMessage = (isStartOfMessageCluster && isEndOfMessageCluster) - @DrawableRes val backgroundID: Int - if (isSingleMessage) { - backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone - } else if (isStartOfMessageCluster) { - backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_start else R.drawable.message_bubble_background_received_start - } else if (isEndOfMessageCluster) { - backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_end else R.drawable.message_bubble_background_received_end - } else { - backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_middle else R.drawable.message_bubble_background_received_middle + @DrawableRes val backgroundID = when { + isSingleMessage -> { + if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone + } + isStartOfMessageCluster -> { + if (isOutgoing) R.drawable.message_bubble_background_sent_start else R.drawable.message_bubble_background_received_start + } + isEndOfMessageCluster -> { + if (isOutgoing) R.drawable.message_bubble_background_sent_end else R.drawable.message_bubble_background_received_end + } + else -> { + if (isOutgoing) R.drawable.message_bubble_background_sent_middle else R.drawable.message_bubble_background_received_middle + } } return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!! } fun recycle() { - mainContainer.removeAllViews() + arrayOf( + binding.deletedMessageView, + binding.untrustedView, + binding.voiceMessageView, + binding.openGroupInvitationView, + binding.documentView, + binding.quoteView, + binding.linkPreviewView, + binding.albumThumbnailView, + binding.bodyTextView + ).forEach { view -> view.isVisible = false } + } + + fun playVoiceMessage() { + binding.voiceMessageView.togglePlayback() } // endregion // region Convenience companion object { - fun getBodyTextView(context: Context, message: MessageRecord, searchQuery: String?): TextView { - val result = EmojiTextView(context) - val vPadding = context.resources.getDimension(R.dimen.small_spacing).toInt() - val hPadding = toPx(12, context.resources) - result.setPadding(hPadding, vPadding, hPadding, vPadding) - result.setTextSize(TypedValue.COMPLEX_UNIT_PX, context.resources.getDimension(R.dimen.small_font_size)) - val color = getTextColor(context, message) - result.setTextColor(color) - result.setLinkTextColor(color) - val body = getBodySpans(context, message, searchQuery) - result.text = body - return result - } - fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) - body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) - body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) + body = SearchUtil.getHighlightedSpan(Locale.getDefault(), + { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) + body = SearchUtil.getHighlightedSpan(Locale.getDefault(), + { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) Linkify.addLinks(body, Linkify.WEB_URLS) 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 802409cbe..615c5ff09 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 @@ -5,39 +5,51 @@ import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.ColorDrawable -import android.os.Build -import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.AttributeSet -import android.view.* +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout -import android.widget.RelativeLayout import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.view_visible_message.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.utilities.ViewUtil -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.* -import java.util.* +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.getColorWithID +import org.thoughtcrime.securesms.util.toDp +import org.thoughtcrime.securesms.util.toPx +import java.util.Date +import java.util.Locale import javax.inject.Inject import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt + @AndroidEntryPoint class VisibleMessageView : LinearLayout { @@ -48,6 +60,7 @@ class VisibleMessageView : LinearLayout { @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase + private lateinit var binding: ViewVisibleMessageBinding private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIconRect = Rect() @@ -60,7 +73,11 @@ class VisibleMessageView : LinearLayout { private var onDoubleTap: (() -> Unit)? = null var indexInAdapter: Int = -1 var snIsSelected = false - set(value) { field = value; handleIsSelectedChanged()} + set(value) { + field = value + binding.messageTimestampTextView.isVisible = isSelected + handleIsSelectedChanged() + } var onPress: ((event: MotionEvent) -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null @@ -68,7 +85,7 @@ class VisibleMessageView : LinearLayout { companion object { const val swipeToReplyThreshold = 64.0f // dp - const val longPressMovementTreshold = 10.0f // dp + const val longPressMovementThreshold = 10.0f // dp const val longPressDurationThreshold = 250L // ms const val maxDoubleTapInterval = 200L } @@ -79,12 +96,12 @@ class VisibleMessageView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_visible_message, this) + binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) isHapticFeedbackEnabled = true setWillNotDraw(false) - expirationTimerViewContainer.disableClipping() - messageContentContainer.disableClipping() + binding.expirationTimerViewContainer.disableClipping() + binding.messageContentContainer.disableClipping() } // endregion @@ -101,47 +118,46 @@ class VisibleMessageView : LinearLayout { // Show profile picture and sender name if this is a group thread AND // the message is incoming if (isGroupThread && !message.isOutgoing) { - profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE - profilePictureView.publicKey = senderSessionID - profilePictureView.glide = glide - profilePictureView.update(message.individualRecipient, threadID) - profilePictureView.setOnClickListener { + binding.profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE + binding.profilePictureView.publicKey = senderSessionID + binding.profilePictureView.glide = glide + binding.profilePictureView.update(message.individualRecipient) + binding.profilePictureView.setOnClickListener { showUserDetails(senderSessionID, threadID) } if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server) - moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE + binding.moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE } else { - moderatorIconImageView.visibility = View.INVISIBLE + binding.moderatorIconImageView.visibility = View.INVISIBLE } - senderNameTextView.isVisible = isStartOfMessageCluster + binding.senderNameTextView.isVisible = isStartOfMessageCluster val context = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR - senderNameTextView.text = contact?.displayName(context) ?: senderSessionID + binding.senderNameTextView.text = contact?.displayName(context) ?: senderSessionID } else { - profilePictureContainer.visibility = View.GONE - senderNameTextView.visibility = View.GONE + binding.profilePictureContainer.visibility = View.GONE + binding.senderNameTextView.visibility = View.GONE } // Date break - dateBreakTextView.showDateBreak(message, previous) + binding.dateBreakTextView.showDateBreak(message, previous) // Timestamp - messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) + binding.messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) // Margins - val startPadding: Int - if (isGroupThread) { - startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt() else 0 + val startPadding = if (isGroupThread) { + if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.very_large_spacing) else toPx(50,resources) } else { - startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt() - else resources.getDimension(R.dimen.medium_spacing).toInt() + if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.very_large_spacing) + else resources.getDimensionPixelSize(R.dimen.medium_spacing) } - val endPadding = if (message.isOutgoing) resources.getDimension(R.dimen.medium_spacing).toInt() - else resources.getDimension(R.dimen.very_large_spacing).toInt() - messageContentContainer.setPaddingRelative(startPadding, 0, endPadding, 0) + val endPadding = if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.medium_spacing) + else resources.getDimensionPixelSize(R.dimen.very_large_spacing) + binding.messageContentContainer.setPaddingRelative(startPadding, 0, endPadding, 0) // Set inter-message spacing setMessageSpacing(isStartOfMessageCluster, isEndOfMessageCluster) // Gravity val gravity = if (message.isOutgoing) Gravity.END else Gravity.START - mainContainer.gravity = gravity or Gravity.BOTTOM + binding.mainContainer.gravity = gravity or Gravity.BOTTOM // Message status indicator val (iconID, iconColor) = getMessageStatusImage(message) if (iconID != null) { @@ -149,24 +165,24 @@ class VisibleMessageView : LinearLayout { if (iconColor != null) { drawable?.setTint(iconColor) } - messageStatusImageView.setImageDrawable(drawable) + binding.messageStatusImageView.setImageDrawable(drawable) } if (message.isOutgoing) { val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - messageStatusImageView.isVisible = !message.isSent || message.id == lastMessageID + binding.messageStatusImageView.isVisible = !message.isSent || message.id == lastMessageID } else { - messageStatusImageView.isVisible = false + binding.messageStatusImageView.isVisible = false } // Expiration timer updateExpirationTimer(message) // Calculate max message bubble width var maxWidth = screenWidth - startPadding - endPadding - if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width } + if (binding.profilePictureContainer.visibility != View.GONE) { maxWidth -= binding.profilePictureContainer.width } // Populate content view - messageContentView.indexInAdapter = indexInAdapter - messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery, isGroupThread || (contact?.isTrusted ?: false)) - messageContentView.delegate = contentViewDelegate - onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() } + binding.messageContentView.indexInAdapter = indexInAdapter + binding.messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery, message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)) + binding.messageContentView.delegate = contentViewDelegate + onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } } private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { @@ -207,29 +223,31 @@ class VisibleMessageView : LinearLayout { } private fun updateExpirationTimer(message: MessageRecord) { - val expirationTimerViewLayoutParams = expirationTimerView.layoutParams as RelativeLayout.LayoutParams - val ruleToAdd = if (message.isOutgoing) RelativeLayout.ALIGN_START else RelativeLayout.ALIGN_END - val ruleToRemove = if (message.isOutgoing) RelativeLayout.ALIGN_END else RelativeLayout.ALIGN_START - expirationTimerViewLayoutParams.removeRule(ruleToRemove) - expirationTimerViewLayoutParams.addRule(ruleToAdd, R.id.messageContentView) + val expirationTimerViewLayoutParams = binding.expirationTimerView.layoutParams as MarginLayoutParams + val container = binding.expirationTimerViewContainer + val content = binding.messageContentView + val expiration = binding.expirationTimerView + container.removeAllViewsInLayout() + container.addView(if (message.isOutgoing) expiration else content) + container.addView(if (message.isOutgoing) content else expiration) val expirationTimerViewSize = toPx(12, resources) val smallSpacing = resources.getDimension(R.dimen.small_spacing).roundToInt() expirationTimerViewLayoutParams.marginStart = if (message.isOutgoing) -(smallSpacing + expirationTimerViewSize) else 0 expirationTimerViewLayoutParams.marginEnd = if (message.isOutgoing) 0 else -(smallSpacing + expirationTimerViewSize) - expirationTimerView.layoutParams = expirationTimerViewLayoutParams + binding.expirationTimerView.layoutParams = expirationTimerViewLayoutParams if (message.expiresIn > 0 && !message.isPending) { - expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme)) - expirationTimerView.isVisible = true - expirationTimerView.setPercentComplete(0.0f) + binding.expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme)) + binding.expirationTimerView.isVisible = true + binding.expirationTimerView.setPercentComplete(0.0f) if (message.expireStarted > 0) { - expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) - expirationTimerView.startAnimation() + binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + binding.expirationTimerView.startAnimation() if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) { ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule() } } else if (!message.isMediaPending) { - expirationTimerView.setPercentComplete(0.0f) - expirationTimerView.stopAnimation() + binding.expirationTimerView.setPercentComplete(0.0f) + binding.expirationTimerView.stopAnimation() ThreadUtils.queue { val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager val id = message.getId() @@ -238,12 +256,13 @@ class VisibleMessageView : LinearLayout { expirationManager.scheduleDeletion(id, mms, message.expiresIn) } } else { - expirationTimerView.stopAnimation() - expirationTimerView.setPercentComplete(0.0f) + binding.expirationTimerView.stopAnimation() + binding.expirationTimerView.setPercentComplete(0.0f) } } else { - expirationTimerView.isVisible = false + binding.expirationTimerView.isVisible = false } + container.requestLayout() } private fun handleIsSelectedChanged() { @@ -255,14 +274,14 @@ class VisibleMessageView : LinearLayout { } override fun onDraw(canvas: Canvas) { - if (translationX < 0 && !expirationTimerView.isVisible) { + if (translationX < 0 && !binding.expirationTimerView.isVisible) { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) - val threshold = VisibleMessageView.swipeToReplyThreshold + val threshold = swipeToReplyThreshold val iconSize = toPx(24, context.resources) - val bottomVOffset = paddingBottom + messageStatusImageView.height + (messageContentView.height - iconSize) / 2 - swipeToReplyIconRect.left = messageContentContainer.right - messageContentContainer.paddingEnd + spacing + val bottomVOffset = paddingBottom + binding.messageStatusImageView.height + (binding.messageContentView.height - iconSize) / 2 + swipeToReplyIconRect.left = binding.messageContentContainer.right - binding.messageContentContainer.paddingEnd + spacing swipeToReplyIconRect.top = height - bottomVOffset - iconSize - swipeToReplyIconRect.right = messageContentContainer.right - messageContentContainer.paddingEnd + iconSize + spacing + swipeToReplyIconRect.right = binding.messageContentContainer.right - binding.messageContentContainer.paddingEnd + iconSize + spacing swipeToReplyIconRect.bottom = height - bottomVOffset swipeToReplyIcon.bounds = swipeToReplyIconRect swipeToReplyIcon.alpha = (255.0f * (min(abs(translationX), threshold) / threshold)).roundToInt() @@ -274,8 +293,8 @@ class VisibleMessageView : LinearLayout { } fun recycle() { - profilePictureView.recycle() - messageContentView.recycle() + binding.profilePictureView.recycle() + binding.messageContentView.recycle() } // endregion @@ -296,13 +315,13 @@ class VisibleMessageView : LinearLayout { longPressCallback?.let { gestureHandler.removeCallbacks(it) } val newLongPressCallback = Runnable { onLongPress() } this.longPressCallback = newLongPressCallback - gestureHandler.postDelayed(newLongPressCallback, VisibleMessageView.longPressDurationThreshold) + gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold) onDownTimestamp = Date().time } private fun onMove(event: MotionEvent) { val translationX = toDp(event.rawX + dx, context.resources) - if (abs(translationX) < VisibleMessageView.longPressMovementTreshold || snIsSelected) { + if (abs(translationX) < longPressMovementThreshold || snIsSelected) { return } else { longPressCallback?.let { gestureHandler.removeCallbacks(it) } @@ -313,20 +332,16 @@ class VisibleMessageView : LinearLayout { val sign = -1.0f val x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign this.translationX = x - this.dateBreakTextView.translationX = -x // Bit of a hack to keep the date break text view from moving + binding.dateBreakTextView.translationX = -x // Bit of a hack to keep the date break text view from moving postInvalidate() // Ensure onDraw(canvas:) is called - if (abs(x) > VisibleMessageView.swipeToReplyThreshold && abs(previousTranslationX) < VisibleMessageView.swipeToReplyThreshold) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } else { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } + if (abs(x) > swipeToReplyThreshold && abs(previousTranslationX) < swipeToReplyThreshold) { + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } previousTranslationX = x } private fun onCancel(event: MotionEvent) { - if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) { + if (abs(translationX) > swipeToReplyThreshold) { onSwipeToReply?.invoke() } longPressCallback?.let { gestureHandler.removeCallbacks(it) } @@ -334,9 +349,9 @@ class VisibleMessageView : LinearLayout { } private fun onUp(event: MotionEvent) { - if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) { + if (abs(translationX) > swipeToReplyThreshold) { onSwipeToReply?.invoke() - } else if ((Date().time - onDownTimestamp) < VisibleMessageView.longPressDurationThreshold) { + } else if ((Date().time - onDownTimestamp) < longPressDurationThreshold) { longPressCallback?.let { gestureHandler.removeCallbacks(it) } val pressCallback = this.pressCallback if (pressCallback != null) { @@ -363,7 +378,7 @@ class VisibleMessageView : LinearLayout { } .start() // Bit of a hack to keep the date break text view from moving - dateBreakTextView.animate() + binding.dateBreakTextView.animate() .translationX(0.0f) .setDuration(150) .start() @@ -375,7 +390,7 @@ class VisibleMessageView : LinearLayout { } fun onContentClick(event: MotionEvent) { - messageContentView.onContentClick?.invoke(event) + binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } } private fun onPress(event: MotionEvent) { @@ -393,5 +408,9 @@ class VisibleMessageView : LinearLayout { val activity = context as AppCompatActivity userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag) } + + fun playVoiceMessage() { + binding.messageContentView.playVoiceMessage() + } // endregion } 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 85f0bccc5..6fca9ce6b 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 @@ -9,8 +9,8 @@ import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.view_voice_message.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewVoiceMessageBinding import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.components.CornerMask @@ -26,6 +26,7 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { @Inject lateinit var attachmentDb: AttachmentDatabase + private lateinit var binding: ViewVoiceMessageBinding private val cornerMask by lazy { CornerMask(this) } private var isPlaying = false set(value) { @@ -44,8 +45,8 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_voice_message, this) - voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + binding = ViewVoiceMessageBinding.inflate(LayoutInflater.from(context), this, true) + binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(0), TimeUnit.MILLISECONDS.toSeconds(0)) } @@ -54,7 +55,7 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { // region Updating fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { val audio = message.slideDeck.audioSlide!! - voiceMessageViewLoader.isVisible = audio.isInProgress + binding.voiceMessageViewLoader.isVisible = audio.isInProgress val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) cornerMask.setTopLeftRadius(cornerRadii[0]) cornerMask.setTopRightRadius(cornerRadii[1]) @@ -74,8 +75,8 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { attachmentDb.getAttachmentAudioExtras(attachment.attachmentId)?.let { audioExtras -> if (audioExtras.durationMs > 0) { duration = audioExtras.durationMs - voiceMessageViewDurationTextView.visibility = View.VISIBLE - voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + binding.voiceMessageViewDurationTextView.visibility = View.VISIBLE + binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs), TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs)) } @@ -99,12 +100,12 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { private fun handleProgressChanged(progress: Double) { this.progress = progress - voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()), TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong())) - val layoutParams = progressView.layoutParams as RelativeLayout.LayoutParams + val layoutParams = binding.progressView.layoutParams as RelativeLayout.LayoutParams layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt() - progressView.layoutParams = layoutParams + binding.progressView.layoutParams = layoutParams } override fun onPlayerStop(player: AudioSlidePlayer) { @@ -118,7 +119,7 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { private fun renderIcon() { val iconID = if (isPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play - voiceMessagePlaybackImageView.setImageResource(iconID) + binding.voiceMessagePlaybackImageView.setImageResource(iconID) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt index da8df0045..afed74b1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -5,11 +5,12 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout -import kotlinx.android.synthetic.main.view_search_bottom_bar.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewSearchBottomBarBinding class SearchBottomBar : LinearLayout { + private lateinit var binding: ViewSearchBottomBarBinding private var eventListener: EventListener? = null // region Lifecycle @@ -18,10 +19,10 @@ class SearchBottomBar : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_search_bottom_bar, this) + binding = ViewSearchBottomBarBinding.inflate(LayoutInflater.from(context), this, true) } - fun setData(position: Int, count: Int) { + fun setData(position: Int, count: Int) = with(binding) { searchProgressWheel.visibility = GONE searchUp.setOnClickListener { v: View? -> if (eventListener != null) { @@ -43,7 +44,7 @@ class SearchBottomBar : LinearLayout { } fun showLoading() { - searchProgressWheel.visibility = VISIBLE + binding.searchProgressWheel.visibility = VISIBLE } private fun setViewEnabled(view: View, enabled: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index 2a7dd099b..48bb731c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -11,6 +11,7 @@ import org.session.libsession.utilities.concurrent.SignalExecutors import org.thoughtcrime.securesms.contacts.ContactAccessor import org.thoughtcrime.securesms.database.CursorList import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.MessageResult @@ -20,14 +21,11 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( - @ApplicationContext context: Context, - searchDb: SearchDatabase, - threadDb: ThreadDatabase + private val searchRepository: SearchRepository ) : ViewModel() { - private val searchRepository: SearchRepository - private val result: CloseableLiveData - private val debouncer: Debouncer + private val result: CloseableLiveData = CloseableLiveData() + private val debouncer: Debouncer = Debouncer(500) private var firstSearch = false private var searchOpen = false private var activeQuery: String? = null @@ -107,13 +105,4 @@ class SearchViewModel @Inject constructor( } } - init { - result = CloseableLiveData() - debouncer = Debouncer(500) - searchRepository = SearchRepository(context, - searchDb, - threadDb, - ContactAccessor.getInstance(), - SignalExecutors.SERIAL) - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt index 80b233c42..d01d86b6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout import androidx.core.view.isVisible @@ -13,8 +14,8 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestOptions -import kotlinx.android.synthetic.main.thumbnail_view.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ThumbnailViewBinding import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.utilities.Util.equals import org.session.libsignal.utilities.ListenableFuture @@ -22,11 +23,13 @@ import org.session.libsignal.utilities.SettableFuture import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.mms.* import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.mms.GlideRequest +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.Slide open class KThumbnailView: FrameLayout { - + private lateinit var binding: ThumbnailViewBinding companion object { private const val WIDTH = 0 private const val HEIGHT = 1 @@ -37,10 +40,10 @@ open class KThumbnailView: FrameLayout { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } - private val image by lazy { thumbnail_image } - private val playOverlay by lazy { play_overlay } - val loadIndicator: View by lazy { thumbnail_load_indicator } - val downloadIndicator: View by lazy { thumbnail_download_icon } + private val image by lazy { binding.thumbnailImage } + private val playOverlay by lazy { binding.playOverlay } + val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } + val downloadIndicator: View by lazy { binding.thumbnailDownloadIcon } private val dimensDelegate = ThumbnailDimensDelegate() @@ -48,7 +51,7 @@ open class KThumbnailView: FrameLayout { private var radius: Int = 0 private fun initialize(attrs: AttributeSet?) { - inflate(context, R.layout.thumbnail_view, this) + binding = ThumbnailViewBinding.inflate(LayoutInflater.from(context), this) if (attrs != null) { val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java index f40a57924..6a6ead67d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java @@ -1,19 +1,19 @@ package org.thoughtcrime.securesms.conversation.v2.utilities; +import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; + import android.content.Context; import android.content.res.TypedArray; import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.UiThread; import android.util.AttributeSet; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.session.libsignal.utilities.Log; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; + import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; @@ -22,8 +22,13 @@ import com.bumptech.glide.load.resource.bitmap.FitCenter; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.request.RequestOptions; -import network.loki.messenger.R; - +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.ViewUtil; +import org.session.libsignal.utilities.ListenableFuture; +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.SettableFuture; +import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget; import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget; import org.thoughtcrime.securesms.components.TransferControlView; @@ -33,17 +38,11 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.SettableFuture; import java.util.Collections; import java.util.Locale; -import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; +import network.loki.messenger.R; public class ThumbnailView extends FrameLayout { @@ -287,7 +286,7 @@ public class ThumbnailView extends FrameLayout { } else if (slide.hasPlaceholder()) { buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(image, result)); } else { - glideRequests.clear(image); + glideRequests.load(R.drawable.ic_image_white_24dp).centerInside().into(image); result.set(false); } 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 a9042ed39..aa80bdb17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -29,6 +29,7 @@ import org.session.libsignal.database.LokiOpenGroupDatabaseProtocol; import java.io.Closeable; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -111,7 +112,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt } } - Optional getGroup(Cursor cursor) { + public Optional getGroup(Cursor cursor) { Reader reader = new Reader(cursor); return Optional.fromNullable(reader.getCurrent()); } @@ -146,6 +147,29 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt return groups; } + public Cursor getGroupsFilteredByMembers(List members) { + if (members == null || members.isEmpty()) { + return null; + } + + String[] queriesValues = new String[members.size()]; + + StringBuilder queries = new StringBuilder(); + for (int i=0; i < members.size(); i++) { + boolean isEnd = i == (members.size() - 1); + queries.append(MEMBERS + " LIKE ?"); + queriesValues[i] = "%"+members.get(i)+"%"; + if (!isEnd) { + queries.append(" OR "); + } + } + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, null, + queries.toString(), + queriesValues, + null, null, null); + } + public @NonNull List getGroupMembers(String groupId, boolean includeSelf) { List
members = getCurrentMembers(groupId, false); List recipients = new LinkedList<>(); 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 ab0e0ba0f..7f32fab1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -450,7 +450,7 @@ private inline fun wrap(x: T): Array { private fun wrap(x: Map): ContentValues { val result = ContentValues(x.size) - x.forEach { result.put(it.key, it.value) } + x.iterator().forEach { result.put(it.key, it.value) } return result } // endregion \ No newline at end of file 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 54d07afe0..f9d524010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -139,7 +139,7 @@ public class MmsSmsDatabase extends Database { try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { cursor.moveToFirst(); - return cursor.getLong(cursor.getColumnIndex(MmsSmsColumns.ID)); + return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); } } @@ -157,7 +157,7 @@ public class MmsSmsDatabase extends Database { try { return cursor != null ? cursor.getCount() : 0; } finally { - if (cursor != null) cursor.close();; + if (cursor != null) cursor.close(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java index 798f34e00..37efc9a43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.database; import android.content.Context; + import androidx.annotation.NonNull; import com.annimon.stream.Stream; @@ -8,8 +9,8 @@ import com.annimon.stream.Stream; import net.sqlcipher.Cursor; import net.sqlcipher.database.SQLiteDatabase; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.session.libsession.utilities.Util; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import java.util.List; @@ -80,7 +81,7 @@ public class SearchDatabase extends Database { "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + - "LIMIT 500"; + "LIMIT ?"; private static final String MESSAGES_FOR_THREAD_QUERY = "SELECT " + @@ -115,7 +116,9 @@ public class SearchDatabase extends Database { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String prefixQuery = adjustQuery(query); - Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery }); + int queryLimit = Math.min(query.length()*50,500); + + Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) }); setNotifyConverationListListeners(cursor); return cursor; } 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 9bcf94ec1..ef9f0cc38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context +import androidx.core.database.getStringOrNull import net.sqlcipher.Cursor import org.session.libsession.messaging.contacts.Contact import org.session.libsignal.utilities.Base64 @@ -73,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da notifyConversationListListeners() } - private fun contactFromCursor(cursor: Cursor): Contact { + fun contactFromCursor(cursor: Cursor): Contact { val sessionID = cursor.getString(sessionID) val contact = Contact(sessionID) contact.name = cursor.getStringOrNull(name) @@ -87,4 +88,29 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da contact.isTrusted = cursor.getInt(isTrusted) != 0 return contact } + + fun contactFromCursor(cursor: android.database.Cursor): Contact { + val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) + val contact = Contact(sessionID) + contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) + contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) + contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) + contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName)) + cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let { + contact.profilePictureEncryptionKey = Base64.decode(it) + } + contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID)) + contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0 + return contact + } + + fun queryContactsByName(constraint: String): Cursor { + return databaseHelper.readableDatabase.query( + sessionContactTable, null, " $name LIKE ? OR $nickname LIKE ?", arrayOf( + "%$constraint%", + "%$constraint%" + ), + null, null, null + ) + } } \ No newline at end of file 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 2aa02a80d..84c7de34e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -45,6 +45,7 @@ import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -55,6 +56,7 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.util.SessionMetaProtocol; import java.io.Closeable; @@ -337,6 +339,19 @@ public class ThreadDatabase extends Database { } + public Cursor searchConversationAddresses(String addressQuery) { + if (addressQuery == null || addressQuery.isEmpty()) { + return null; + } + + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String selection = TABLE_NAME + "." + ADDRESS + " LIKE ? AND " + TABLE_NAME + "." + MESSAGE_COUNT + " != 0"; + String[] selectionArgs = new String[]{addressQuery+"%"}; + String query = createQuery(selection, 0); + Cursor cursor = db.rawQuery(query, selectionArgs); + return cursor; + } + public Cursor getFilteredConversationList(@Nullable List
filter) { if (filter == null || filter.size() == 0) return null; @@ -593,6 +608,18 @@ public class ThreadDatabase extends Database { notifyConversationListeners(threadId); } + public void markAllAsRead(long threadId, boolean isGroupRecipient) { + List messages = setRead(threadId, true); + if (isGroupRecipient) { + for (MarkedMessageInfo message: messages) { + MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); + } + } else { + MarkReadReceiver.process(context, messages); + } + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0); + } + private boolean deleteThreadOnEmpty(long threadId) { Recipient threadRecipient = getRecipientForThreadId(threadId); return threadRecipient != null && !threadRecipient.isOpenGroupRecipient(); @@ -692,14 +719,14 @@ public class ThreadDatabase extends Database { long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)); int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)); long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE)); - boolean archived = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.ARCHIVED)) != 0; + boolean archived = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.ARCHIVED)) != 0; int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS)); int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT)); int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)); long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN)); long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); Uri snippetUri = getSnippetUri(cursor); - boolean pinned = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.IS_PINNED)) != 0; + boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0; if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt new file mode 100644 index 000000000..6f26c6ae3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.repository.DefaultConversationRepository + +@Module +@InstallIn(SingletonComponent::class) +abstract class AppModule { + + @Binds + abstract fun bindTextSecurePreferences(preferences: AppTextSecurePreferences): TextSecurePreferences + + @Binds + abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt index c1971fa05..2aecad34b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt @@ -9,16 +9,18 @@ import android.content.Intent import android.os.Bundle import android.text.InputType import android.util.TypedValue -import android.view.* +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter -import kotlinx.android.synthetic.main.activity_create_private_chat.* -import kotlinx.android.synthetic.main.fragment_enter_public_key.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityCreatePrivateChatBinding +import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.snode.SnodeAPI @@ -27,13 +29,13 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity - import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + private lateinit var binding: ActivityCreatePrivateChatBinding private val adapter = CreatePrivateChatActivityAdapter(this) private var isKeyboardShowing = false set(value) { @@ -47,37 +49,36 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + binding = ActivityCreatePrivateChatBinding.inflate(layoutInflater) // Set content view - setContentView(R.layout.activity_create_private_chat) + setContentView(binding.root) // Set title supportActionBar!!.title = resources.getString(R.string.activity_create_private_chat_title) // Set up view pager - viewPager.adapter = adapter - tabLayout.setupWithViewPager(viewPager) - rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - - override fun onGlobalLayout() { - val diff = rootLayout.rootView.height - rootLayout.height - val displayMetrics = this@CreatePrivateChatActivity.resources.displayMetrics - val estimatedKeyboardHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200.0f, displayMetrics) - this@CreatePrivateChatActivity.isKeyboardShowing = (diff > estimatedKeyboardHeight) - } - }) + binding.viewPager.adapter = adapter + binding.tabLayout.setupWithViewPager(binding.viewPager) + binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener { + val diff = binding.rootLayout.rootView.height - binding.rootLayout.height + val displayMetrics = this@CreatePrivateChatActivity.resources.displayMetrics + val estimatedKeyboardHeight = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200.0f, displayMetrics) + this@CreatePrivateChatActivity.isKeyboardShowing = (diff > estimatedKeyboardHeight) + } } // endregion // region Updating private fun showLoader() { - loader.visibility = View.VISIBLE - loader.animate().setDuration(150).alpha(1.0f).start() + binding.loader.visibility = View.VISIBLE + binding.loader.animate().setDuration(150).alpha(1.0f).start() } private fun hideLoader() { - loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { + binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { super.onAnimationEnd(animation) - loader.visibility = View.GONE + binding.loader.visibility = View.GONE } }) } @@ -156,6 +157,8 @@ private class CreatePrivateChatActivityAdapter(val activity: CreatePrivateChatAc // region Enter Public Key Fragment class EnterPublicKeyFragment : Fragment() { + private lateinit var binding: FragmentEnterPublicKeyBinding + var isKeyboardShowing = false set(value) { field = value; handleIsKeyboardShowingChanged() } @@ -165,32 +168,34 @@ class EnterPublicKeyFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return inflater.inflate(R.layout.fragment_enter_public_key, container, false) + binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard - publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) - publicKeyEditText.setOnEditorActionListener { v, actionID, _ -> - if (actionID == EditorInfo.IME_ACTION_DONE) { - val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(v.windowToken, 0) - createPrivateChatIfPossible() - true - } else { - false + with(binding) { + publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard + publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) + publicKeyEditText.setOnEditorActionListener { v, actionID, _ -> + if (actionID == EditorInfo.IME_ACTION_DONE) { + val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(v.windowToken, 0) + createPrivateChatIfPossible() + true + } else { + false + } } + publicKeyTextView.text = hexEncodedPublicKey + copyButton.setOnClickListener { copyPublicKey() } + shareButton.setOnClickListener { sharePublicKey() } + createPrivateChatButton.setOnClickListener { createPrivateChatIfPossible() } } - publicKeyTextView.text = hexEncodedPublicKey - copyButton.setOnClickListener { copyPublicKey() } - shareButton.setOnClickListener { sharePublicKey() } - createPrivateChatButton.setOnClickListener { createPrivateChatIfPossible() } } private fun handleIsKeyboardShowingChanged() { - val optionalContentContainer = optionalContentContainer ?: return - optionalContentContainer.isVisible = !isKeyboardShowing + binding.optionalContentContainer.isVisible = !isKeyboardShowing } private fun copyPublicKey() { @@ -209,7 +214,7 @@ class EnterPublicKeyFragment : Fragment() { } private fun createPrivateChatIfPossible() { - val hexEncodedPublicKey = publicKeyEditText.text?.trim().toString() + val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim().toString() val activity = requireActivity() as CreatePrivateChatActivity activity.createPrivateChatIfPossible(hexEncodedPublicKey) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt index f5a75041a..4991d3098 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt @@ -1,22 +1,23 @@ package org.thoughtcrime.securesms.groups import android.os.Bundle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import kotlinx.android.synthetic.main.fragment_closed_group_edit_bottom_sheet.* -import network.loki.messenger.R +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import network.loki.messenger.databinding.FragmentClosedGroupEditBottomSheetBinding -public class ClosedGroupEditingOptionsBottomSheet : BottomSheetDialogFragment() { +class ClosedGroupEditingOptionsBottomSheet : BottomSheetDialogFragment() { + private lateinit var binding: FragmentClosedGroupEditBottomSheetBinding var onRemoveTapped: (() -> Unit)? = null - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_closed_group_edit_bottom_sheet, container, false) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentClosedGroupEditBottomSheetBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - removeFromGroup.setOnClickListener { onRemoveTapped?.invoke() } + binding.removeFromGroup.setOnClickListener { onRemoveTapped?.invoke() } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt index 85c0bd3dd..63894344d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt @@ -10,8 +10,8 @@ import android.widget.Toast import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.synthetic.main.activity_create_closed_group.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityCreateClosedGroupBinding import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.sending_receiving.MessageSender @@ -28,8 +28,8 @@ import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut -//TODO Refactor to avoid using kotlinx.android.synthetic class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderManager.LoaderCallbacks> { + private lateinit var binding: ActivityCreateClosedGroupBinding private var isLoading = false set(newValue) { field = newValue; invalidateOptionsMenu() } private var members = listOf() @@ -50,11 +50,12 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_create_closed_group) + binding = ActivityCreateClosedGroupBinding.inflate(layoutInflater) + setContentView(binding.root) supportActionBar!!.title = resources.getString(R.string.activity_create_closed_group_title) - recyclerView.adapter = this.selectContactsAdapter - recyclerView.layoutManager = LinearLayoutManager(this) - createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } + binding.recyclerView.adapter = this.selectContactsAdapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } LoaderManager.getInstance(this).initLoader(0, null, this) } @@ -80,8 +81,8 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM private fun update(members: List) { //if there is a Note to self conversation, it loads self in the list, so we need to remove it here this.members = members.minus(publicKey) - mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE - emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE + binding.mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE + binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE invalidateOptionsMenu() } // endregion @@ -95,12 +96,12 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM } private fun createNewPrivateChat() { - setResult(Companion.closedGroupCreatedResultCode) + setResult(closedGroupCreatedResultCode) finish() } private fun createClosedGroup() { - val name = nameEditText.text.trim() + val name = binding.nameEditText.text.trim() if (name.isEmpty()) { return Toast.makeText(this, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() } @@ -116,9 +117,9 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM } val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! isLoading = true - loaderContainer.fadeIn() + binding.loaderContainer.fadeIn() MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> - loaderContainer.fadeOut() + binding.loaderContainer.fadeOut() isLoading = false val threadID = DatabaseComponent.get(this).threadDatabase().getOrCreateThreadIdFor(Recipient.from(this, Address.fromSerialized(groupID), false)) if (!isFinishing) { @@ -126,7 +127,7 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM finish() } }.failUi { - loaderContainer.fadeOut() + binding.loaderContainer.fadeOut() isLoading = false Toast.makeText(this, it.message, Toast.LENGTH_LONG).show() } 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 5ffe42492..62e762316 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -8,12 +8,14 @@ import android.view.MenuItem import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager -import android.widget.* +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.activity_settings.* import network.loki.messenger.R import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt index 5d6d559a8..ba87e8706 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt @@ -13,62 +13,63 @@ import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.core.view.isVisible -import androidx.fragment.app.* +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.chip.Chip -import kotlinx.android.synthetic.main.activity_join_public_chat.* -import kotlinx.android.synthetic.main.fragment_enter_chat_url.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityJoinPublicChatBinding +import network.loki.messenger.databinding.FragmentEnterChatUrlBinding import okhttp3.HttpUrl import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.groups.DefaultGroupsViewModel -import org.thoughtcrime.securesms.groups.GroupManager -import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.State -import java.util.* +import java.util.Locale class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + private lateinit var binding: ActivityJoinPublicChatBinding private val adapter = JoinPublicChatActivityAdapter(this) // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + binding = ActivityJoinPublicChatBinding.inflate(layoutInflater) // Set content view - setContentView(R.layout.activity_join_public_chat) + setContentView(binding.root) // Set title supportActionBar!!.title = resources.getString(R.string.activity_join_public_chat_title) // Set up view pager - viewPager.adapter = adapter - tabLayout.setupWithViewPager(viewPager) + binding.viewPager.adapter = adapter + binding.tabLayout.setupWithViewPager(binding.viewPager) } // endregion // region Updating private fun showLoader() { - loader.visibility = View.VISIBLE - loader.animate().setDuration(150).alpha(1.0f).start() + binding.loader.visibility = View.VISIBLE + binding.loader.animate().setDuration(150).alpha(1.0f).start() } private fun hideLoader() { - loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { + binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { super.onAnimationEnd(animation) - loader.visibility = View.GONE + binding.loader.visibility = View.GONE } }) } @@ -166,26 +167,28 @@ private class JoinPublicChatActivityAdapter(val activity: JoinPublicChatActivity // region Enter Chat URL Fragment class EnterChatURLFragment : Fragment() { + private lateinit var binding: FragmentEnterChatUrlBinding private val viewModel by activityViewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return inflater.inflate(R.layout.fragment_enter_chat_url, container, false) + binding = FragmentEnterChatUrlBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - chatURLEditText.imeOptions = chatURLEditText.imeOptions or 16777216 // Always use incognito keyboard - joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() } + binding.chatURLEditText.imeOptions = binding.chatURLEditText.imeOptions or 16777216 // Always use incognito keyboard + binding.joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() } viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> - defaultRoomsContainer.isVisible = state is State.Success - defaultRoomsLoaderContainer.isVisible = state is State.Loading - defaultRoomsLoader.isVisible = state is State.Loading + binding.defaultRoomsContainer.isVisible = state is State.Success + binding.defaultRoomsLoaderContainer.isVisible = state is State.Loading + binding.defaultRoomsLoader.isVisible = state is State.Loading when (state) { State.Loading -> { - // TODO: Show a loader + // TODO: Show a binding.loader } is State.Error -> { - // TODO: Hide the loader + // TODO: Hide the binding.loader } is State.Success -> { populateDefaultGroups(state.value) @@ -195,10 +198,10 @@ class EnterChatURLFragment : Fragment() { } private fun populateDefaultGroups(groups: List) { - defaultRoomsGridLayout.removeAllViews() - defaultRoomsGridLayout.useDefaultMargins = false - groups.forEach { defaultGroup -> - val chip = layoutInflater.inflate(R.layout.default_group_chip, defaultRoomsGridLayout, false) as Chip + binding.defaultRoomsGridLayout.removeAllViews() + binding.defaultRoomsGridLayout.useDefaultMargins = false + groups.iterator().forEach { defaultGroup -> + val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsGridLayout, false) as Chip val drawable = defaultGroup.image?.let { bytes -> val bitmap = BitmapFactory.decodeByteArray(bytes,0, bytes.size) RoundedBitmapDrawableFactory.create(resources,bitmap).apply { @@ -210,18 +213,18 @@ class EnterChatURLFragment : Fragment() { chip.setOnClickListener { (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(defaultGroup.joinURL) } - defaultRoomsGridLayout.addView(chip) + binding.defaultRoomsGridLayout.addView(chip) } if ((groups.size and 1) != 0) { // This checks that the number of rooms is even - layoutInflater.inflate(R.layout.grid_layout_filler, defaultRoomsGridLayout) + layoutInflater.inflate(R.layout.grid_layout_filler, binding.defaultRoomsGridLayout) } } // region Convenience private fun joinPublicChatIfPossible() { val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(chatURLEditText.windowToken, 0) - val chatURL = chatURLEditText.text.trim().toString().toLowerCase(Locale.US) + inputMethodManager.hideSoftInputFromWindow(binding.chatURLEditText.windowToken, 0) + val chatURL = binding.chatURLEditText.text.trim().toString().toLowerCase(Locale.US) (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(chatURL) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupGuidelinesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupGuidelinesActivity.kt index 3b7827679..775c46768 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupGuidelinesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupGuidelinesActivity.kt @@ -1,16 +1,16 @@ package org.thoughtcrime.securesms.groups import android.os.Bundle -import kotlinx.android.synthetic.main.activity_open_group_guidelines.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityOpenGroupGuidelinesBinding import org.thoughtcrime.securesms.BaseActionBarActivity class OpenGroupGuidelinesActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_open_group_guidelines) - communityGuidelinesTextView.text = """ + val binding = ActivityOpenGroupGuidelinesBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.communityGuidelinesTextView.text = """ Welcome to Oxen. Oxen believes privacy is an important part of our future. People have been safeguarding the right to privacy since the dawn of humanity, but the digital world has turned privacy into a privilege. Enough is enough. We're taking it back. For you. For us. For everyone. 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 40f41d23d..01a1a3241 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -6,13 +6,12 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.android.synthetic.main.fragment_conversation_bottom_sheet.* -import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.util.UiModeUtilities -public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener { - +class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener { + private lateinit var binding: FragmentConversationBottomSheetBinding //FIXME AC: Supplying a threadRecord directly into the field from an activity // is not the best idea. It doesn't survive configuration change. // We should be dealing with IDs and all sorts of serializable data instead @@ -25,24 +24,27 @@ public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View. var onBlockTapped: (() -> Unit)? = null var onUnblockTapped: (() -> Unit)? = null var onDeleteTapped: (() -> Unit)? = null + var onMarkAllAsReadTapped: (() -> Unit)? = null var onNotificationTapped: (() -> Unit)? = null var onSetMuteTapped: ((Boolean) -> Unit)? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_conversation_bottom_sheet, container, false) + binding = FragmentConversationBottomSheetBinding.inflate(inflater, container, false) + return binding.root } override fun onClick(v: View?) { when (v) { - detailsTextView -> onViewDetailsTapped?.invoke() - pinTextView -> onPinTapped?.invoke() - unpinTextView -> onUnpinTapped?.invoke() - blockTextView -> onBlockTapped?.invoke() - unblockTextView -> onUnblockTapped?.invoke() - deleteTextView -> onDeleteTapped?.invoke() - notificationsTextView -> onNotificationTapped?.invoke() - unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) - muteNotificationsTextView -> onSetMuteTapped?.invoke(true) + binding.detailsTextView -> onViewDetailsTapped?.invoke() + binding.pinTextView -> onPinTapped?.invoke() + binding.unpinTextView -> onUnpinTapped?.invoke() + binding.blockTextView -> onBlockTapped?.invoke() + binding.unblockTextView -> onUnblockTapped?.invoke() + binding.deleteTextView -> onDeleteTapped?.invoke() + binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() + binding.notificationsTextView -> onNotificationTapped?.invoke() + binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) + binding.muteNotificationsTextView -> onSetMuteTapped?.invoke(true) } } @@ -51,26 +53,28 @@ public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View. if (!this::thread.isInitialized) { return dismiss() } val recipient = thread.recipient if (!recipient.isGroupRecipient && !recipient.isLocalNumber) { - detailsTextView.visibility = View.VISIBLE - unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE - blockTextView.visibility = if (recipient.isBlocked) View.GONE else View.VISIBLE - detailsTextView.setOnClickListener(this) - blockTextView.setOnClickListener(this) - unblockTextView.setOnClickListener(this) + binding.detailsTextView.visibility = View.VISIBLE + binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE + binding.blockTextView.visibility = if (recipient.isBlocked) View.GONE else View.VISIBLE + binding.detailsTextView.setOnClickListener(this) + binding.blockTextView.setOnClickListener(this) + binding.unblockTextView.setOnClickListener(this) } else { - detailsTextView.visibility = View.GONE + binding.detailsTextView.visibility = View.GONE } - unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber - muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber - unMuteNotificationsTextView.setOnClickListener(this) - muteNotificationsTextView.setOnClickListener(this) - notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted - notificationsTextView.setOnClickListener(this) - deleteTextView.setOnClickListener(this) - pinTextView.isVisible = !thread.isPinned - unpinTextView.isVisible = thread.isPinned - pinTextView.setOnClickListener(this) - unpinTextView.setOnClickListener(this) + binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber + binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber + binding.unMuteNotificationsTextView.setOnClickListener(this) + binding.muteNotificationsTextView.setOnClickListener(this) + binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted + binding.notificationsTextView.setOnClickListener(this) + binding.deleteTextView.setOnClickListener(this) + binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 + binding.markAllAsReadTextView.setOnClickListener(this) + binding.pinTextView.isVisible = !thread.isPinned + binding.unpinTextView.isVisible = thread.isPinned + binding.pinTextView.setOnClickListener(this) + binding.unpinTextView.setOnClickListener(this) } override fun onStart() { 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 679b26d6d..3e4a71200 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -11,8 +11,8 @@ import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.view_conversation.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientDatabase @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.util.DateUtils import java.util.Locale class ConversationView : LinearLayout { + private lateinit var binding: ViewConversationBinding private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null @@ -31,7 +32,7 @@ class ConversationView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } private fun initialize() { - LayoutInflater.from(context).inflate(R.layout.view_conversation, this) + binding = ViewConversationBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = RecyclerView.LayoutParams(screenWidth, RecyclerView.LayoutParams.WRAP_CONTENT) } // endregion @@ -39,84 +40,84 @@ class ConversationView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) { this.thread = thread - if (thread.isPinned) { - conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_pin, 0) - background = ContextCompat.getDrawable(context, R.drawable.conversation_pinned_background) + background = if (thread.isPinned) { + binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_pin, 0) + ContextCompat.getDrawable(context, R.drawable.conversation_pinned_background) } else { - conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - background = ContextCompat.getDrawable(context, R.drawable.conversation_view_background) + binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } - profilePictureView.glide = glide + binding.profilePictureView.glide = glide val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { - accentView.setBackgroundResource(R.color.destructive) - accentView.visibility = View.VISIBLE + binding.accentView.setBackgroundResource(R.color.destructive) + binding.accentView.visibility = View.VISIBLE } else { - accentView.setBackgroundResource(R.color.accent) + binding.accentView.setBackgroundResource(R.color.accent) // Using thread.isRead we can determine if the last message was our own, and display it as 'read' even though previous messages may not be // This would also not trigger the disappearing message timer which may or may not be desirable - accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE + binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE } val formattedUnreadCount = if (thread.isRead) { null } else { - if (unreadCount < 100) unreadCount.toString() else "99+" + if (unreadCount < 10000) unreadCount.toString() else "9999+" } - unreadCountTextView.text = formattedUnreadCount - val textSize = if (unreadCount < 100) 12.0f else 9.0f - unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) - unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) + binding.unreadCountTextView.text = formattedUnreadCount + val textSize = if (unreadCount < 10000) 12.0f else 9.0f + binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + binding.unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) + binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() - conversationViewDisplayNameTextView.text = senderDisplayName - timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) + binding.conversationViewDisplayNameTextView.text = senderDisplayName + binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val recipient = thread.recipient - muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != RecipientDatabase.NOTIFY_TYPE_ALL + binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != RecipientDatabase.NOTIFY_TYPE_ALL val drawableRes = if (recipient.isMuted || recipient.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { R.drawable.ic_outline_notifications_off_24 } else { R.drawable.ic_notifications_mentions } - muteIndicatorImageView.setImageResource(drawableRes) + binding.muteIndicatorImageView.setImageResource(drawableRes) val rawSnippet = thread.getDisplayBody(context) val snippet = highlightMentions(rawSnippet, thread.threadId, context) - snippetTextView.text = snippet - snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT - snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE + binding.snippetTextView.text = snippet + binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { - typingIndicatorView.startAnimation() + binding.typingIndicatorView.startAnimation() } else { - typingIndicatorView.stopAnimation() + binding.typingIndicatorView.stopAnimation() } - typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE - statusIndicatorImageView.visibility = View.VISIBLE + binding.typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE + binding.statusIndicatorImageView.visibility = View.VISIBLE when { - !thread.isOutgoing -> statusIndicatorImageView.visibility = View.GONE + !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE thread.isFailed -> { val drawable = ContextCompat.getDrawable(context, R.drawable.ic_error)?.mutate() drawable?.setTint(ContextCompat.getColor(context, R.color.destructive)) - statusIndicatorImageView.setImageDrawable(drawable) + binding.statusIndicatorImageView.setImageDrawable(drawable) } - thread.isPending -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) - thread.isRead -> statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) - else -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) + thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) + thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) + else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } post { - profilePictureView.update(thread.recipient, thread.threadId) + binding.profilePictureView.update(thread.recipient) } } fun recycle() { - profilePictureView.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { - if (recipient.isLocalNumber) { - return context.getString(R.string.note_to_self) + return if (recipient.isLocalNumber) { + context.getString(R.string.note_to_self) } else { - return recipient.name // Internally uses the Contact API + recipient.name // Internally uses the Contact API } } // endregion -} \ No newline at end of file +} 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 6373aaca1..035ceb971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -7,11 +7,9 @@ import android.content.Intent import android.content.IntentFilter import android.database.Cursor import android.os.Bundle -import android.text.Spannable import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.view.View import android.widget.Toast +import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.Observer @@ -19,23 +17,26 @@ import androidx.lifecycle.lifecycleScope import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.activity_home.* -import kotlinx.android.synthetic.main.seed_reminder_stub.* -import kotlinx.android.synthetic.main.seed_reminder_stub.view.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.utilities.* -import org.session.libsession.utilities.Util +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.ProfilePictureModifiedEvent +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext @@ -45,6 +46,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils 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.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord @@ -53,79 +55,136 @@ import org.thoughtcrime.securesms.dms.CreatePrivateChatActivity import org.thoughtcrime.securesms.groups.CreateClosedGroupActivity import org.thoughtcrime.securesms.groups.JoinPublicChatActivity import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter +import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout +import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel 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.preferences.SettingsActivity -import org.thoughtcrime.securesms.util.* +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.IP2Country +import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.show import java.io.IOException import javax.inject.Inject @AndroidEntryPoint -class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, - SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, LoaderManager.LoaderCallbacks { +class HomeActivity : PassphraseRequiredActionBarActivity(), + ConversationClickListener, + SeedReminderViewDelegate, + NewConversationButtonSetViewDelegate, + LoaderManager.LoaderCallbacks, + GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + + private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var groupDatabase: GroupDatabase + @Inject lateinit var textSecurePreferences: TextSecurePreferences + + private val globalSearchViewModel by viewModels() private val publicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! - private val homeAdapter:HomeAdapter by lazy { - HomeAdapter(this, threadDb.conversationList) + private val homeAdapter: HomeAdapter by lazy { + HomeAdapter(context = this, cursor = threadDb.conversationList, listener = this) + } + + private val globalSearchAdapter = GlobalSearchAdapter { model -> + when (model) { + is GlobalSearchAdapter.Model.Message -> { + val threadId = model.messageResult.threadId + val timestamp = model.messageResult.receivedTimestampMs + val author = model.messageResult.messageRecipient.address + + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp) + intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author) + push(intent) + } + is GlobalSearchAdapter.Model.SavedMessages -> { + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) + push(intent) + } + is GlobalSearchAdapter.Model.Contact -> { + val address = model.contact.sessionID + + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) + push(intent) + } + is GlobalSearchAdapter.Model.GroupConversation -> { + val groupAddress = Address.fromSerialized(model.groupRecord.encodedId) + val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false)) + if (threadId >= 0) { + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + push(intent) + } + } + else -> { + Log.d("Loki", "callback with model: $model") + } + } } // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) // Set content view - setContentView(R.layout.activity_home) + binding = ActivityHomeBinding.inflate(layoutInflater) + setContentView(binding.root) // Set custom toolbar - setSupportActionBar(toolbar) + setSupportActionBar(binding.toolbar) // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons - profileButton.glide = glide - profileButton.setOnClickListener { openSettings() } - pathStatusViewContainer.disableClipping() - pathStatusViewContainer.setOnClickListener { showPath() } + binding.profileButton.glide = glide + binding.profileButton.setOnClickListener { openSettings() } + binding.searchViewContainer.setOnClickListener { + binding.globalSearchInputLayout.requestFocus() + } + binding.sessionToolbar.disableClipping() // Set up seed reminder view val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (!hasViewedSeed) { - seedReminderStub.inflate().apply { - val seedReminderView = this.seedReminderView - val seedReminderViewTitle = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - seedReminderView.title = seedReminderViewTitle - seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) - seedReminderView.setProgress(80, false) - seedReminderView.delegate = this@HomeActivity - } + 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 { - seedReminderStub.isVisible = false + binding.seedReminderView.isVisible = false } + setupHeaderImage() // Set up recycler view + binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) homeAdapter.glide = glide - homeAdapter.conversationClickListener = this - recyclerView.adapter = homeAdapter - recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = homeAdapter + binding.globalSearchRecycler.adapter = globalSearchAdapter // Set up empty state view - createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } + binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } IP2Country.configureIfNeeded(this@HomeActivity) // This is a workaround for the fact that CursorRecyclerViewAdapter doesn't actually auto-update (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) // Set up new conversation button set - newConversationButtonSet.delegate = this + binding.newConversationButtonSet.delegate = this // Observe blocked contacts changed events val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - recyclerView.adapter!!.notifyDataSetChanged() + binding.recyclerView.adapter!!.notifyDataSetChanged() } } this.broadcastReceiver = broadcastReceiver @@ -138,7 +197,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis // Set up typing observer withContext(Dispatchers.Main) { ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this@HomeActivity, Observer> { threadIDs -> - val adapter = recyclerView.adapter as HomeAdapter + val adapter = binding.recyclerView.adapter as HomeAdapter adapter.typingThreadIDs = threadIDs ?: setOf() }) updateProfileButton() @@ -155,10 +214,85 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis JobQueue.shared.resumePendingJobs() } } + // monitor the global search VM query + launch { + binding.globalSearchInputLayout.query + .onEach(globalSearchViewModel::postQuery) + .collect() + } + // Get group results and display them + launch { + globalSearchViewModel.result.collect { result -> + val currentUserPublicKey = publicKey + val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + + result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } + + val contactResults = contactAndGroupList.toMutableList() + + if (contactResults.isEmpty()) { + contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) + } + + val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } + if (userIndex >= 0) { + contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) + } + + if (contactResults.isNotEmpty()) { + contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) + } + + val unreadThreadMap = result.messages + .groupBy { it.threadId }.keys + .map { it to mmsSmsDatabase.getUnreadCount(it) } + .toMap() + + val messageResults: MutableList = result.messages + .map { messageResult -> + GlobalSearchAdapter.Model.Message( + messageResult, + unreadThreadMap[messageResult.threadId] ?: 0 + ) + }.toMutableList() + + if (messageResults.isNotEmpty()) { + messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) + } + + val newData = contactResults + messageResults + + globalSearchAdapter.setNewData(result.query, newData) + } + } } EventBus.getDefault().register(this@HomeActivity) } + private fun setupHeaderImage() { + val isDayUiMode = UiModeUtilities.isDayUiMode(this) + val headerTint = if (isDayUiMode) R.color.black else R.color.accent + binding.sessionHeaderImage.setColorFilter(getColor(headerTint)) + } + + override fun onInputFocusChanged(hasFocus: Boolean) { + if (hasFocus) { + setSearchShown(true) + } else { + setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty()) + } + } + + private fun setSearchShown(isShown: Boolean) { + binding.searchToolbar.isVisible = isShown + binding.sessionToolbar.isVisible = !isShown + binding.recyclerView.isVisible = !isShown + binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible + binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown + binding.gradientView.isVisible = !isShown + binding.globalSearchRecycler.isVisible = isShown + binding.newConversationButtonSet.isVisible = !isShown + } + override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { return HomeLoader(this@HomeActivity) } @@ -177,11 +311,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (TextSecurePreferences.getLocalNumber(this) == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - profileButton.recycle() // clear cached image before update tje profilePictureView - profileButton.update() + binding.profileButton.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.update() val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (hasViewedSeed) { - seedReminderView?.isVisible = false + binding.seedReminderView.isVisible = false } if (TextSecurePreferences.getConfigurationMessageSynced(this)) { lifecycleScope.launch(Dispatchers.IO) { @@ -214,8 +348,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis // region Updating private fun updateEmptyState() { - val threadCount = (recyclerView.adapter as HomeAdapter).itemCount - emptyStateContainer.visibility = if (threadCount == 0) View.VISIBLE else View.GONE + val threadCount = (binding.recyclerView.adapter as HomeAdapter).itemCount + binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible } @Subscribe(threadMode = ThreadMode.MAIN) @@ -226,26 +360,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis } private fun updateProfileButton() { - profileButton.publicKey = publicKey - profileButton.displayName = TextSecurePreferences.getProfileName(this) - profileButton.recycle() - profileButton.update() + binding.profileButton.publicKey = publicKey + binding.profileButton.displayName = TextSecurePreferences.getProfileName(this) + binding.profileButton.recycle() + binding.profileButton.update() } // endregion // region Interaction + override fun onBackPressed() { + if (binding.globalSearchRecycler.isVisible) { + binding.globalSearchInputLayout.clearSearch(true) + return + } + super.onBackPressed() + } + override fun handleSeedReminderViewContinueButtonTapped() { val intent = Intent(this, SeedActivity::class.java) show(intent) } - override fun onConversationClick(view: ConversationView) { - val thread = view.thread ?: return - openConversation(thread) + override fun onConversationClick(thread: ThreadRecord) { + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, thread.threadId) + push(intent) } - override fun onLongConversationClick(view: ConversationView) { - val thread = view.thread ?: return + override fun onLongConversationClick(thread: ThreadRecord) { val bottomSheet = ConversationOptionsBottomSheet() bottomSheet.thread = thread bottomSheet.onViewDetailsTapped = { @@ -286,15 +428,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis } bottomSheet.onPinTapped = { bottomSheet.dismiss() - if (!thread.isPinned) { - pinConversation(thread) - } + setConversationPinned(thread.threadId, true) } bottomSheet.onUnpinTapped = { bottomSheet.dismiss() - if (thread.isPinned) { - unpinConversation(thread) - } + setConversationPinned(thread.threadId, false) + } + bottomSheet.onMarkAllAsReadTapped = { + bottomSheet.dismiss() + markAllAsRead(thread) } bottomSheet.show(supportFragmentManager, bottomSheet.tag) } @@ -305,10 +447,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis .setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ -> - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setBlocked(thread.recipient, true) - Util.runOnMain { - recyclerView.adapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } } @@ -321,10 +463,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis .setMessage(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ -> - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setBlocked(thread.recipient, false) - Util.runOnMain { - recyclerView.adapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } } @@ -333,18 +475,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { if (!isMuted) { - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, 0) - Util.runOnMain { - recyclerView.adapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } } } else { MuteDialog.show(this) { until: Long -> - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, until) - Util.runOnMain { - recyclerView.adapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } } } @@ -352,45 +494,41 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis } private fun setNotifyType(thread: ThreadRecord, newNotifyType: Int) { - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setNotifyType(thread.recipient, newNotifyType) - Util.runOnMain { - recyclerView.adapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } } } - private fun pinConversation(thread: ThreadRecord) { - ThreadUtils.queue { - threadDb.setPinned(thread.threadId, true) - Util.runOnMain { - LoaderManager.getInstance(this).restartLoader(0, null, this) + private fun setConversationPinned(threadId: Long, pinned: Boolean) { + lifecycleScope.launch(Dispatchers.IO) { + threadDb.setPinned(threadId, pinned) + withContext(Dispatchers.Main) { + LoaderManager.getInstance(this@HomeActivity).restartLoader(0, null, this@HomeActivity) } } } - private fun unpinConversation(thread: ThreadRecord) { + private fun markAllAsRead(thread: ThreadRecord) { ThreadUtils.queue { - threadDb.setPinned(thread.threadId, false) - Util.runOnMain { - LoaderManager.getInstance(this).restartLoader(0, null, this) - } + threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) } } private fun deleteConversation(thread: ThreadRecord) { val threadID = thread.threadId val recipient = thread.recipient - val message: String - if (recipient.isGroupRecipient) { + val message = if (recipient.isGroupRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() if (group != null && group.admins.map { it.toString() }.contains(TextSecurePreferences.getLocalNumber(this))) { - message = "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." } else { - message = resources.getString(R.string.activity_home_leave_group_dialog_message) + resources.getString(R.string.activity_home_leave_group_dialog_message) } } else { - message = resources.getString(R.string.activity_home_delete_conversation_dialog_message) + resources.getString(R.string.activity_home_delete_conversation_dialog_message) } val dialog = AlertDialog.Builder(this) dialog.setMessage(message) @@ -419,7 +557,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis if (v2OpenGroup != null) { OpenGroupManager.delete(v2OpenGroup.server, v2OpenGroup.room, this@HomeActivity) } else { - ThreadUtils.queue { + lifecycleScope.launch(Dispatchers.IO) { threadDb.deleteConversation(threadID) } } @@ -436,12 +574,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis dialog.create().show() } - private fun openConversation(thread: ThreadRecord) { - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, thread.threadId) - push(intent) - } - private fun openSettings() { val intent = Intent(this, SettingsActivity::class.java) show(intent, isForResult = true) 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 3adf5f7b0..c75564d07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -9,20 +9,23 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests -class HomeAdapter(context: Context, cursor: Cursor?) : CursorRecyclerViewAdapter(context, cursor) { +class HomeAdapter( + context: Context, + cursor: Cursor?, + val listener: ConversationClickListener +) : CursorRecyclerViewAdapter(context, cursor) { private val threadDatabase = DatabaseComponent.get(context).threadDatabase() lateinit var glide: GlideRequests var typingThreadIDs = setOf() set(value) { field = value; notifyDataSetChanged() } - var conversationClickListener: ConversationClickListener? = null class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = ConversationView(context) - view.setOnClickListener { conversationClickListener?.onConversationClick(view) } + view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } view.setOnLongClickListener { - conversationClickListener?.onLongConversationClick(view) + view.thread?.let { listener.onLongConversationClick(it) } true } return ViewHolder(view) @@ -45,6 +48,6 @@ class HomeAdapter(context: Context, cursor: Cursor?) : CursorRecyclerViewAdapter } interface ConversationClickListener { - fun onConversationClick(view: ConversationView) - fun onLongConversationClick(view: ConversationView) + fun onConversationClick(thread: ThreadRecord) + fun onLongConversationClick(thread: ThreadRecord) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index b812d5c0d..c76668c41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -17,26 +17,33 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.ColorRes import androidx.localbroadcastmanager.content.LocalBroadcastManager -import kotlinx.android.synthetic.main.activity_path.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityPathBinding import org.session.libsession.snode.OnionRequestAPI import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.util.* import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.PathDotView +import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.animateSizeChange +import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.fadeIn +import org.thoughtcrime.securesms.util.fadeOut +import org.thoughtcrime.securesms.util.getColorWithID class PathActivity : PassphraseRequiredActionBarActivity() { + private lateinit var binding: ActivityPathBinding private val broadcastReceivers = mutableListOf() // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_path) + binding = ActivityPathBinding.inflate(layoutInflater) + setContentView(binding.root) supportActionBar!!.title = resources.getString(R.string.activity_path_title) - pathRowsContainer.disableClipping() - learnMoreButton.setOnClickListener { learnMore() } + binding.pathRowsContainer.disableClipping() + binding.learnMoreButton.setOnClickListener { learnMore() } update(false) registerObservers() } @@ -82,7 +89,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() { private fun handleOnionRequestPathCountriesLoaded() { update(false) } private fun update(isAnimated: Boolean) { - pathRowsContainer.removeAllViews() + binding.pathRowsContainer.removeAllViews() if (OnionRequestAPI.paths.isNotEmpty()) { val path = OnionRequestAPI.paths.firstOrNull() ?: return finish() val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000 @@ -94,18 +101,18 @@ class PathActivity : PassphraseRequiredActionBarActivity() { val destinationRow = getPathRow(resources.getString(R.string.activity_path_destination_row_title), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) val rows = listOf( youRow ) + pathRows + listOf( destinationRow ) for (row in rows) { - pathRowsContainer.addView(row) + binding.pathRowsContainer.addView(row) } if (isAnimated) { - spinner.fadeOut() + binding.spinner.fadeOut() } else { - spinner.alpha = 0.0f + binding.spinner.alpha = 0.0f } } else { if (isAnimated) { - spinner.fadeIn() + binding.spinner.fadeIn() } else { - spinner.alpha = 1.0f + binding.spinner.alpha = 1.0f } } } 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 35785492d..0ac74c084 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -14,10 +14,9 @@ import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.EntryPoint import dagger.hilt.android.AndroidEntryPoint -import kotlinx.android.synthetic.main.fragment_user_details_bottom_sheet.* import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentUserDetailsBottomSheetBinding import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address @@ -34,13 +33,15 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { @Inject lateinit var threadDb: ThreadDatabase + private lateinit var binding: FragmentUserDetailsBottomSheetBinding companion object { const val ARGUMENT_PUBLIC_KEY = "publicKey" const val ARGUMENT_THREAD_ID = "threadId" } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_user_details_bottom_sheet, container, false) + binding = FragmentUserDetailsBottomSheetBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -49,58 +50,62 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { val threadID = arguments?.getLong(ARGUMENT_THREAD_ID) ?: return dismiss() val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() - profilePictureView.publicKey = publicKey - profilePictureView.glide = GlideApp.with(this) - profilePictureView.isLarge = true - profilePictureView.update(recipient, -1) - nameTextViewContainer.visibility = View.VISIBLE - nameTextViewContainer.setOnClickListener { - nameTextViewContainer.visibility = View.INVISIBLE - nameEditTextContainer.visibility = View.VISIBLE - nicknameEditText.text = null - nicknameEditText.requestFocus() - showSoftKeyboard() - } - cancelNicknameEditingButton.setOnClickListener { - nicknameEditText.clearFocus() - hideSoftKeyboard() + with(binding) { + profilePictureView.publicKey = publicKey + profilePictureView.glide = GlideApp.with(this@UserDetailsBottomSheet) + profilePictureView.isLarge = true + profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE - nameEditTextContainer.visibility = View.INVISIBLE - } - saveNicknameButton.setOnClickListener { - saveNickName(recipient) - } - nicknameEditText.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - saveNickName(recipient) - return@setOnEditorActionListener true - } - else -> return@setOnEditorActionListener false + nameTextViewContainer.setOnClickListener { + nameTextViewContainer.visibility = View.INVISIBLE + nameEditTextContainer.visibility = View.VISIBLE + nicknameEditText.text = null + nicknameEditText.requestFocus() + showSoftKeyboard() } - } - nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally + cancelNicknameEditingButton.setOnClickListener { + nicknameEditText.clearFocus() + hideSoftKeyboard() + nameTextViewContainer.visibility = View.VISIBLE + nameEditTextContainer.visibility = View.INVISIBLE + } + saveNicknameButton.setOnClickListener { + saveNickName(recipient) + } + nicknameEditText.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_DONE -> { + saveNickName(recipient) + return@setOnEditorActionListener true + } + else -> return@setOnEditorActionListener false + } + } + nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient - publicKeyTextView.text = publicKey - publicKeyTextView.setOnLongClickListener { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - true - } - messageButton.setOnClickListener { - val threadId = MessagingModuleConfiguration.shared.storage.getThreadId(recipient) - val intent = Intent( - context, - ConversationActivityV2::class.java - ) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1) - startActivity(intent) - dismiss() + publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient + messageButton.isVisible = !threadRecipient.isOpenGroupRecipient + publicKeyTextView.text = publicKey + publicKeyTextView.setOnLongClickListener { + val clipboard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Session ID", publicKey) + clipboard.setPrimaryClip(clip) + Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT) + .show() + true + } + messageButton.setOnClickListener { + val threadId = MessagingModuleConfiguration.shared.storage.getThreadId(recipient) + val intent = Intent( + context, + ConversationActivityV2::class.java + ) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1) + startActivity(intent) + dismiss() + } } } @@ -111,7 +116,7 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { window.setDimAmount(if (isLightMode) 0.1f else 0.75f) } - fun saveNickName(recipient: Recipient) { + fun saveNickName(recipient: Recipient) = with(binding) { nicknameEditText.clearFocus() hideSoftKeyboard() nameTextViewContainer.visibility = View.VISIBLE @@ -131,11 +136,11 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { @SuppressLint("ServiceCast") fun showSoftKeyboard() { val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(nicknameEditText, 0) + imm?.showSoftInput(binding.nicknameEditText, 0) } fun hideSoftKeyboard() { val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.hideSoftInputFromWindow(nicknameEditText.windowToken, 0) + imm?.hideSoftInputFromWindow(binding.nicknameEditText.windowToken, 0) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt new file mode 100644 index 000000000..554fb2e11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.home.search + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding +import network.loki.messenger.databinding.ViewGlobalSearchResultBinding +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.search.model.MessageResult +import java.security.InvalidParameterException +import org.session.libsession.messaging.contacts.Contact as ContactModel + +class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerView.Adapter() { + + companion object { + const val HEADER_VIEW_TYPE = 0 + const val CONTENT_VIEW_TYPE = 1 + } + + private var data: List = listOf() + private var query: String? = null + + fun setNewData(query: String, newData: List) { + val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData)) + this.query = query + data = newData + diffResult.dispatchUpdatesTo(this) + } + + override fun getItemViewType(position: Int): Int = + if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE + + override fun getItemCount(): Int = data.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + if (viewType == HEADER_VIEW_TYPE) { + HeaderView( + LayoutInflater.from(parent.context) + .inflate(R.layout.view_global_search_header, parent, false) + ) + } else { + ContentView( + LayoutInflater.from(parent.context) + .inflate(R.layout.view_global_search_result, parent, false) + , modelCallback) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + val newUpdateQuery: String? = payloads.firstOrNull { it is String } as String? + if (newUpdateQuery != null && holder is ContentView) { + holder.bindPayload(newUpdateQuery, data[position]) + return + } + if (holder is HeaderView) { + holder.bind(data[position] as Model.Header) + } else if (holder is ContentView) { + holder.bind(query.orEmpty(), data[position]) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + onBindViewHolder(holder,position, mutableListOf()) + } + + class HeaderView(view: View) : RecyclerView.ViewHolder(view) { + + val binding = ViewGlobalSearchHeaderBinding.bind(view) + + fun bind(header: Model.Header) { + binding.searchHeader.setText(header.title) + } + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is ContentView) { + holder.binding.searchResultProfilePicture.recycle() + } + } + + class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { + + val binding = ViewGlobalSearchResultBinding.bind(view).apply { + searchResultProfilePicture.glide = GlideApp.with(root) + } + + fun bindPayload(newQuery: String, model: Model) { + bindQuery(newQuery, model) + } + + fun bind(query: String, model: Model) { + binding.searchResultProfilePicture.recycle() + when (model) { + is Model.GroupConversation -> bindModel(query, model) + is Model.Contact -> bindModel(query, model) + is Model.Message -> bindModel(query, model) + is Model.SavedMessages -> bindModel(model) + is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView") + } + binding.root.setOnClickListener { modelCallback(model) } + } + + } + + data class MessageModel( + val threadRecipient: Recipient, + val messageRecipient: Recipient, + val messageSnippet: String + ) + + sealed class Model { + data class Header(@StringRes val title: Int) : Model() + data class SavedMessages(val currentUserPublicKey: String): Model() + data class Contact(val contact: ContactModel) : Model() + data class GroupConversation(val groupRecord: GroupRecord) : Model() + data class Message(val messageResult: MessageResult, val unread: Int) : Model() + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..2181b7f83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.home.search + +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.util.TypedValue +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +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.Message +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.SearchUtil +import java.util.Locale +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel + + +class GlobalSearchDiff( + private val oldQuery: String?, + private val newQuery: String?, + private val oldData: List, + private val newData: List +) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldData.size + override fun getNewListSize(): Int = newData.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldData[oldItemPosition] == newData[newItemPosition] + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldQuery == newQuery && oldData[oldItemPosition] == newData[newItemPosition] + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? = + if (oldQuery != newQuery) newQuery + else null +} + +private val BoldStyleFactory = { StyleSpan(Typeface.BOLD) } + +fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { + when (model) { + is ContactModel -> { + binding.searchResultTitle.text = getHighlight( + query, + model.contact.getSearchName() + ) + } + is Message -> { + val textSpannable = SpannableStringBuilder() + if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { + // group chat, bind + val text = "${model.messageResult.messageRecipient.getSearchName()}: " + textSpannable.append(text) + } + textSpannable.append(getHighlight( + query, + model.messageResult.bodySnippet + )) + binding.searchResultSubtitle.text = textSpannable + binding.searchResultSubtitle.isVisible = true + binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() + } + is GroupConversation -> { + binding.searchResultTitle.text = getHighlight( + query, + model.groupRecord.title + ) + + val membersString = model.groupRecord.members.joinToString { address -> + val recipient = Recipient.from(binding.root.context, address, false) + recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}" + } + binding.searchResultSubtitle.text = getHighlight(query, membersString) + } + } +} + +private fun getHighlight(query: String?, toSearch: String): Spannable? { + return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) +} + +fun ContentView.bindModel(query: String?, model: GroupConversation) { + binding.searchResultProfilePicture.isVisible = true + binding.searchResultSavedMessages.isVisible = false + binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup + binding.searchResultTimestamp.isVisible = false + val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) + binding.searchResultProfilePicture.update(threadRecipient) + val nameString = model.groupRecord.title + binding.searchResultTitle.text = getHighlight(query, nameString) + + val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } + + val membersString = groupRecipients.joinToString { + val address = it.address.serialize() + it.name ?: "${address.take(4)}...${address.takeLast(4)}" + } + if (model.groupRecord.isClosedGroup) { + binding.searchResultSubtitle.text = getHighlight(query, membersString) + } +} + +fun ContentView.bindModel(query: String?, model: ContactModel) { + binding.searchResultProfilePicture.isVisible = true + binding.searchResultSavedMessages.isVisible = false + binding.searchResultSubtitle.isVisible = false + binding.searchResultTimestamp.isVisible = false + binding.searchResultSubtitle.text = null + val recipient = + Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) + binding.searchResultProfilePicture.update(recipient) + val nameString = model.contact.getSearchName() + binding.searchResultTitle.text = getHighlight(query, nameString) +} + +fun ContentView.bindModel(model: SavedMessages) { + binding.searchResultSubtitle.isVisible = false + binding.searchResultTimestamp.isVisible = false + binding.searchResultTitle.setText(R.string.note_to_self) + binding.searchResultProfilePicture.isVisible = false + binding.searchResultSavedMessages.isVisible = true +} + +fun ContentView.bindModel(query: String?, model: Message) { + binding.searchResultProfilePicture.isVisible = true + binding.searchResultSavedMessages.isVisible = false + binding.searchResultTimestamp.isVisible = true +// val hasUnreads = model.unread > 0 +// binding.unreadCountIndicator.isVisible = hasUnreads +// if (hasUnreads) { +// binding.unreadCountTextView.text = model.unread.toString() +// } + binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.receivedTimestampMs) + binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) + val textSpannable = SpannableStringBuilder() + if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { + // group chat, bind + val text = "${model.messageResult.messageRecipient.getSearchName()}: " + textSpannable.append(text) + } + textSpannable.append(getHighlight( + query, + model.messageResult.bodySnippet + )) + binding.searchResultSubtitle.text = textSpannable + binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() + binding.searchResultSubtitle.isVisible = true +} + +fun Recipient.getSearchName(): String = name ?: address.serialize().let { address -> "${address.take(4)}...${address.takeLast(4)}" } + +fun Contact.getSearchName(): String = + if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}" + else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)" \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt new file mode 100644 index 000000000..411ae0956 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.home.search + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.LinearLayout +import android.widget.TextView +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import network.loki.messenger.databinding.ViewGlobalSearchInputBinding + +class GlobalSearchInputLayout @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : LinearLayout(context, attrs), + View.OnFocusChangeListener, + View.OnClickListener, + TextWatcher, TextView.OnEditorActionListener { + + var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true) + + var listener: GlobalSearchInputLayoutListener? = null + + private val _query = MutableStateFlow(null) + val query: StateFlow = _query + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + binding.searchInput.onFocusChangeListener = this + binding.searchInput.addTextChangedListener(this) + binding.searchInput.setOnEditorActionListener(this) + binding.searchCancel.setOnClickListener(this) + binding.searchClear.setOnClickListener(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + } + + override fun onFocusChange(v: View?, hasFocus: Boolean) { + if (v === binding.searchInput) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + listener?.onInputFocusChanged(hasFocus) + } + } + + override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (v === binding.searchInput && actionId == EditorInfo.IME_ACTION_SEARCH) { + binding.searchInput.clearFocus() + return true + } + return false + } + + override fun onClick(v: View?) { + if (v === binding.searchCancel) { + clearSearch(true) + } else if (v === binding.searchClear) { + clearSearch(false) + } + } + + fun clearSearch(clearFocus: Boolean) { + binding.searchInput.text = null + if (clearFocus) { + binding.searchInput.clearFocus() + } + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) { + _query.value = s?.toString() + } + + interface GlobalSearchInputLayoutListener { + fun onInputFocusChanged(hasFocus: Boolean) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt new file mode 100644 index 000000000..c85ffa874 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.home.search + +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.GroupRecord +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.search.model.SearchResult + +data class GlobalSearchResult( + val query: String, + val contacts: List, + val threads: List, + val messages: List +) { + + val isEmpty: Boolean + get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() + + companion object { + + val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList()) + const val SEARCH_LIMIT = 5 + + fun from(searchResult: SearchResult): GlobalSearchResult { + val query = searchResult.query + val contactList = searchResult.contacts.toList() + val threads = searchResult.conversations.toList() + val messages = searchResult.messages.toList() + searchResult.close() + return GlobalSearchResult(query, contactList, threads, messages) + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt new file mode 100644 index 000000000..8908554b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.home.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus +import org.session.libsignal.utilities.SettableFuture +import org.thoughtcrime.securesms.search.SearchRepository +import org.thoughtcrime.securesms.search.model.SearchResult +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { + + private val executor = viewModelScope + SupervisorJob() + + private val _result: MutableStateFlow = + MutableStateFlow(GlobalSearchResult.EMPTY) + + val result: StateFlow = _result + + private val _queryText: MutableStateFlow = MutableStateFlow("") + + fun postQuery(charSequence: CharSequence?) { + charSequence ?: return + _queryText.value = charSequence + } + + init { + // + _queryText + .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) + .mapLatest { query -> + if (query.trim().length < 2) { + SearchResult.EMPTY + } else { + // user input delay here in case we get a new query within a few hundred ms + // this coroutine will be cancelled and expensive query will not be run if typing quickly + // first query of 2 characters will be instant however + delay(300) + val settableFuture = SettableFuture() + searchRepository.query(query.toString(), settableFuture::set) + try { + // search repository doesn't play nicely with suspend functions (yet) + settableFuture.get(10_000, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + SearchResult.EMPTY + } + } + } + .onEach { result -> + // update the latest _result value + _result.value = GlobalSearchResult.from(result) + } + .launchIn(executor) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java index ee11c12bc..23f9d33e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.mediasend; import android.annotation.SuppressLint; -import androidx.lifecycle.ViewModelProviders; +import androidx.lifecycle.ViewModelProvider; import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Matrix; @@ -80,7 +80,7 @@ public class Camera1Fragment extends Fragment implements TextureView.SurfaceText controller = (Controller) getActivity(); camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this); orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE); - viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); } @Nullable diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 5f9e4ed09..9e3db73bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.mediasend; import androidx.appcompat.app.ActionBar; -import androidx.lifecycle.ViewModelProviders; +import androidx.lifecycle.ViewModelProvider; import android.content.Context; import android.content.res.Configuration; import android.graphics.Point; @@ -66,7 +66,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem bucketId = getArguments().getString(KEY_BUCKET_ID); folderTitle = getArguments().getString(KEY_FOLDER_TITLE); maxSelection = getArguments().getInt(KEY_MAX_SELECTION); - viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); } @Override @@ -105,7 +105,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); } - viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia); + viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); initMediaObserver(viewModel); } @@ -178,7 +178,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem } private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { - viewModel.getCountButtonState().observe(this, media -> { + viewModel.getCountButtonState().observe(getViewLifecycleOwner(), media -> { requireActivity().invalidateOptionsMenu(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index ce1ccb2bb..319f00997 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.mediasend; import android.annotation.SuppressLint; -import androidx.lifecycle.ViewModelProviders; +import androidx.lifecycle.ViewModelProvider; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Rect; @@ -313,7 +313,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl } private void initViewModel() { - viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); viewModel.getSelectedMedia().observe(this, media -> { if (Util.isEmpty(media)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 22a3e037b..cac1bfb9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -60,7 +60,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() - allGroupPublicKeys.forEach { closedGroupPoller.poll(it) } + allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } // Open Groups val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt index ac6039063..48a649dee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt @@ -57,7 +57,7 @@ object LokiPushNotificationManager { // Unsubscribe from all closed groups val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - allClosedGroupPublicKeys.forEach { closedGroup -> + allClosedGroupPublicKeys.iterator().forEach { closedGroup -> performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey) } } @@ -87,7 +87,7 @@ object LokiPushNotificationManager { } // Subscribe to all closed groups val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() - allClosedGroupPublicKeys.forEach { closedGroup -> + allClosedGroupPublicKeys.iterator().forEach { closedGroup -> performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey) } } 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 8ba364b7a..90be1fc3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -70,12 +70,13 @@ public class MarkReadReceiver extends BroadcastReceiver { public static void process(@NonNull Context context, @NonNull List markedReadMessages) { if (markedReadMessages.isEmpty()) return; - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return; for (MarkedMessageInfo messageInfo : markedReadMessages) { scheduleDeletion(context, messageInfo.getExpirationInfo()); } + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return; + Map> addressMap = Stream.of(markedReadMessages) .map(MarkedMessageInfo::getSyncMessageId) .collect(Collectors.groupingBy(SyncMessageId::getAddress)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt index 885878c5f..c0699e3eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt @@ -7,8 +7,8 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.TextView.OnEditorActionListener import android.widget.Toast -import kotlinx.android.synthetic.main.activity_display_name.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityDisplayNameBinding import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.util.push @@ -16,28 +16,32 @@ import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.session.libsession.utilities.TextSecurePreferences class DisplayNameActivity : BaseActionBarActivity() { + private lateinit var binding: ActivityDisplayNameBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setUpActionBarSessionLogo() - setContentView(R.layout.activity_display_name) - displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard - displayNameEditText.setOnEditorActionListener( - OnEditorActionListener { _, actionID, event -> - if (actionID == EditorInfo.IME_ACTION_SEARCH || - actionID == EditorInfo.IME_ACTION_DONE || - (event.action == KeyEvent.ACTION_DOWN && - event.keyCode == KeyEvent.KEYCODE_ENTER)) { - this.register() - return@OnEditorActionListener true - } - false - }) - registerButton.setOnClickListener { register() } + binding = ActivityDisplayNameBinding.inflate(layoutInflater) + setContentView(binding.root) + with(binding) { + displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard + displayNameEditText.setOnEditorActionListener( + OnEditorActionListener { _, actionID, event -> + if (actionID == EditorInfo.IME_ACTION_SEARCH || + actionID == EditorInfo.IME_ACTION_DONE || + (event.action == KeyEvent.ACTION_DOWN && + event.keyCode == KeyEvent.KEYCODE_ENTER)) { + register() + return@OnEditorActionListener true + } + false + }) + registerButton.setOnClickListener { register() } + } } private fun register() { - val displayName = displayNameEditText.text.toString().trim() + val displayName = binding.displayNameEditText.text.toString().trim() if (displayName.isEmpty()) { return Toast.makeText(this, R.string.activity_display_name_display_name_missing_error, Toast.LENGTH_SHORT).show() } @@ -45,7 +49,7 @@ class DisplayNameActivity : BaseActionBarActivity() { return Toast.makeText(this, R.string.activity_display_name_display_name_too_long_error, Toast.LENGTH_SHORT).show() } val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(displayNameEditText.windowToken, 0) + inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) TextSecurePreferences.setProfileName(this, displayName) val intent = Intent(this, PNModeActivity::class.java) push(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt index 68c4edd59..dcd4d783e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt @@ -3,19 +3,17 @@ package org.thoughtcrime.securesms.onboarding import android.animation.FloatEvaluator import android.animation.ValueAnimator import android.content.Context -import android.content.Context.LAYOUT_INFLATER_SERVICE import android.os.Handler import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.widget.LinearLayout import android.widget.ScrollView -import kotlinx.android.synthetic.main.view_fake_chat.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.ViewFakeChatBinding import org.thoughtcrime.securesms.util.disableClipping class FakeChatView : ScrollView { - + private lateinit var binding: ViewFakeChatBinding // region Settings private val spacing = context.resources.getDimension(R.dimen.medium_spacing) private val startDelay: Long = 1000 @@ -41,17 +39,15 @@ class FakeChatView : ScrollView { } private fun setUpViewHierarchy() { - val inflater = context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_fake_chat, null) as LinearLayout - contentView.disableClipping() - addView(contentView) + binding = ViewFakeChatBinding.inflate(LayoutInflater.from(context), this, true) + binding.root.disableClipping() isVerticalScrollBarEnabled = false } // endregion // region Animation fun startAnimating() { - listOf( bubble1, bubble2, bubble3, bubble4, bubble5 ).forEach { it.alpha = 0.0f } + listOf( binding.bubble1, binding.bubble2, binding.bubble3, binding.bubble4, binding.bubble5 ).forEach { it.alpha = 0.0f } fun show(bubble: View) { val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) animation.duration = animationDuration @@ -61,18 +57,18 @@ class FakeChatView : ScrollView { animation.start() } Handler().postDelayed({ - show(bubble1) + show(binding.bubble1) Handler().postDelayed({ - show(bubble2) + show(binding.bubble2) Handler().postDelayed({ - show(bubble3) - smoothScrollTo(0, (bubble1.height + spacing).toInt()) + show(binding.bubble3) + smoothScrollTo(0, (binding.bubble1.height + spacing).toInt()) Handler().postDelayed({ - show(bubble4) - smoothScrollTo(0, (bubble1.height + spacing).toInt() + (bubble2.height + spacing).toInt()) + show(binding.bubble4) + smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt()) Handler().postDelayed({ - show(bubble5) - smoothScrollTo(0, (bubble1.height + spacing).toInt() + (bubble2.height + spacing).toInt() + (bubble3.height + spacing).toInt()) + show(binding.bubble5) + smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt() + (binding.bubble3.height + spacing).toInt()) }, delayBetweenMessages) }, delayBetweenMessages) }, delayBetweenMessages) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt index 4fbd0f688..a87b769e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -2,25 +2,27 @@ package org.thoughtcrime.securesms.onboarding import android.content.Intent import android.os.Bundle -import android.view.View -import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityLandingBinding import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import org.thoughtcrime.securesms.service.KeyCachingService class LandingActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_landing) + val binding = ActivityLandingBinding.inflate(layoutInflater) + setContentView(binding.root) setUpActionBarSessionLogo(true) - findViewById(R.id.fakeChatView).startAnimating() - findViewById(R.id.registerButton).setOnClickListener { register() } - findViewById(R.id.restoreButton).setOnClickListener { restore() } - findViewById(R.id.linkButton).setOnClickListener { link() } + with(binding) { + fakeChatView.startAnimating() + registerButton.setOnClickListener { register() } + restoreButton.setOnClickListener { restore() } + linkButton.setOnClickListener { link() } + } IdentityKeyUtil.generateIdentityKeyPair(this) TextSecurePreferences.setPasswordDisabled(this, true) // AC: This is a temporary workaround to trick the old code that the screen is unlocked. 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 24701e461..ee1631a00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.text.InputType -import android.view.* +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast @@ -13,14 +15,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.activity_link_device.* -import kotlinx.android.synthetic.main.fragment_recovery_phrase.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityLinkDeviceBinding +import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.Hex @@ -30,13 +31,14 @@ import org.session.libsignal.utilities.hexEncodedPublicKey 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.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate -import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + private lateinit var binding: ActivityLinkDeviceBinding private val adapter = LinkDeviceActivityAdapter(this) private var restoreJob: Job? = null @@ -55,9 +57,10 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis()) setLastProfileUpdateTime(this@LinkDeviceActivity, 0) } - setContentView(R.layout.activity_link_device) - viewPager.adapter = adapter - tabLayout.setupWithViewPager(viewPager) + binding = ActivityLinkDeviceBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.viewPager.adapter = adapter + binding.tabLayout.setupWithViewPager(binding.viewPager) } // endregion @@ -107,8 +110,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel TextSecurePreferences.setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis()) TextSecurePreferences.setHasViewedSeed(this@LinkDeviceActivity, true) - loader.isVisible = true - val snackBar = Snackbar.make(containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE) + binding.loader.isVisible = true + val snackBar = Snackbar.make(binding.containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE) .setAction(R.string.registration_activity__skip) { register(true) } val skipJob = launch { @@ -127,13 +130,13 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel register(false) } - loader.isVisible = false + binding.loader.isVisible = false } } private fun register(skipped: Boolean) { restoreJob?.cancel() - loader.isVisible = false + binding.loader.isVisible = false TextSecurePreferences.setLastConfigurationSyncTime(this, System.currentTimeMillis()) val intent = Intent(this@LinkDeviceActivity, if (skipped) DisplayNameActivity::class.java else PNModeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -175,30 +178,34 @@ private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity // region Recovery Phrase Fragment class RecoveryPhraseFragment : Fragment() { + private lateinit var binding: FragmentRecoveryPhraseBinding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return inflater.inflate(R.layout.fragment_recovery_phrase, container, false) + binding = FragmentRecoveryPhraseBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard - mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) - mnemonicEditText.setOnEditorActionListener { v, actionID, _ -> - if (actionID == EditorInfo.IME_ACTION_DONE) { - val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(v.windowToken, 0) - handleContinueButtonTapped() - true - } else { - false + with(binding) { + mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard + mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) + mnemonicEditText.setOnEditorActionListener { v, actionID, _ -> + if (actionID == EditorInfo.IME_ACTION_DONE) { + val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(v.windowToken, 0) + handleContinueButtonTapped() + true + } else { + false + } } + continueButton.setOnClickListener { handleContinueButtonTapped() } } - continueButton.setOnClickListener { handleContinueButtonTapped() } } private fun handleContinueButtonTapped() { - val mnemonic = mnemonicEditText.text?.trim().toString() + val mnemonic = binding.mnemonicEditText.text?.trim().toString() (requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic) } } 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 cbfd5bcc9..c081e8fa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -13,9 +13,8 @@ import android.view.View import android.widget.Toast import androidx.annotation.ColorRes import androidx.annotation.DrawableRes -import kotlinx.android.synthetic.main.activity_display_name.registerButton -import kotlinx.android.synthetic.main.activity_pn_mode.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityPnModeBinding import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity @@ -28,6 +27,7 @@ import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.PNModeView class PNModeActivity : BaseActionBarActivity() { + private lateinit var binding: ActivityPnModeBinding private var selectedOptionView: PNModeView? = null // region Lifecycle @@ -35,15 +35,18 @@ class PNModeActivity : BaseActionBarActivity() { super.onCreate(savedInstanceState) setUpActionBarSessionLogo(true) TextSecurePreferences.setHasSeenWelcomeScreen(this, true) - setContentView(R.layout.activity_pn_mode) - contentView.disableClipping() - fcmOptionView.setOnClickListener { toggleFCM() } - fcmOptionView.mainColor = resources.getColorWithID(R.color.pn_option_background, theme) - fcmOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) - backgroundPollingOptionView.setOnClickListener { toggleBackgroundPolling() } - backgroundPollingOptionView.mainColor = resources.getColorWithID(R.color.pn_option_background, theme) - backgroundPollingOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) - registerButton.setOnClickListener { register() } + binding = ActivityPnModeBinding.inflate(layoutInflater) + setContentView(binding.root) + with(binding) { + contentView.disableClipping() + fcmOptionView.setOnClickListener { toggleFCM() } + fcmOptionView.mainColor = resources.getColorWithID(R.color.pn_option_background, theme) + fcmOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) + backgroundPollingOptionView.setOnClickListener { toggleBackgroundPolling() } + backgroundPollingOptionView.mainColor = resources.getColorWithID(R.color.pn_option_background, theme) + backgroundPollingOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) + registerButton.setOnClickListener { register() } + } toggleFCM() } @@ -63,8 +66,7 @@ class PNModeActivity : BaseActionBarActivity() { // region Interaction override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - when(id) { + when(item.itemId) { R.id.learnMoreButton -> learnMore() else -> { /* Do nothing */ } } @@ -81,52 +83,52 @@ class PNModeActivity : BaseActionBarActivity() { } } - private fun toggleFCM() { + private fun toggleFCM() = with(binding) { when (selectedOptionView) { null -> { performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(this, fcmOptionView, R.color.transparent, R.color.accent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, fcmOptionView, R.color.transparent, R.color.accent) animateStrokeColorChange(fcmOptionView, R.color.pn_option_border, R.color.accent) selectedOptionView = fcmOptionView } fcmOptionView -> { performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(this, fcmOptionView, R.color.accent, R.color.transparent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, fcmOptionView, R.color.accent, R.color.transparent) animateStrokeColorChange(fcmOptionView, R.color.accent, R.color.pn_option_border) selectedOptionView = null } backgroundPollingOptionView -> { performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(this, fcmOptionView, R.color.transparent, R.color.accent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, fcmOptionView, R.color.transparent, R.color.accent) animateStrokeColorChange(fcmOptionView, R.color.pn_option_border, R.color.accent) performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(this, backgroundPollingOptionView, R.color.accent, R.color.transparent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, backgroundPollingOptionView, R.color.accent, R.color.transparent) animateStrokeColorChange(backgroundPollingOptionView, R.color.accent, R.color.pn_option_border) selectedOptionView = fcmOptionView } } } - private fun toggleBackgroundPolling() { + private fun toggleBackgroundPolling() = with(binding) { when (selectedOptionView) { null -> { performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(this, backgroundPollingOptionView, R.color.transparent, R.color.accent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, backgroundPollingOptionView, R.color.transparent, R.color.accent) animateStrokeColorChange(backgroundPollingOptionView, R.color.pn_option_border, R.color.accent) selectedOptionView = backgroundPollingOptionView } backgroundPollingOptionView -> { performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(this, backgroundPollingOptionView, R.color.accent, R.color.transparent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, backgroundPollingOptionView, R.color.accent, R.color.transparent) animateStrokeColorChange(backgroundPollingOptionView, R.color.accent, R.color.pn_option_border) selectedOptionView = null } fcmOptionView -> { performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(this, backgroundPollingOptionView, R.color.transparent, R.color.accent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, backgroundPollingOptionView, R.color.transparent, R.color.accent) animateStrokeColorChange(backgroundPollingOptionView, R.color.pn_option_border, R.color.accent) performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(this, fcmOptionView, R.color.accent, R.color.transparent) + GlowViewUtilities.animateShadowColorChange(this@PNModeActivity, fcmOptionView, R.color.accent, R.color.transparent) animateStrokeColorChange(fcmOptionView, R.color.accent, R.color.pn_option_border) selectedOptionView = backgroundPollingOptionView } @@ -153,7 +155,7 @@ class PNModeActivity : BaseActionBarActivity() { dialog.create().show() return } - TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == fcmOptionView)) + TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView)) val application = ApplicationContext.getInstance(this) application.startPollingIfNeeded() application.registerForFCMIfNeeded(true) 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 968b3d0dd..6a1c785ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt @@ -11,8 +11,8 @@ import android.text.style.ClickableSpan import android.text.style.StyleSpan import android.view.View import android.widget.Toast -import kotlinx.android.synthetic.main.activity_recovery_phrase_restore.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.Hex @@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { - + private lateinit var binding: ActivityRecoveryPhraseRestoreBinding // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,9 +36,10 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { setRestorationTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis()) setLastProfileUpdateTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis()) } - setContentView(R.layout.activity_recovery_phrase_restore) - mnemonicEditText.imeOptions = mnemonicEditText.imeOptions or 16777216 // Always use incognito keyboard - restoreButton.setOnClickListener { restore() } + binding = ActivityRecoveryPhraseRestoreBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.mnemonicEditText.imeOptions = binding.mnemonicEditText.imeOptions or 16777216 // Always use incognito keyboard + binding.restoreButton.setOnClickListener { restore() } val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy") termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) termsExplanation.setSpan(object : ClickableSpan() { @@ -54,14 +55,14 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { openURL("https://getsession.org/privacy-policy/") } }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsTextView.movementMethod = LinkMovementMethod.getInstance() - termsTextView.text = termsExplanation + binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() + binding.termsTextView.text = termsExplanation } // endregion // region Interaction private fun restore() { - val mnemonic = mnemonicEditText.text.toString() + val mnemonic = binding.mnemonicEditText.text.toString() try { val loadFileContents: (String) -> String = { fileName -> MnemonicUtilities.loadFileContents(this, fileName) 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 9dac8875d..0105fbedf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -16,8 +16,8 @@ import android.text.style.StyleSpan import android.view.View import android.widget.Toast import com.goterl.lazysodium.utils.KeyPair -import kotlinx.android.synthetic.main.activity_register.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityRegisterBinding import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.KeyHelper @@ -26,9 +26,9 @@ import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import java.util.* class RegisterActivity : BaseActionBarActivity() { + private lateinit var binding: ActivityRegisterBinding private var seed: ByteArray? = null private var ed25519KeyPair: KeyPair? = null private var x25519KeyPair: ECKeyPair? = null @@ -37,7 +37,8 @@ class RegisterActivity : BaseActionBarActivity() { // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_register) + binding = ActivityRegisterBinding.inflate(layoutInflater) + setContentView(binding.root) setUpActionBarSessionLogo() TextSecurePreferences.apply { setHasViewedSeed(this@RegisterActivity, false) @@ -45,8 +46,8 @@ class RegisterActivity : BaseActionBarActivity() { setRestorationTime(this@RegisterActivity, 0) setLastProfileUpdateTime(this@RegisterActivity, System.currentTimeMillis()) } - registerButton.setOnClickListener { register() } - copyButton.setOnClickListener { copyPublicKey() } + binding.registerButton.setOnClickListener { register() } + binding.copyButton.setOnClickListener { copyPublicKey() } val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy") termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) termsExplanation.setSpan(object : ClickableSpan() { @@ -62,8 +63,8 @@ class RegisterActivity : BaseActionBarActivity() { openURL("https://getsession.org/privacy-policy/") } }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsTextView.movementMethod = LinkMovementMethod.getInstance() - termsTextView.text = termsExplanation + binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() + binding.termsTextView.text = termsExplanation updateKeyPair() } // endregion @@ -94,12 +95,12 @@ class RegisterActivity : BaseActionBarActivity() { } count += 1 if (count < limit) { - publicKeyTextView.text = mangledHexEncodedPublicKey + binding.publicKeyTextView.text = mangledHexEncodedPublicKey Handler().postDelayed({ animate() }, 32) } else { - publicKeyTextView.text = hexEncodedPublicKey + binding.publicKeyTextView.text = hexEncodedPublicKey } } animate() diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt index ad8d3a29c..b179ce852 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt @@ -9,8 +9,8 @@ import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.widget.LinearLayout import android.widget.Toast -import kotlinx.android.synthetic.main.activity_seed.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivitySeedBinding import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey @@ -21,6 +21,8 @@ import org.thoughtcrime.securesms.util.getColorWithID class SeedActivity : BaseActionBarActivity() { + private lateinit var binding: ActivitySeedBinding + private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) if (hexEncodedSeed == null) { @@ -35,27 +37,30 @@ class SeedActivity : BaseActionBarActivity() { // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_seed) + binding = ActivitySeedBinding.inflate(layoutInflater) + setContentView(binding.root) supportActionBar!!.title = resources.getString(R.string.activity_seed_title) val seedReminderViewTitle = SpannableString("You're almost finished! 90%") // Intentionally not yet translated seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - seedReminderView.title = seedReminderViewTitle - seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_2) - seedReminderView.setProgress(90, false) - seedReminderView.hideContinueButton() - var redactedSeed = seed - var index = 0 - for (character in seed) { - if (character.isLetter()) { - redactedSeed = redactedSeed.replaceRange(index, index + 1, "▆") + with(binding) { + seedReminderView.title = seedReminderViewTitle + seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_2) + seedReminderView.setProgress(90, false) + seedReminderView.hideContinueButton() + var redactedSeed = seed + var index = 0 + for (character in seed) { + if (character.isLetter()) { + redactedSeed = redactedSeed.replaceRange(index, index + 1, "▆") + } + index += 1 } - index += 1 + seedTextView.setTextColor(resources.getColorWithID(R.color.accent, theme)) + seedTextView.text = redactedSeed + seedTextView.setOnLongClickListener { revealSeed(); true } + revealButton.setOnLongClickListener { revealSeed(); true } + copyButton.setOnClickListener { copySeed() } } - seedTextView.setTextColor(resources.getColorWithID(R.color.accent, theme)) - seedTextView.text = redactedSeed - seedTextView.setOnLongClickListener { revealSeed(); true } - revealButton.setOnLongClickListener { revealSeed(); true } - copyButton.setOnClickListener { copySeed() } } // endregion @@ -63,14 +68,16 @@ class SeedActivity : BaseActionBarActivity() { private fun revealSeed() { val seedReminderViewTitle = SpannableString("Account secured! 100%") // Intentionally not yet translated seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 17, 21, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - seedReminderView.title = seedReminderViewTitle - seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_3) - seedReminderView.setProgress(100, true) - val seedTextViewLayoutParams = seedTextView.layoutParams as LinearLayout.LayoutParams - seedTextViewLayoutParams.height = seedTextView.height - seedTextView.layoutParams = seedTextViewLayoutParams - seedTextView.setTextColor(resources.getColorWithID(R.color.text, theme)) - seedTextView.text = seed + with(binding) { + seedReminderView.title = seedReminderViewTitle + seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_3) + seedReminderView.setProgress(100, true) + val seedTextViewLayoutParams = seedTextView.layoutParams as LinearLayout.LayoutParams + seedTextViewLayoutParams.height = seedTextView.height + seedTextView.layoutParams = seedTextViewLayoutParams + seedTextView.setTextColor(resources.getColorWithID(R.color.text, theme)) + seedTextView.text = seed + } TextSecurePreferences.setHasViewedSeed(this, true) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt index 199ed7a5a..28611985f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt @@ -6,16 +6,17 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout -import kotlinx.android.synthetic.main.view_seed_reminder.view.* -import network.loki.messenger.R +import network.loki.messenger.databinding.ViewSeedReminderBinding class SeedReminderView : FrameLayout { + private lateinit var binding: ViewSeedReminderBinding + var title: CharSequence - get() = titleTextView.text - set(value) { titleTextView.text = value } + get() = binding.titleTextView.text + set(value) { binding.titleTextView.text = value } var subtitle: CharSequence - get() = subtitleTextView.text - set(value) { subtitleTextView.text = value } + get() = binding.subtitleTextView.text + set(value) { binding.subtitleTextView.text = value } var delegate: SeedReminderViewDelegate? = null constructor(context: Context) : super(context) { @@ -35,22 +36,20 @@ class SeedReminderView : FrameLayout { } private fun setUpViewHierarchy() { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val contentView = inflater.inflate(R.layout.view_seed_reminder, null) - addView(contentView) - button.setOnClickListener { delegate?.handleSeedReminderViewContinueButtonTapped() } + binding = ViewSeedReminderBinding.inflate(LayoutInflater.from(context), this, true) + binding.button.setOnClickListener { delegate?.handleSeedReminderViewContinueButtonTapped() } } fun setProgress(progress: Int, isAnimated: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - progressBar.setProgress(progress, isAnimated) + binding.progressBar.setProgress(progress, isAnimated) } else { - progressBar.progress = progress + binding.progressBar.progress = progress } } fun hideContinueButton() { - button.visibility = View.GONE + binding.button.visibility = View.GONE } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index a974d427a..d4d30d864 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -4,10 +4,12 @@ import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.dialog_clear_all_data.* -import kotlinx.android.synthetic.main.dialog_clear_all_data.view.* -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.databinding.DialogClearAllDataBinding import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext @@ -15,6 +17,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog class ClearAllDataDialog : BaseDialog() { + private lateinit var binding: DialogClearAllDataBinding enum class Steps { INFO_PROMPT, @@ -34,15 +37,15 @@ class ClearAllDataDialog : BaseDialog() { } override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_clear_all_data, null) - contentView.cancelButton.setOnClickListener { + binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) + binding.cancelButton.setOnClickListener { if (step == Steps.NETWORK_PROMPT) { clearAllData(false) } else if (step != Steps.DELETING) { dismiss() } } - contentView.clearAllDataButton.setOnClickListener { + binding.clearAllDataButton.setOnClickListener { when(step) { Steps.INFO_PROMPT -> step = Steps.NETWORK_PROMPT Steps.NETWORK_PROMPT -> { @@ -51,36 +54,33 @@ class ClearAllDataDialog : BaseDialog() { Steps.DELETING -> { /* do nothing intentionally */ } } } - builder.setView(contentView) + builder.setView(binding.root) builder.setCancelable(false) } private fun updateUI() { - - dialog?.let { view -> - + dialog?.let { val isLoading = step == Steps.DELETING when (step) { Steps.INFO_PROMPT -> { - view.dialogDescriptionText.setText(R.string.dialog_clear_all_data_explanation) - view.cancelButton.setText(R.string.cancel) - view.clearAllDataButton.setText(R.string.delete) + binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_explanation) + binding.cancelButton.setText(R.string.cancel) + binding.clearAllDataButton.setText(R.string.delete) } else -> { - view.dialogDescriptionText.setText(R.string.dialog_clear_all_data_network_explanation) - view.cancelButton.setText(R.string.dialog_clear_all_data_local_only) - view.clearAllDataButton.setText(R.string.dialog_clear_all_data_clear_network) + binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_network_explanation) + binding.cancelButton.setText(R.string.dialog_clear_all_data_local_only) + binding.clearAllDataButton.setText(R.string.dialog_clear_all_data_clear_network) } } - view.cancelButton.isVisible = !isLoading - view.clearAllDataButton.isVisible = !isLoading - view.progressBar.isVisible = isLoading + binding.cancelButton.isVisible = !isLoading + binding.clearAllDataButton.isVisible = !isLoading + binding.progressBar.isVisible = isLoading - view.setCanceledOnTouchOutside(!isLoading) + it.setCanceledOnTouchOutside(!isLoading) isCancelable = !isLoading - } } 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 6b097a0d5..7bf967237 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -54,9 +54,9 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp } @Override + @SuppressLint("RestrictedApi") protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) { return new PreferenceGroupAdapter(preferenceScreen) { - @SuppressLint("RestrictedApi") @Override public void onBindViewHolder(PreferenceViewHolder holder, int position) { super.onBindViewHolder(holder, position); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index c21470598..20f67cd29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -10,9 +10,9 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter -import kotlinx.android.synthetic.main.activity_qr_code.* -import kotlinx.android.synthetic.main.fragment_view_my_qr_code.* import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityQrCodeBinding +import network.loki.messenger.databinding.FragmentViewMyQrCodeBinding import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient @@ -20,23 +20,29 @@ import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.* +import org.thoughtcrime.securesms.util.FileProviderUtil +import org.thoughtcrime.securesms.util.QRCodeUtilities +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate +import org.thoughtcrime.securesms.util.toPx import java.io.File import java.io.FileOutputStream class QRCodeActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + private lateinit var binding: ActivityQrCodeBinding private val adapter = QRCodeActivityAdapter(this) // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + binding = ActivityQrCodeBinding.inflate(layoutInflater) // Set content view - setContentView(R.layout.activity_qr_code) + setContentView(binding.root) // Set title supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title) // Set up view pager - viewPager.adapter = adapter - tabLayout.setupWithViewPager(viewPager) + binding.viewPager.adapter = adapter + binding.tabLayout.setupWithViewPager(binding.viewPager) } // endregion @@ -91,6 +97,7 @@ private class QRCodeActivityAdapter(val activity: QRCodeActivity) : FragmentPage // region View My QR Code Fragment class ViewMyQRCodeFragment : Fragment() { + private lateinit var binding: FragmentViewMyQrCodeBinding private val hexEncodedPublicKey: String get() { @@ -98,18 +105,19 @@ class ViewMyQRCodeFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return inflater.inflate(R.layout.fragment_view_my_qr_code, container, false) + binding = FragmentViewMyQrCodeBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val size = toPx(280, resources) val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false) - qrCodeImageView.setImageBitmap(qrCode) + binding.qrCodeImageView.setImageBitmap(qrCode) // val explanation = SpannableStringBuilder("This is your unique public QR code. Other users can scan this to start a conversation with you.") // explanation.setSpan(StyleSpan(Typeface.BOLD), 8, 34, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - explanationTextView.text = resources.getString(R.string.fragment_view_my_qr_code_explanation) - shareButton.setOnClickListener { shareQRCode() } + binding.explanationTextView.text = resources.getString(R.string.fragment_view_my_qr_code_explanation) + binding.shareButton.setOnClickListener { shareQRCode() } } private fun shareQRCode() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt index 19fffc343..32624129d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt @@ -6,8 +6,8 @@ import android.content.Context import android.view.LayoutInflater import android.widget.Toast import androidx.appcompat.app.AlertDialog -import kotlinx.android.synthetic.main.dialog_seed.view.* import network.loki.messenger.R +import network.loki.messenger.databinding.DialogSeedBinding import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -28,11 +28,11 @@ class SeedDialog : BaseDialog() { } override fun setContentView(builder: AlertDialog.Builder) { - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_seed, null) - contentView.seedTextView.text = seed - contentView.cancelButton.setOnClickListener { dismiss() } - contentView.copyButton.setOnClickListener { copySeed() } - builder.setView(contentView) + val binding = DialogSeedBinding.inflate(LayoutInflater.from(requireContext())) + binding.seedTextView.text = seed + binding.cancelButton.setOnClickListener { dismiss() } + binding.copyButton.setOnClickListener { copySeed() } + builder.setView(binding.root) } private fun copySeed() { 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 c0159d317..d5c7747e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -7,7 +7,11 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent 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.view.ActionMode import android.view.Menu import android.view.MenuItem @@ -15,9 +19,9 @@ import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.activity_settings.* import network.loki.messenger.BuildConfig import network.loki.messenger.R +import network.loki.messenger.databinding.ActivitySettingsBinding import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi @@ -30,16 +34,24 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection +import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints -import org.thoughtcrime.securesms.util.* +import org.thoughtcrime.securesms.util.BitmapDecodingException +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.show import java.io.File import java.security.SecureRandom -import java.util.* +import java.util.Date class SettingsActivity : PassphraseRequiredActionBarActivity() { + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } private lateinit var glide: GlideRequests @@ -59,33 +71,38 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_settings) + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) val displayName = TextSecurePreferences.getProfileName(this) ?: hexEncodedPublicKey glide = GlideApp.with(this) - profilePictureView.glide = glide - profilePictureView.publicKey = hexEncodedPublicKey - profilePictureView.displayName = displayName - profilePictureView.isLarge = true - profilePictureView.update() - profilePictureView.setOnClickListener { showEditProfilePictureUI() } - ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } - btnGroupNameDisplay.text = displayName - publicKeyTextView.text = hexEncodedPublicKey - copyButton.setOnClickListener { copyPublicKey() } - shareButton.setOnClickListener { sharePublicKey() } - privacyButton.setOnClickListener { showPrivacySettings() } - notificationsButton.setOnClickListener { showNotificationSettings() } - chatsButton.setOnClickListener { showChatSettings() } - sendInvitationButton.setOnClickListener { sendInvitation() } - faqButton.setOnClickListener { showFAQ() } - surveyButton.setOnClickListener { showSurvey() } - helpTranslateButton.setOnClickListener { helpTranslate() } - seedButton.setOnClickListener { showSeed() } - clearAllDataButton.setOnClickListener { clearAllData() } - debugLogButton.setOnClickListener { shareLogs() } - val isLightMode = UiModeUtilities.isDayUiMode(this) - oxenLogoImageView.setImageResource(if (isLightMode) R.drawable.oxen_light_mode else R.drawable.oxen_dark_mode) - versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + with(binding) { + profilePictureView.glide = glide + profilePictureView.publicKey = hexEncodedPublicKey + profilePictureView.displayName = displayName + profilePictureView.isLarge = true + profilePictureView.update() + profilePictureView.setOnClickListener { showEditProfilePictureUI() } + ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } + btnGroupNameDisplay.text = displayName + publicKeyTextView.text = hexEncodedPublicKey + copyButton.setOnClickListener { copyPublicKey() } + shareButton.setOnClickListener { sharePublicKey() } + pathButton.setOnClickListener { showPath() } + pathContainer.disableClipping() + privacyButton.setOnClickListener { showPrivacySettings() } + notificationsButton.setOnClickListener { showNotificationSettings() } + chatsButton.setOnClickListener { showChatSettings() } + sendInvitationButton.setOnClickListener { sendInvitation() } + faqButton.setOnClickListener { showFAQ() } + surveyButton.setOnClickListener { showSurvey() } + helpTranslateButton.setOnClickListener { helpTranslate() } + seedButton.setOnClickListener { showSeed() } + clearAllDataButton.setOnClickListener { clearAllData() } + debugLogButton.setOnClickListener { shareLogs() } + val isLightMode = UiModeUtilities.isDayUiMode(this@SettingsActivity) + oxenLogoImageView.setImageResource(if (isLightMode) R.drawable.oxen_light_mode else R.drawable.oxen_dark_mode) + versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -152,22 +169,22 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private fun handleDisplayNameEditActionModeChanged() { val isEditingDisplayName = this.displayNameEditActionMode !== null - btnGroupNameDisplay.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE - displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE + binding.btnGroupNameDisplay.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE + binding.displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager if (isEditingDisplayName) { - displayNameEditText.setText(btnGroupNameDisplay.text) - displayNameEditText.selectAll() - displayNameEditText.requestFocus() - inputMethodManager.showSoftInput(displayNameEditText, 0) + binding.displayNameEditText.setText(binding.btnGroupNameDisplay.text) + binding.displayNameEditText.selectAll() + binding.displayNameEditText.requestFocus() + inputMethodManager.showSoftInput(binding.displayNameEditText, 0) } else { - inputMethodManager.hideSoftInputFromWindow(displayNameEditText.windowToken, 0) + inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) } } private fun updateProfile(isUpdatingProfilePicture: Boolean) { - loader.isVisible = true + binding.loader.isVisible = true val promises = mutableListOf>() val displayName = displayNameToBeUploaded if (displayName != null) { @@ -192,15 +209,15 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } compoundPromise.alwaysUi { if (displayName != null) { - btnGroupNameDisplay.text = displayName + binding.btnGroupNameDisplay.text = displayName } if (isUpdatingProfilePicture && profilePicture != null) { - profilePictureView.recycle() // Clear the cached image before updating - profilePictureView.update() + binding.profilePictureView.recycle() // Clear the cached image before updating + binding.profilePictureView.update() } displayNameToBeUploaded = null profilePictureToBeUploaded = null - loader.isVisible = false + binding.loader.isVisible = false } } // endregion @@ -211,7 +228,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { * @return true if the update was successful. */ private fun saveDisplayName(): Boolean { - val displayName = displayNameEditText.text.toString().trim() + val displayName = binding.displayNameEditText.text.toString().trim() if (displayName.isEmpty()) { Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show() return false @@ -291,6 +308,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } + private fun showPath() { + val intent = Intent(this, PathActivity::class.java) + show(intent) + } + private fun showSurvey() { try { val url = "https://getsession.org/survey" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 76d6f6a1c..0421f08ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -13,12 +13,12 @@ import android.webkit.MimeTypeMap import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.dialog_share_logs.view.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.R +import network.loki.messenger.databinding.DialogShareLogsBinding import org.session.libsignal.utilities.ExternalStorageUtil import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog @@ -26,7 +26,7 @@ import org.thoughtcrime.securesms.util.StreamUtil import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.util.* +import java.util.Objects import java.util.concurrent.TimeUnit class ShareLogsDialog : BaseDialog() { @@ -34,16 +34,15 @@ class ShareLogsDialog : BaseDialog() { private var shareJob: Job? = null override fun setContentView(builder: AlertDialog.Builder) { - val contentView = - LayoutInflater.from(requireContext()).inflate(R.layout.dialog_share_logs, null) - contentView.cancelButton.setOnClickListener { + val binding = DialogShareLogsBinding.inflate(LayoutInflater.from(requireContext())) + binding.cancelButton.setOnClickListener { dismiss() } - contentView.shareButton.setOnClickListener { + binding.shareButton.setOnClickListener { // start the export and share shareLogs() } - builder.setView(contentView) + builder.setView(binding.root) builder.setCancelable(false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt new file mode 100644 index 000000000..4225dabf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -0,0 +1,229 @@ +package org.thoughtcrime.securesms.repository + +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +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.recipients.Recipient +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +interface ConversationRepository { + fun isOxenHostedOpenGroup(threadId: Long): Boolean + fun getRecipientForThreadId(threadId: Long): Recipient + fun saveDraft(threadId: Long, text: String) + fun getDraft(threadId: Long): String? + fun inviteContacts(threadId: Long, contacts: List) + fun unblock(recipient: Recipient) + fun deleteLocally(recipient: Recipient, message: MessageRecord) + + suspend fun deleteForEveryone( + threadId: Long, + recipient: Recipient, + message: MessageRecord + ): ResultOf + + fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? + + suspend fun deleteMessageWithoutUnsendRequest( + threadId: Long, + messages: Set + ): ResultOf + + suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf + + suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf +} + +class DefaultConversationRepository @Inject constructor( + private val textSecurePreferences: TextSecurePreferences, + private val messageDataProvider: MessageDataProvider, + private val threadDb: ThreadDatabase, + private val draftDb: DraftDatabase, + private val lokiThreadDb: LokiThreadDatabase, + private val smsDb: SmsDatabase, + private val mmsDb: MmsDatabase, + private val recipientDb: RecipientDatabase, + private val lokiMessageDb: LokiMessageDatabase +) : ConversationRepository { + + override fun isOxenHostedOpenGroup(threadId: Long): Boolean { + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) + return openGroup?.room == "session" || openGroup?.room == "oxen" + || openGroup?.room == "lokinet" || openGroup?.room == "crypto" + } + + override fun getRecipientForThreadId(threadId: Long): Recipient { + return threadDb.getRecipientForThreadId(threadId)!! + } + + override fun saveDraft(threadId: Long, text: String) { + if (text.isEmpty()) return + val drafts = DraftDatabase.Drafts() + drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) + draftDb.insertDrafts(threadId, drafts) + } + + override fun getDraft(threadId: Long): String? { + val drafts = draftDb.getDrafts(threadId) + draftDb.clearDrafts(threadId) + return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value + } + + override fun inviteContacts(threadId: Long, contacts: List) { + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return + for (contact in contacts) { + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + val openGroupInvitation = OpenGroupInvitation() + openGroupInvitation.name = openGroup.name + openGroupInvitation.url = openGroup.joinURL + message.openGroupInvitation = openGroupInvitation + val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( + openGroupInvitation, + contact, + message.sentTimestamp + ) + smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!) + MessageSender.send(message, contact.address) + } + } + + override fun unblock(recipient: Recipient) { + recipientDb.setBlocked(recipient, false) + } + + override fun deleteLocally(recipient: Recipient, message: MessageRecord) { + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + textSecurePreferences.getLocalNumber()?.let { + MessageSender.send(unsendRequest, Address.fromSerialized(it)) + } + } + messageDataProvider.deleteMessage(message.id, !message.isMms) + } + + override suspend fun deleteForEveryone( + threadId: Long, + recipient: Recipient, + message: MessageRecord + ): ResultOf = suspendCoroutine { continuation -> + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, recipient.address) + } + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) + if (openGroup != null) { + lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + .success { + messageDataProvider.deleteMessage(message.id, !message.isMms) + continuation.resume(ResultOf.Success(Unit)) + }.fail { error -> + continuation.resumeWithException(error) + } + } + } else { + messageDataProvider.deleteMessage(message.id, !message.isMms) + messageDataProvider.getServerHashForMessage(message.id)?.let { serverHash -> + var publicKey = recipient.address.serialize() + if (recipient.isClosedGroupRecipient) { + publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() + } + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + .success { + continuation.resume(ResultOf.Success(Unit)) + }.fail { error -> + continuation.resumeWithException(error) + } + } + } + } + + override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { + if (recipient.isOpenGroupRecipient) return null + messageDataProvider.getServerHashForMessage(message.id) ?: return null + val unsendRequest = UnsendRequest() + if (message.isOutgoing) { + unsendRequest.author = textSecurePreferences.getLocalNumber() + } else { + unsendRequest.author = message.individualRecipient.address.contactIdentifier() + } + unsendRequest.timestamp = message.timestamp + + return unsendRequest + } + + override suspend fun deleteMessageWithoutUnsendRequest( + threadId: Long, + messages: Set + ): ResultOf = suspendCoroutine { continuation -> + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) + if (openGroup != null) { + val messageServerIDs = mutableMapOf() + for (message in messages) { + val messageServerID = + lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue + messageServerIDs[messageServerID] = message + } + for ((messageServerID, message) in messageServerIDs) { + OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + .success { + messageDataProvider.deleteMessage(message.id, !message.isMms) + }.fail { error -> + continuation.resumeWithException(error) + } + } + } else { + for (message in messages) { + if (message.isMms) { + mmsDb.deleteMessage(message.id) + } else { + smsDb.deleteMessage(message.id) + } + } + } + continuation.resume(ResultOf.Success(Unit)) + } + + override suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf = + suspendCoroutine { continuation -> + val sessionID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server) + .success { + continuation.resume(ResultOf.Success(Unit)) + }.fail { error -> + continuation.resumeWithException(error) + } + } + + override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf = + suspendCoroutine { continuation -> + val sessionID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) + .success { + continuation.resume(ResultOf.Success(Unit)) + }.fail { error -> + continuation.resumeWithException(error) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ResultOf.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ResultOf.kt new file mode 100644 index 000000000..96ae97e51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ResultOf.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.repository + +import kotlinx.coroutines.CancellationException + +sealed class ResultOf { + + data class Success(val value: R) : ResultOf() + + data class Failure(val throwable: Throwable) : ResultOf() + + inline fun onFailure(block: (throwable: Throwable) -> Unit) = this.also { + if (this is Failure) { + block(throwable) + } + } + + inline fun onSuccess(block: (value: T) -> Unit) = this.also { + if (this is Success) { + block(value) + } + } + + inline fun flatMap(mapper: (T) -> R): ResultOf = when (this) { + is Success -> wrap { mapper(value) } + is Failure -> Failure(throwable) + } + + fun getOrThrow(): T = when (this) { + is Success -> value + is Failure -> throw throwable + } + + companion object { + inline fun wrap(block: () -> T): ResultOf = + try { + Success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Failure(e) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt new file mode 100644 index 000000000..7ee247fdb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.search + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.scopes.ViewModelScoped +import org.session.libsession.utilities.concurrent.SignalExecutors +import org.thoughtcrime.securesms.contacts.ContactAccessor +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase + +@Module +@InstallIn(ViewModelComponent::class) +object SearchModule { + + @Provides + @ViewModelScoped + fun provideSearchRepository(@ApplicationContext context: Context, + searchDatabase: SearchDatabase, + threadDatabase: ThreadDatabase, + groupDatabase: GroupDatabase, + contactDatabase: SessionContactDatabase) = + SearchRepository(context, searchDatabase, threadDatabase, groupDatabase, contactDatabase, ContactAccessor.getInstance(), SignalExecutors.SERIAL) + + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index df4cb1d17..a33f4dd11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -3,29 +3,39 @@ package org.thoughtcrime.securesms.search; import android.content.Context; import android.database.Cursor; import android.database.DatabaseUtils; -import androidx.annotation.NonNull; +import android.database.MergeCursor; import android.text.TextUtils; +import androidx.annotation.NonNull; + import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.GroupRecord; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.database.CursorList; +import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.SessionContactDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.session.libsignal.utilities.Log; -import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.search.model.MessageResult; import org.thoughtcrime.securesms.search.model.SearchResult; import org.thoughtcrime.securesms.util.Stopwatch; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; +import kotlin.Pair; + /** * Manages data retrieval for search. */ @@ -50,21 +60,27 @@ public class SearchRepository { } } - private final Context context; - private final SearchDatabase searchDatabase; - private final ThreadDatabase threadDatabase; - private final ContactAccessor contactAccessor; - private final Executor executor; + private final Context context; + private final SearchDatabase searchDatabase; + private final ThreadDatabase threadDatabase; + private final GroupDatabase groupDatabase; + private final SessionContactDatabase contactDatabase; + private final ContactAccessor contactAccessor; + private final Executor executor; public SearchRepository(@NonNull Context context, @NonNull SearchDatabase searchDatabase, @NonNull ThreadDatabase threadDatabase, + @NonNull GroupDatabase groupDatabase, + @NonNull SessionContactDatabase contactDatabase, @NonNull ContactAccessor contactAccessor, @NonNull Executor executor) { this.context = context.getApplicationContext(); this.searchDatabase = searchDatabase; this.threadDatabase = threadDatabase; + this.groupDatabase = groupDatabase; + this.contactDatabase = contactDatabase; this.contactAccessor = contactAccessor; this.executor = executor; } @@ -81,10 +97,10 @@ public class SearchRepository { String cleanQuery = sanitizeQuery(query); timer.split("clean"); - CursorList contacts = queryContacts(cleanQuery); + Pair, List> contacts = queryContacts(cleanQuery); timer.split("contacts"); - CursorList conversations = queryConversations(cleanQuery); + CursorList conversations = queryConversations(cleanQuery, contacts.getSecond()); timer.split("conversations"); CursorList messages = queryMessages(cleanQuery); @@ -92,7 +108,7 @@ public class SearchRepository { timer.stop(TAG); - callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages)); + callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages)); }); } @@ -111,28 +127,62 @@ public class SearchRepository { }); } - private CursorList queryContacts(String query) { - return CursorList.emptyList(); - /* Loki - We don't need contacts permission - if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { - return CursorList.emptyList(); + private Pair, List> queryContacts(String query) { + + Cursor contacts = contactDatabase.queryContactsByName(query); + List
contactList = new ArrayList<>(); + List contactStrings = new ArrayList<>(); + + while (contacts.moveToNext()) { + try { + Contact contact = contactDatabase.contactFromCursor(contacts); + String contactSessionId = contact.getSessionID(); + Address address = Address.fromSerialized(contactSessionId); + contactList.add(address); + contactStrings.add(contactSessionId); + } catch (Exception e) { + Log.e("Loki", "Error building Contact from cursor in query", e); + } } - Cursor textSecureContacts = contactsDatabase.queryTextSecureContacts(query); - Cursor systemContacts = contactsDatabase.querySystemContacts(query); - MergeCursor contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); + contacts.close(); + + Cursor addressThreads = threadDatabase.searchConversationAddresses(query); + Cursor individualRecipients = threadDatabase.getFilteredConversationList(contactList); + if (individualRecipients == null && addressThreads == null) { + return new Pair<>(CursorList.emptyList(),contactStrings); + } + MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients}); + + return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings); - return new CursorList<>(contacts, new RecipientModelBuilder(context)); - */ } - private CursorList queryConversations(@NonNull String query) { + private CursorList queryConversations(@NonNull String query, List matchingAddresses) { List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); - List
addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList(); + String localUserNumber = TextSecurePreferences.getLocalNumber(context); + if (localUserNumber != null) { + matchingAddresses.remove(localUserNumber); + } + Set
addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList()); - Cursor conversations = threadDatabase.getFilteredConversationList(addresses); - return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase)) - : CursorList.emptyList(); + Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses); + if (membersGroupList != null) { + GroupDatabase.Reader reader = new GroupDatabase.Reader(membersGroupList); + while (membersGroupList.moveToNext()) { + GroupRecord record = reader.getCurrent(); + if (record == null) continue; + + addresses.add(Address.fromSerialized(record.getEncodedId())); + } + membersGroupList.close(); + } + + + Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses)); + + return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase)) + : CursorList.emptyList(); } private CursorList queryMessages(@NonNull String query) { @@ -169,6 +219,28 @@ public class SearchRepository { return out.toString(); } + private static class ContactModelBuilder implements CursorList.ModelBuilder { + + private final SessionContactDatabase contactDb; + private final ThreadDatabase threadDb; + + public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) { + this.contactDb = contactDb; + this.threadDb = threadDb; + } + + @Override + public Contact build(@NonNull Cursor cursor) { + ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent(); + Contact contact = contactDb.getContactWithSessionID(threadRecord.getRecipient().getAddress().serialize()); + if (contact == null) { + contact = new Contact(threadRecord.getRecipient().getAddress().serialize()); + contact.setThreadID(threadRecord.getThreadId()); + } + return contact; + } + } + private static class RecipientModelBuilder implements CursorList.ModelBuilder { private final Context context; @@ -184,6 +256,22 @@ public class SearchRepository { } } + private static class GroupModelBuilder implements CursorList.ModelBuilder { + private final ThreadDatabase threadDatabase; + private final GroupDatabase groupDatabase; + + public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) { + this.threadDatabase = threadDatabase; + this.groupDatabase = groupDatabase; + } + + @Override + public GroupRecord build(@NonNull Cursor cursor) { + ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent(); + return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get(); + } + } + private static class ThreadModelBuilder implements CursorList.ModelBuilder { private final ThreadDatabase threadDatabase; @@ -208,7 +296,7 @@ public class SearchRepository { @Override public MessageResult build(@NonNull Cursor cursor) { - Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS))); + Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS))); Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))); Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); Recipient messageRecipient = Recipient.from(context, messageAddress, false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java index c1e40beb4..33c687c93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java @@ -4,9 +4,10 @@ import android.database.ContentObserver; import androidx.annotation.NonNull; +import org.session.libsession.messaging.contacts.Contact; +import org.session.libsession.utilities.GroupRecord; import org.thoughtcrime.securesms.database.CursorList; import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.session.libsession.utilities.recipients.Recipient; import java.util.List; @@ -19,13 +20,13 @@ public class SearchResult { public static final SearchResult EMPTY = new SearchResult("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList()); private final String query; - private final CursorList contacts; - private final CursorList conversations; + private final CursorList contacts; + private final CursorList conversations; private final CursorList messages; public SearchResult(@NonNull String query, - @NonNull CursorList contacts, - @NonNull CursorList conversations, + @NonNull CursorList contacts, + @NonNull CursorList conversations, @NonNull CursorList messages) { this.query = query; @@ -34,11 +35,11 @@ public class SearchResult { this.messages = messages; } - public List getContacts() { + public List getContacts() { return contacts; } - public List getConversations() { + public List getConversations() { return conversations; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt index 3f4867839..eaaf06f45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt @@ -229,7 +229,7 @@ object BackupUtil { @JvmOverloads fun deleteAllBackupFiles(context: Context, except: Collection? = null) { val db = DatabaseComponent.get(context).lokiBackupFilesDatabase() - db.getBackupFiles().forEach { record -> + db.getBackupFiles().iterator().forEach { record -> if (except != null && except.contains(record)) return@forEach // Try to delete the related file. The operation may fail in many cases 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 7860e4624..874440f5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -121,14 +121,12 @@ public class DateUtils extends android.text.format.DateUtils { * e.g. 2020-09-04T19:17:51Z * https://www.iso.org/iso-8601-date-and-time-format.html * - * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. - * * @return The timestamp if able to be parsed, otherwise -1. */ @SuppressLint("ObsoleteSdkInt") public static long parseIso8601(@Nullable String date) { SimpleDateFormat format; - if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { + if (Build.VERSION.SDK_INT >= 24) { format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); } else { format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 4ff45e8f0..479a54faf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -116,8 +116,8 @@ class IP2Country private constructor(private val context: Context) { private fun populateCacheIfNeeded() { ThreadUtils.queue { - OnionRequestAPI.paths.forEach { path -> - path.forEach { snode -> + OnionRequestAPI.paths.iterator().forEach { path -> + path.iterator().forEach { snode -> cacheCountryForIP(snode.ip) // Preload if needed } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt index 9eecc15b7..e060a8282 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt @@ -2,39 +2,40 @@ package org.thoughtcrime.securesms.util import android.content.res.Configuration import android.os.Bundle -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout -import kotlinx.android.synthetic.main.fragment_scan_qr_code.* -import network.loki.messenger.R +import androidx.fragment.app.Fragment +import network.loki.messenger.databinding.FragmentScanQrCodeBinding import org.thoughtcrime.securesms.qr.ScanListener import org.thoughtcrime.securesms.qr.ScanningThread class ScanQRCodeFragment : Fragment() { + private lateinit var binding: FragmentScanQrCodeBinding private val scanningThread = ScanningThread() var scanListener: ScanListener? = null set(value) { field = value; scanningThread.setScanListener(scanListener) } var message: CharSequence = "" - override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? { - return layoutInflater.inflate(R.layout.fragment_scan_qr_code, viewGroup, false) + override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View { + binding = FragmentScanQrCodeBinding.inflate(layoutInflater, viewGroup, false) + return binding.root } override fun onViewCreated(view: View, bundle: Bundle?) { super.onViewCreated(view, bundle) when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> overlayView.orientation = LinearLayout.HORIZONTAL - else -> overlayView.orientation = LinearLayout.VERTICAL + Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL + else -> binding.overlayView.orientation = LinearLayout.VERTICAL } - messageTextView.text = message + binding.messageTextView.text = message } override fun onResume() { super.onResume() - cameraView.onResume() - cameraView.setPreviewCallback(scanningThread) + binding.cameraView.onResume() + binding.cameraView.setPreviewCallback(scanningThread) try { scanningThread.start() } catch (exception: Exception) { @@ -45,18 +46,18 @@ class ScanQRCodeFragment : Fragment() { override fun onConfigurationChanged(newConfiguration: Configuration) { super.onConfigurationChanged(newConfiguration) - this.cameraView.onPause() + binding.cameraView.onPause() when (newConfiguration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> overlayView.orientation = LinearLayout.HORIZONTAL - else -> overlayView.orientation = LinearLayout.VERTICAL + Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL + else -> binding.overlayView.orientation = LinearLayout.VERTICAL } - cameraView.onResume() - cameraView.setPreviewCallback(scanningThread) + binding.cameraView.onResume() + binding.cameraView.setPreviewCallback(scanningThread) } override fun onPause() { super.onPause() - this.cameraView.onPause() + this.binding.cameraView.onPause() this.scanningThread.stopScanning() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt index 5b36de7ae..7e14b7234 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt @@ -1,23 +1,24 @@ package org.thoughtcrime.securesms.util import android.os.Bundle -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import kotlinx.android.synthetic.main.fragment_scan_qr_code_placeholder.* -import network.loki.messenger.R +import androidx.fragment.app.Fragment +import network.loki.messenger.databinding.FragmentScanQrCodePlaceholderBinding class ScanQRCodePlaceholderFragment: Fragment() { + private lateinit var binding: FragmentScanQrCodePlaceholderBinding var delegate: ScanQRCodePlaceholderFragmentDelegate? = null - override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? { - return layoutInflater.inflate(R.layout.fragment_scan_qr_code_placeholder, viewGroup, false) + override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View { + binding = FragmentScanQrCodePlaceholderBinding.inflate(layoutInflater, viewGroup, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - grantCameraAccessButton.setOnClickListener { delegate?.requestCameraAccess() } + binding.grantCameraAccessButton.setOnClickListener { delegate?.requestCameraAccess() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java index 8935780b1..cf046b9a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java @@ -1,12 +1,13 @@ package org.thoughtcrime.securesms.util; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.text.Spannable; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.CharacterStyle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.annimon.stream.Stream; import org.session.libsignal.utilities.Pair; diff --git a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml b/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml new file mode 100644 index 000000000..0cb95b770 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml b/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml new file mode 100644 index 000000000..28f57645a --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_session.xml b/app/src/main/res/drawable/ic_session.xml new file mode 100644 index 000000000..040347af1 --- /dev/null +++ b/app/src/main/res/drawable/ic_session.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rounded_rectangle.xml b/app/src/main/res/drawable/rounded_rectangle.xml new file mode 100644 index 000000000..4901429e3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml new file mode 100644 index 000000000..a2090ca6b --- /dev/null +++ b/app/src/main/res/drawable/search_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf new file mode 100644 index 000000000..e89b0b79a Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 61e1680ef..65652359c 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -16,17 +16,17 @@ + android:layout_height="match_parent" /> + android:layout_above="@+id/inputBar" + /> + android:layout_marginBottom="32dp"> - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 87bd87eb2..893cf3835 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -1,6 +1,5 @@ - - + + + android:layout_centerInParent="true" + android:layout_toStartOf="@+id/searchViewContainer" + android:layout_toEndOf="@+id/profileButton" + android:padding="@dimen/medium_spacing" + android:scaleType="centerInside" + android:src="@drawable/ic_session" + app:tint="@color/black" /> - + + + + + - + android:layout_height="wrap_content" /> @@ -91,7 +111,9 @@ android:layout_height="match_parent" android:paddingBottom="172dp" android:clipToPadding="false" - tools:listitem="@layout/view_conversation"/> + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + tools:itemCount="6" + tools:listitem="@layout/view_conversation" /> + + + android:paddingBottom="32dp" + android:visibility="gone"> + android:textSize="@dimen/medium_font_size" />