* Remove unused sizeResId

* Fix caching

* Prefix message with name in HomeActivity

* Hide sender prefix for note to self

* Hide sender prefix for control messages

* Remove problematic getLastMessage()

* Refactor snippet formatting

* Remove unused RecoveryPhraseRestoreActivity

* Fix unresolved theme attributes exception

* Fix dialog button style

* Investigation in progress

* Working fix push before cleanup

* Fixes #1346

* Removed unused logging imports

* Put back some whitespace

* Minor cleanup

* Fix NPE on null display name

* fix: disappearing viewmodel tests (#1432)

* SES-1354 - Video call self viewer not mirrored (#1397)

* Fixes #874

* Removed accidentally left in line

* Fixed issue - push before cleanup

* Cleaned up

* Removed cruft

---------

Co-authored-by: = <=>
Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>

* SES-1145 - New messages are hidden under keyboard - MK3 (#1415)

* WIP

* Working - push before cleanup

* Fixes #1316

* Cleanup

* PR review adjustments

* Fixed scrolling when receiving an image based message while keyboard is up

* Prevent auto-scroll to last seen item pos in conversation view if <= 3

* Put back <=3 check to scroll

---------

Co-authored-by: = <=>
Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>

* Fix missing parenthesis

* SES-789 - Scroll to bottom of long new message(s) (#1426)

* WIP

* Working - push before cleanup

* Fixes #1316

* Cleanup

* PR review adjustments

* Fixed scrolling when receiving an image based message while keyboard is up

* Prevent auto-scroll to last seen item pos in conversation view if <= 3

* Put back <=3 check to scroll

* Forced scrolling to bottom of long messages (both sent and received) when already at the bottom of the RecyclerView

* Fixes #1364

---------

Co-authored-by: = <=>
Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>

* SES-1352 - User and group names allowing multi-line strings (#1395)

* Fix WIP

* Resolved issue - pushing before cleanup & PR tomorrow morning

* Enforced single line for new closed group names

* Fixes #1394

* Final cleanup prior to PR

* Added code to restore a previous contact nickname if an empty one is given

* Added initial limits to nicknames and group names, both creation and display

* Minor adjustments

* Adjusted max nickname and group name to 35 chars as per Kee's instructions

* Fixed closed group edit text able to get too wide and cut off buttons

---------

Co-authored-by: = <=>
Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>
Co-authored-by: Al Lansley <al@oxen.io>

* SES-212 - Always show delivery status of last sent message - FINAL! (#1418)

* Fixes #1408

* Addressed PR feedback

* Cleanup

* PR adjustments

* Further PR adjustments

* Updated libsession-util

* Added fix for crash when no messages

* Ignoring dirty submodules so they don't show up in git

* Re-fixed display of delivery status on last sent message (got broken by disappearing messages)

* Removed ignore dirty modules line in .gitmodules as it all seems to be playing nice now

---------

Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>
Co-authored-by: Al Lansley <al@oxen.io>

* fix: use a set for the from/to serialized lists (#1370)

* Fixes #1347 (#1396)

Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>
Co-authored-by: Al Lansley <al@oxen.io>

* SES-1156 - Ban and delete functionality fix (#1428)

* WIP

* Investigation in progress

* End of day push

* WIP

* Fixes #1416

* Cleanup

* Added code to remove zombie messages caught in limbo during a ban & delete - still chock full o' debug while finding root cause

* Root cause debug WIP

* Push prior to cleanup

* Cleaned up for PR

* fix: mms delete, remove unnecessary values from sms

* Addressed PR feedback

* fix: fix unit tests

* Added '.run' folder with test setup

* Update README.md

Test commit for CI

* Re-added accidentally removed closing brace

---------

Co-authored-by: alansley <aclansley@gmail.com>
Co-authored-by: Al Lansley <alansley@users.noreply.github.com>
Co-authored-by: 0x330a <92654767+0x330a@users.noreply.github.com>

* SES-1356 - List of recently used reaction emojis is not accurate (#1400)

* WIP

* Further WIP

* Push prior to cleanup

* Fixes #1015

* Added limiting to the count of recently used emoji that we store

* Put back adjusted reaction pill layout to standard

* Adjusted recently used reaction emojis already in list to go to start of list

---------

Co-authored-by: = <=>

* SES-697 - Add loading state when exporting logs (#1402)

* WIP

* Fixes #1401

* Cleanup from PR view

* Final cleanup

* Removed commented line of code & re-ordered comment

* Addressed PR feedback

* Re-allowed loading of avatars to throw exceptions rather than return null on failure

---------

Co-authored-by: = <=>

* SES-1251 - App crash on non alphanumeric first char search (#1393)

* Investigation in progress

* Working fix push before cleanup

* Fixes #1346

* Removed unused logging imports

* Put back some whitespace

* Minor cleanup

* Push before cleanup

* Fixes #1346 - properly this time!

* SES1567 - Community message delivery status fix (#1442)

* Initial investigation

* WIP

* Continued work to track down cause of delivery status issue

* Fixes #1438

* Cleanup for PR

* Further cleanup

* Fixed merge conflict

* Addressed PR feedback

---------

Co-authored-by: alansley <aclansley@gmail.com>

* Tiny adjustment to center user name in Settings activity (#1446)

* Addressed PR feedback

* Cleanup

* Initial fix implemented

* Fixes #1448

* Addressed PR feedback

* SES1688 - Deleting last message in conversation, group, or community leaves the RecyclerView in a broken state (#1449)

* Initial fix implemented

* Fixes #1448

* Addressed PR feedback

* Handle case where there are no messages

* build: update build number

* Fix spacing when title is absent

* Hide reply button in MessageDetails for group invitations

* Remove reply from context menu for open group invitations

* Ignore swipe reply to open group invitation

* Fix multiple quote previews

* Fix message menu icons not visible in light theme

* Hide reply app bar menu item for open group invite

* SES-1727 Mentions text is the wrong colour (#1454)

* Fixes #1453

* Cleanup

* Code review adjustments

* Adjusted mentions to use the accent colour as their background colour when using light themes

---------

Co-authored-by: alansley <aclansley@gmail.com>

* Disable swipe to reply on open group invites

* Fix multiple link previews

* SES1718 - Message Sending Status (#1462)

* Investigation in progress

* Initial push for PR

* Fixes #1461

* Removed leftover debug comments

* Added minor optimisation to showMessageStatus method (bail early if the message isn't one we care about displaying details of to the user)

* Minor cleanup

* Tiny cleanup

* Addressed PR feedback

* Removed forgotten debug log line & forced delivery status elements to be removed on non-visible messages just in case

* Minor refactor to simplify 'VisibleMessageView.showStatusMessage'

---------

Co-authored-by: alansley <aclansley@gmail.com>

* Fix margins

* WIP

* Commit before converting SmsDatabase from Java to Kotlin

* Remove old expiration config strings from UpdateMessageBuilder

* Fix group expiration update config messages

* Fixed conversation view closing + hopefully wrong status text displayed + deletion of contact on removal of last message in 1-on-1 convo

* Cleanup for PR review

* Implemented PR feedback

* Don't start expiration for group expiration update messages

* Fix expiry update message for groups

* Correctly don't start disappear timer on group timer updates

* SES1813 - Fix regression test failures (#1473)

* Initial fix for regression test failure 1.1

* Added permissions fix for sharing documents which should allow for thumbnail generation

* Minor touch-up prior to merge into dev

* Fixes #1813

* Fixes #1472 - please ignore previous fixes 1813 statement, I'd used the Jira ticket number rather than creating a GitHub issue and using that

---------

Co-authored-by: alansley <aclansley@gmail.com>

---------

Co-authored-by: alansley <aclansley@gmail.com>
Co-authored-by: 0x330a <92654767+0x330a@users.noreply.github.com>
Co-authored-by: Al Lansley <alansley@users.noreply.github.com>
Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com>
Co-authored-by: Al Lansley <al@oxen.io>
This commit is contained in:
Andrew 2024-05-01 12:29:33 +09:30 committed by GitHub
parent 7bcf823740
commit d16faf94c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
123 changed files with 1376 additions and 1007 deletions

24
.run/Run Tests.run.xml Normal file
View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Tests" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="testPlayDebugUnitTestCoverageReport" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -31,8 +31,8 @@ configurations.all {
exclude module: "commons-logging" exclude module: "commons-logging"
} }
def canonicalVersionCode = 369 def canonicalVersionCode = 370
def canonicalVersionName = "1.18.1" def canonicalVersionName = "1.18.2"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,

View File

@ -41,6 +41,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
@ -106,11 +107,6 @@
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity" android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity" android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
@ -230,11 +226,13 @@
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2" android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity" android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar"> android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" >
<meta-data <meta-data
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" /> android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity> </activity>
<activity <activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity" android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"

View File

@ -478,9 +478,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.d("Loki-Avatar", "Uploading Avatar Finished"); Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE; return Unit.INSTANCE;
}); });
} catch (Exception exception) { } catch (Exception e) {
// Do nothing Log.e("Loki-Avatar", "Uploading avatar failed.");
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
} }
}); });
} }

View File

@ -39,15 +39,20 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
public int getDesiredTheme() { public int getDesiredTheme() {
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
int userSelectedTheme = themeState.getTheme(); int userSelectedTheme = themeState.getTheme();
// If the user has configured Session to follow the system light/dark theme mode then do so..
if (themeState.getFollowSystem()) { if (themeState.getFollowSystem()) {
// do light or dark based on the selected theme
// Use light or dark versions of the user's theme based on light-mode / dark-mode settings
boolean isDayUi = UiModeUtilities.isDayUiMode(this); boolean isDayUi = UiModeUtilities.isDayUiMode(this);
if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) { if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) {
return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark; return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark;
} else { } else {
return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark; return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark;
} }
} else { }
else // ..otherwise just return their selected theme.
{
return userSelectedTheme; return userSelectedTheme;
} }
} }

View File

@ -8,6 +8,7 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button import android.widget.Button
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL import android.widget.LinearLayout.VERTICAL
import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
@ -15,13 +16,11 @@ import androidx.annotation.StringRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins import androidx.core.view.setMargins
import androidx.core.view.setPadding
import androidx.core.view.updateMargins import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
@DslMarker @DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl annotation class DialogDsl
@ -31,13 +30,16 @@ class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources) private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources) private val dp40 = toPx(40, context.resources)
private val dp60 = toPx(60, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context) private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss() private fun dismiss() = dialog?.dismiss()
private val topView = LinearLayout(context).apply { orientation = VERTICAL } private val topView = LinearLayout(context)
.apply { setPadding(0, dp20, 0, 0) }
.apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle) .also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL } private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context) private val buttonLayout = LinearLayout(context)
@ -53,18 +55,17 @@ class SessionDialogBuilder(val context: Context) {
fun title(text: CharSequence?) = title(text?.toString()) fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) { fun title(text: String?) {
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) } text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 0, dp20, 0) }
} }
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style) fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) { fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) { text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
.apply { updateMargins(dp40, 0, dp40, dp20) } .apply { updateMargins(dp40, 0, dp40, 0) }
} }
} }
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) { private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return text ?: return
TextView(context, null, 0, style) TextView(context, null, 0, style)
@ -73,6 +74,10 @@ class SessionDialogBuilder(val context: Context) {
textAlignment = View.TEXT_ALIGNMENT_CENTER textAlignment = View.TEXT_ALIGNMENT_CENTER
modify() modify()
}.let(topView::addView) }.let(topView::addView)
Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, dp20)
}.let(topView::addView)
} }
fun view(view: View) = contentView.addView(view) fun view(view: View) = contentView.addView(view)
@ -125,8 +130,7 @@ class SessionDialogBuilder(val context: Context) {
) = Button(context, null, 0, style).apply { ) = Button(context, null, 0, style).apply {
setText(text) setText(text)
contentDescription = resources.getString(contentDescriptionRes) contentDescription = resources.getString(contentDescriptionRes)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f) layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f)
.apply { setMargins(toPx(20, resources)) }
setOnClickListener { setOnClickListener {
listener.invoke() listener.invoke()
if (dismiss) dismiss() if (dismiss) dismiss()

View File

@ -184,16 +184,21 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
override fun deleteMessage(messageID: Long, isSms: Boolean) { override fun deleteMessage(messageID: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
messagingDatabase.deleteMessage(messageID) messagingDatabase.deleteMessage(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms)
} }
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) { override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
// Perform local delete
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
// Perform online delete
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms)
} }

View File

@ -93,6 +93,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent?.action == ACTION_ANSWER) { if (intent?.action == ACTION_ANSWER) {
val answerIntent = WebRtcCallService.acceptCallIntent(this) val answerIntent = WebRtcCallService.acceptCallIntent(this)
answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
ContextCompat.startForegroundService(this, answerIntent) ContextCompat.startForegroundService(this, answerIntent)
} }
} }
@ -106,6 +107,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
setShowWhenLocked(true) setShowWhenLocked(true)
setTurnScreenOn(true) setTurnScreenOn(true)
} }
window.addFlags( window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
@ -334,6 +336,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
if (isEnabled) { if (isEnabled) {
viewModel.localRenderer?.let { surfaceView -> viewModel.localRenderer?.let { surfaceView ->
surfaceView.setZOrderOnTop(true) surfaceView.setZOrderOnTop(true)
// Mirror the video preview of the person making the call to prevent disorienting them
surfaceView.setMirror(true)
binding.localRenderer.addView(surfaceView) binding.localRenderer.addView(surfaceView)
} }
} }

View File

@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.annotation.DimenRes
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding import network.loki.messenger.databinding.ViewProfilePictureBinding
@ -33,13 +32,12 @@ class ProfilePictureView @JvmOverloads constructor(
var additionalDisplayName: String? = null var additionalDisplayName: String? = null
var isLarge = false var isLarge = false
private val profilePicturesCache = mutableMapOf<String, String?>() private val profilePicturesCache = mutableMapOf<View, Recipient>()
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion // endregion
constructor(context: Context, sender: Recipient): this(context) { constructor(context: Context, sender: Recipient): this(context) {
@ -90,8 +88,8 @@ class ProfilePictureView @JvmOverloads constructor(
val publicKey = publicKey ?: return val publicKey = publicKey ?: return
val additionalPublicKey = additionalPublicKey val additionalPublicKey = additionalPublicKey
if (additionalPublicKey != null) { if (additionalPublicKey != null) {
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size) setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size) setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.doubleModeImageView1) glide.clear(binding.doubleModeImageView1)
@ -99,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor(
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
} }
if (additionalPublicKey == null && !isLarge) { if (additionalPublicKey == null && !isLarge) {
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size) setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE binding.singleModeImageView.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.singleModeImageView) glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE binding.singleModeImageView.visibility = View.INVISIBLE
} }
if (additionalPublicKey == null && isLarge) { if (additionalPublicKey == null && isLarge) {
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size) setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE binding.largeSingleModeImageView.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.largeSingleModeImageView) glide.clear(binding.largeSingleModeImageView)
@ -114,17 +112,19 @@ class ProfilePictureView @JvmOverloads constructor(
} }
} }
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) { private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
if (publicKey.isNotEmpty()) { if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return if (profilePicturesCache[imageView] == recipient) return
profilePicturesCache[imageView] = recipient
val signalProfilePicture = recipient.contactPhoto val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
glide.clear(imageView)
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView)
glide.load(signalProfilePicture) glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
@ -132,21 +132,19 @@ class ProfilePictureView @JvmOverloads constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView) glide.clear(imageView)
glide.load(unknownOpenGroupDrawable) glide.load(unknownOpenGroupDrawable)
.centerCrop() .centerCrop()
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else { } else {
glide.clear(imageView)
glide.load(placeholder) glide.load(placeholder)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
.circleCrop() .circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
} }
profilePicturesCache[publicKey] = recipient.profileAvatar
} else { } else {
glide.load(unknownRecipientDrawable) glide.load(unknownRecipientDrawable)
.centerCrop() .centerCrop()

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components; package org.thoughtcrime.securesms.components;
import android.animation.Animator; import android.animation.Animator;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
@ -68,9 +67,7 @@ public class SearchToolbar extends LinearLayout {
} }
@Override @Override
public boolean onQueryTextChange(String newText) { public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); }
return onQueryTextSubmit(newText);
}
}); });
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {

View File

@ -3,133 +3,95 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Iterator; import java.util.LinkedList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import network.loki.messenger.R; import network.loki.messenger.R;
public class RecentEmojiPageModel implements EmojiPageModel { public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName(); private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2"; public static final String RECENT_EMOJIS_KEY = "Recents";
private static final int EMOJI_LRU_SIZE = 50;
public static final String KEY = "Recents";
public static final List<String> DEFAULT_REACTIONS_LIST =
Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
private final SharedPreferences prefs; public static final LinkedList<String> DEFAULT_REACTION_EMOJIS_LIST = new LinkedList<>(Arrays.asList(
private final LinkedHashSet<String> recentlyUsed; "\ud83d\ude02",
"\ud83e\udd70",
"\ud83d\ude22",
"\ud83d\ude21",
"\ud83d\ude2e",
"\ud83d\ude08"));
public static final String DEFAULT_REACTION_EMOJIS_JSON_STRING = JsonUtil.toJson(new LinkedList<>(DEFAULT_REACTION_EMOJIS_LIST));
private static SharedPreferences prefs;
private static LinkedList<String> recentlyUsed;
public RecentEmojiPageModel(Context context) { public RecentEmojiPageModel(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context); prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.recentlyUsed = getPersistedCache();
}
private LinkedHashSet<String> getPersistedCache() { // Note: Do NOT try to populate or update the persisted recent emojis in the constructor - the
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]"); // `getEmoji` method ends up getting called half-way through in a race-condition manner.
try {
CollectionType collectionType = TypeFactory.defaultInstance()
.constructCollectionType(LinkedHashSet.class, String.class);
return JsonUtil.getMapper().readValue(serialized, collectionType);
} catch (IOException e) {
Log.w(TAG, e);
return new LinkedHashSet<>();
}
} }
@Override @Override
public String getKey() { public String getKey() { return RECENT_EMOJIS_KEY; }
return KEY;
}
@Override public int getIconAttr() { @Override public int getIconAttr() { return R.attr.emoji_category_recent; }
return R.attr.emoji_category_recent;
}
@Override public List<String> getEmoji() { @Override public List<String> getEmoji() {
List<String> recent = new ArrayList<>(recentlyUsed); // Populate our recently used list if required (i.e., on first run)
List<String> out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size()); if (recentlyUsed == null) {
try {
for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) { String recentlyUsedEmjoiJsonString = prefs.getString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING);
if (recent.size() > i) { recentlyUsed = JsonUtil.fromJson(recentlyUsedEmjoiJsonString, LinkedList.class);
out.add(recent.get(i)); } catch (Exception e) {
} else { Log.w(TAG, e);
out.add(DEFAULT_REACTIONS_LIST.get(i)); Log.d(TAG, "Default reaction emoji data was corrupt (likely via key re-use on app upgrade) - rewriting fresh data.");
boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING).commit();
if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
recentlyUsed = DEFAULT_REACTION_EMOJIS_LIST;
} }
} }
return new ArrayList<>(recentlyUsed);
return out;
} }
@Override public List<Emoji> getDisplayEmoji() { @Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList(); return Stream.of(getEmoji()).map(Emoji::new).toList();
} }
@Override public boolean hasSpriteMap() { @Override public boolean hasSpriteMap() { return false; }
return false;
}
@Nullable @Nullable
@Override @Override
public Uri getSpriteUri() { public Uri getSpriteUri() { return null; }
return null;
}
@Override public boolean isDynamic() { @Override public boolean isDynamic() { return true; }
return true;
}
public void onCodePointSelected(String emoji) { public static void onCodePointSelected(String emoji) {
recentlyUsed.remove(emoji); // If the emoji is already in the recently used list then remove it..
recentlyUsed.add(emoji); if (recentlyUsed.contains(emoji)) { recentlyUsed.removeFirstOccurrence(emoji); }
if (recentlyUsed.size() > EMOJI_LRU_SIZE) { // ..and then regardless of whether the emoji used was already in the recently used list or not
Iterator<String> iterator = recentlyUsed.iterator(); // it gets placed as the first element in the list..
iterator.next(); recentlyUsed.addFirst(emoji);
iterator.remove();
}
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed); // Ensure that we only ever store data for a maximum of 6 recently used emojis (this code will
new AsyncTask<Void, Void, Void>() { // execute if if we did NOT remove any occurrence of a previously used emoji but then added the
// new emoji to the front of the list).
while (recentlyUsed.size() > 6) { recentlyUsed.removeLast(); }
@Override // ..which we then save to shared prefs.
protected Void doInBackground(Void... params) { String recentlyUsedAsJsonString = JsonUtil.toJson(recentlyUsed);
try { boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, recentlyUsedAsJsonString).commit();
String serialized = JsonUtil.toJsonThrows(latestRecentlyUsed); if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
prefs.edit()
.putString(EMOJI_LRU_PREFERENCE, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);
}
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
String[] emojis = new String[emojiSet.size()];
int i = emojiSet.size() - 1;
for (String emoji : emojiSet) {
emojis[i--] = emoji;
}
return emojis;
} }
} }

View File

@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@ -84,7 +85,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
context.theme.resolveAttribute(item.iconRes, typedValue, true) context.theme.resolveAttribute(item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
icon.imageTintList = color?.let(ColorStateList::valueOf) icon.imageTintList = ColorStateList.valueOf(color ?: context.getColorFromAttr(android.R.attr.textColor))
} }
item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it } item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
title.setText(item.title) title.setText(item.title)

View File

@ -58,7 +58,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> { private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) { return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) {
it.address.isOpenGroup it.address.isCommunity
} }
} }

View File

@ -116,7 +116,7 @@ class ConversationActionBarView @JvmOverloads constructor(
) )
} }
if (recipient.isGroupRecipient) { if (recipient.isGroupRecipient) {
val title = if (recipient.isOpenGroupRecipient) { val title = if (recipient.isCommunityRecipient) {
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
context.getString(R.string.ConversationActivity_active_member_count, userCount) context.getString(R.string.ConversationActivity_active_member_count, userCount)
} else { } else {

View File

@ -68,7 +68,7 @@ enum class ExpiryType(
AFTER_SEND( AFTER_SEND(
ExpiryMode::AfterSend, ExpiryMode::AfterSend,
R.string.expiration_type_disappear_after_send, R.string.expiration_type_disappear_after_send,
R.string.expiration_type_disappear_after_read_description, R.string.expiration_type_disappear_after_send_description,
R.string.AccessibilityId_disappear_after_send_option R.string.AccessibilityId_disappear_after_send_option
); );

View File

@ -35,11 +35,28 @@ class ContactListAdapter(
binding.profilePictureView.update(contact.recipient) binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) } binding.root.setOnClickListener { listener(contact.recipient) }
// TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like:
/*
binding.root.setOnLongClickListener {
Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}")
binding.contentView.context.showSessionDialog {
title("Delete Contact")
text("Are you sure you want to delete this contact?")
button(R.string.delete) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
endActionMode()
}
cancelButton(::endActionMode)
}
true
}
*/
} }
fun unbind() { fun unbind() { binding.profilePictureView.recycle() }
binding.profilePictureView.recycle()
}
} }
class HeaderViewHolder( class HeaderViewHolder(
@ -52,15 +69,11 @@ class ContactListAdapter(
} }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int { return items.size }
return items.size
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder) super.onViewRecycled(holder)
if (holder is ContactViewHolder) { if (holder is ContactViewHolder) { holder.unbind() }
holder.unbind()
}
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
@ -72,13 +85,9 @@ class ContactListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == ViewType.Contact) { return if (viewType == ViewType.Contact) {
ContactViewHolder( ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
ViewContactBinding.inflate(LayoutInflater.from(context), parent, false)
)
} else { } else {
HeaderViewHolder( HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
)
} }
} }

View File

@ -46,7 +46,6 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -57,8 +56,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -106,6 +103,7 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
@ -175,12 +173,11 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.time.Instant
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -191,8 +188,6 @@ import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "ConversationActivityV2" private const val TAG = "ConversationActivityV2"
@ -281,6 +276,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
private val isScrolledToWithin30dpOfBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true
private val layoutManager: LinearLayoutManager? private val layoutManager: LinearLayoutManager?
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
@ -336,6 +334,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
lifecycleCoroutineScope = lifecycleScope lifecycleCoroutineScope = lifecycleScope
) )
adapter.visibleMessageViewDelegate = this adapter.visibleMessageViewDelegate = this
// Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView if we're
// already near the the bottom and the data changes.
adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter))
adapter adapter
} }
@ -352,6 +355,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private lateinit var reactionDelegate: ConversationReactionDelegate private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1 private val reactWithAnyEmojiStartPage = -1
// Properties for what message indices are visible previously & now, as well as the scroll state
private var previousLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
// region Settings // region Settings
companion object { companion object {
// Extras // Extras
@ -375,12 +383,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivityConversationV2Binding.inflate(layoutInflater) binding = ActivityConversationV2Binding.inflate(layoutInflater)
setContentView(binding!!.root) setContentView(binding!!.root)
// messageIdToScroll // messageIdToScroll
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR))
val recipient = viewModel.recipient val recipient = viewModel.recipient
val openGroup = recipient.let { viewModel.openGroup } val openGroup = recipient.let { viewModel.openGroup }
if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) { if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
return finish() return finish()
} }
@ -390,6 +399,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpLinkPreviewObserver() setUpLinkPreviewObserver()
restoreDraftIfNeeded() restoreDraftIfNeeded()
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
@ -419,9 +429,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded()
setUpMessageRequestsBar() setUpMessageRequestsBar()
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
// keyboard visible and have no need to immediately display it.
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -563,17 +575,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (recyclerScrollState == RecyclerView.SCROLL_STATE_IDLE) {
scrollToMostRecentMessageIfWeShould()
}
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
recyclerScrollState = newState
} }
}) })
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
showScrollToBottomButtonIfApplicable()
} }
private fun scrollToMostRecentMessageIfWeShould() {
// Grab an initial 'previous' last visible message..
if (previousLastVisibleRecyclerViewIndex == RecyclerView.NO_POSITION) {
previousLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
}
// ..and grab the 'current' last visible message.
currentLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
// If the current last visible message index is less than the previous one (i.e. we've
// lost visibility of one or more messages due to showing the IME keyboard) AND we're
// at the bottom of the message feed..
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!!
// ..OR we're at the last message or have received a new message..
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call
// scroll/smoothScroll - we have to `post` it or nothing happens!
if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount)
}
}
// Update our previous last visible view index to the current one
previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex
} }
// called from onCreate // called from onCreate
@ -760,13 +800,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// of the first unread message in the middle of the screen // of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) { if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition return lastSeenItemPosition
} }
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition return lastSeenItemPosition
} }
@ -931,11 +970,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
view.glide = glide view.glide = glide
view.onCandidateSelected = { handleMentionSelected(it) } view.onCandidateSelected = { handleMentionSelected(it) }
additionalContentContainer.addView(view) additionalContentContainer.addView(view)
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView = view this.mentionCandidatesView = view
view.show(candidates, viewModel.threadId) view.show(candidates, viewModel.threadId)
} else { } else {
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates) this.mentionCandidatesView!!.setMentionCandidates(candidates)
} }
isShowingMentionCandidatesView = true isShowingMentionCandidatesView = true
@ -1040,8 +1079,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun handleRecyclerViewScrolled() { private fun handleRecyclerViewScrolled() {
val binding = binding ?: return val binding = binding ?: return
// Note: The typing indicate is whether the other person / other people are typing - it has
// nothing to do with the IME keyboard state.
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable() showScrollToBottomButtonIfApplicable()
val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
@ -1069,6 +1112,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val blindedRecipient = viewModel.blindedRecipient val blindedRecipient = viewModel.blindedRecipient
val binding = binding ?: return val binding = binding ?: return
val openGroup = viewModel.openGroup val openGroup = viewModel.openGroup
val (textResource, insertParam) = when { val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
@ -1148,7 +1192,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun copyOpenGroupUrl(thread: Recipient) { override fun copyOpenGroupUrl(thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
@ -1207,6 +1251,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// `position` is the adapter position; not the visual position // `position` is the adapter position; not the visual position
private fun handleSwipeToReply(message: MessageRecord) { private fun handleSwipeToReply(message: MessageRecord) {
if (message.isOpenGroupInvitation) return
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide) binding?.inputBar?.draftQuote(recipient, message, glide)
} }
@ -1286,6 +1331,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendEmojiRemoval(emoji, messageRecord) sendEmojiRemoval(emoji, messageRecord)
} else { } else {
sendEmojiReaction(emoji, messageRecord) sendEmojiReaction(emoji, messageRecord)
RecentEmojiPageModel.onCodePointSelected(emoji) // Save to recently used reaction emojis
} }
} }
@ -1312,7 +1358,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address } else originalMessage.individualRecipient.address
// Send it // Send it
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true)
if (recipient.isOpenGroupRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
@ -1336,7 +1382,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address } else originalMessage.individualRecipient.address
message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false)
if (recipient.isOpenGroupRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
@ -1733,7 +1779,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendAttachments(slideDeck.asAttachments(), body) sendAttachments(slideDeck.asAttachments(), body)
} }
INVITE_CONTACTS -> { INVITE_CONTACTS -> {
if (viewModel.recipient?.isOpenGroupRecipient != true) { return } if (viewModel.recipient?.isCommunityRecipient != true) { return }
val extras = intent?.extras ?: return val extras = intent?.extras ?: return
if (!intent.hasExtra(selectedContactsKey)) { return } if (!intent.hasExtra(selectedContactsKey)) { return }
val selectedContacts = extras.getStringArray(selectedContactsKey)!! val selectedContacts = extras.getStringArray(selectedContactsKey)!!
@ -1799,19 +1845,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleLongPress(messages.first(), 0) //TODO: begin selection mode handleLongPress(messages.first(), 0) //TODO: begin selection mode
} }
override fun deleteMessages(messages: Set<MessageRecord>) { // The option to "Delete just for me" or "Delete for everyone"
val recipient = viewModel.recipient ?: return private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set<MessageRecord>) {
val allSentByCurrentUser = messages.all { it.isOutgoing } val bottomSheet = DeleteOptionsBottomSheet()
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } bottomSheet.recipient = viewModel.recipient!!
if (recipient.isOpenGroupRecipient) { bottomSheet.onDeleteForMeTapped = {
val messageCount = 1 messages.forEach(viewModel::deleteLocally)
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.onDeleteForEveryoneTapped = {
messages.forEach(viewModel::deleteForEveryone)
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.onCancelTapped = {
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
}
private fun showDeleteLocallyUI(messages: Set<MessageRecord>) {
val messageCount = 1
showSessionDialog { showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
cancelButton(::endActionMode)
}
}
// Note: The messages in the provided set may be a single message, or multiple if there are a
// group of selected messages.
override fun deleteMessages(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient
if (recipient == null) {
Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.")
return
}
val allSentByCurrentUser = messages.all { it.isOutgoing }
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null }
// If the recipient is a community OR a Note-to-Self then we delete the message for everyone
if (recipient.isCommunityRecipient || recipient.isLocalNumber) {
val messageCount = 1 // Only used for plurals string
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) {
messages.forEach(viewModel::deleteForEveryone); endActionMode()
}
cancelButton { endActionMode() } cancelButton { endActionMode() }
} }
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
} else if (allSentByCurrentUser && allHasHash) { } else if (allSentByCurrentUser && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet() val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient bottomSheet.recipient = recipient
@ -1830,13 +1918,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode() endActionMode()
} }
bottomSheet.show(supportFragmentManager, bottomSheet.tag) bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else { }
else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally.
{
val messageCount = 1 val messageCount = 1
showSessionDialog { showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } button(R.string.delete) {
messages.forEach(viewModel::deleteLocally); endActionMode()
}
cancelButton(::endActionMode) cancelButton(::endActionMode)
} }
} }
@ -1855,7 +1946,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog { showSessionDialog {
title(R.string.ConversationFragment_ban_selected_user) title(R.string.ConversationFragment_ban_selected_user)
text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() } button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
cancelButton(::endActionMode) cancelButton(::endActionMode)
} }
} }
@ -1937,7 +2028,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val message = messages.first() as MmsMessageRecord val message = messages.first() as MmsMessageRecord
// Do not allow the user to download a file attachment before it has finished downloading // Do not allow the user to download a file attachment before it has finished downloading
// TODO: Localise the msg in this toast!
if (message.isMediaPending) { if (message.isMediaPending) {
Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show() Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show()
return return
@ -2107,4 +2197,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
// AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView
// when we're already near the bottom and we send or receive a message.
inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
if (recyclerView.isScrolledToWithin30dpOfBottom) {
// Note: The adapter itemCount is zero based - so calling this with the itemCount in
// a non-zero based manner scrolls us to the bottom of the last message (including
// to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
recyclerView.scrollToPosition(adapter.itemCount)
}
}
}
} }

View File

@ -22,10 +22,12 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
@ -57,6 +59,7 @@ class ConversationAdapter(
private val contactCache = SparseArray<Contact>(100) private val contactCache = SparseArray<Contact>(100)
private val contactLoadedCache = SparseBooleanArray(100) private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen) private val lastSeen = AtomicLong(originalLastSeen)
private var lastSentMessageId: Long = -1L
init { init {
lifecycleCoroutineScope.launch(IO) { lifecycleCoroutineScope.launch(IO) {
@ -136,7 +139,8 @@ class ConversationAdapter(
senderId, senderId,
lastSeen.get(), lastSeen.get(),
visibleMessageViewDelegate, visibleMessageViewDelegate,
onAttachmentNeedsDownload onAttachmentNeedsDownload,
lastSentMessageId
) )
if (!message.isDeleted) { if (!message.isDeleted) {
@ -205,8 +209,23 @@ class ConversationAdapter(
return messageDB.readerFor(cursor).current return messageDB.readerFor(cursor).current
} }
private fun getLastSentMessageId(cursor: Cursor): Long {
// If we don't move to first (or at least step backwards) we can step off the end of the
// cursor and any query will return an "Index = -1" error.
val cursorHasContent = cursor.moveToFirst()
if (cursorHasContent) {
val thisThreadId = cursor.getLong(4) // Column index 4 is "thread_id"
if (thisThreadId != -1L) {
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
return messageDB.getLastSentMessageFromSender(thisThreadId, thisUsersSessionId)
}
}
return -1L
}
override fun changeCursor(cursor: Cursor?) { override fun changeCursor(cursor: Cursor?) {
super.changeCursor(cursor) super.changeCursor(cursor)
val toRemove = mutableSetOf<MessageRecord>() val toRemove = mutableSetOf<MessageRecord>()
val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>() val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>()
for (selected in selectedItems) { for (selected in selectedItems) {
@ -224,6 +243,11 @@ class ConversationAdapter(
toDeselect.iterator().forEach { (pos, record) -> toDeselect.iterator().forEach { (pos, record) ->
onDeselect(record, pos) onDeselect(record, pos)
} }
// This value gets updated here ONLY when the cursor changes, and the value is then passed
// through to `VisibleMessageView.bind` each time we bind via `onBindItemViewHolder`, above.
// If there are no messages then lastSentMessageId is assigned the value -1L.
if (cursor != null) { lastSentMessageId = getLastSentMessageId(cursor) }
} }
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {

View File

@ -39,7 +39,6 @@ import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.ReactionRecord
@ -533,7 +532,7 @@ class ConversationReactionOverlay : FrameLayout {
items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
// Reply // Reply
val canWrite = openGroup == null || openGroup.canWrite val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed) { if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message) items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
} }
// Copy message text // Copy message text
@ -541,7 +540,7 @@ class ConversationReactionOverlay : FrameLayout {
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
} }
// Copy Session ID // Copy Session ID
if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) { if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) {
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) }) items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) })
} }
// Delete message // Delete message

View File

@ -1,18 +1,23 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
@ -22,9 +27,12 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
@ -144,9 +152,14 @@ class ConversationViewModel(
} }
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
repository.deleteForEveryone(threadId, recipient, message) repository.deleteForEveryone(threadId, recipient, message)
.onSuccess {
Log.d("Loki", "Deleted message ${message.id} ")
}
.onFailure { .onFailure {
Log.w("Loki", "FAILED TO delete message ${message.id} ")
showMessage("Couldn't delete message due to error: $it") showMessage("Couldn't delete message due to error: $it")
} }
} }
@ -168,10 +181,15 @@ class ConversationViewModel(
} }
} }
fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch { fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch {
repository.banAndDeleteAll(threadId, recipient)
repository.banAndDeleteAll(threadId, messageRecord.individualRecipient)
.onSuccess { .onSuccess {
// At this point the server side messages have been successfully deleted..
showMessage("Successfully banned user and deleted all their messages") showMessage("Successfully banned user and deleted all their messages")
// ..so we can now delete all their messages in this thread from local storage & remove the views.
repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord)
} }
.onFailure { .onFailure {
showMessage("Couldn't execute request due to error: $it") showMessage("Couldn't execute request due to error: $it")

View File

@ -123,7 +123,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
AppTheme { AppTheme {
MessageDetails( MessageDetails(
state = state, state = state,
onReply = { setResultAndFinish(ON_REPLY) }, onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) }, onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) }, onClickImage = { viewModel.onClickImage(it) },
@ -145,7 +145,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable @Composable
fun MessageDetails( fun MessageDetails(
state: MessageDetailsState, state: MessageDetailsState,
onReply: () -> Unit = {}, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {}, onClickImage: (Int) -> Unit = {},
@ -214,18 +214,20 @@ fun CellMetadata(
@Composable @Composable
fun CellButtons( fun CellButtons(
onReply: () -> Unit = {}, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
) { ) {
Cell { Cell {
Column { Column {
onReply?.let {
ItemButton( ItemButton(
stringResource(R.string.reply), stringResource(R.string.reply),
R.drawable.ic_message_details__reply, R.drawable.ic_message_details__reply,
onClick = onReply onClick = it
) )
Divider() Divider()
}
onResend?.let { onResend?.let {
ItemButton( ItemButton(
stringResource(R.string.resend), stringResource(R.string.resend),

View File

@ -117,7 +117,7 @@ class MessageDetailsViewModel @Inject constructor(
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide) Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) { fun onClickImage(index: Int) {
val state = state.value ?: return val state = state.value
val mmsRecord = state.mmsRecord ?: return val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images // only open to downloaded images
@ -158,6 +158,7 @@ data class MessageDetailsState(
val thread: Recipient? = null, val thread: Recipient? = null,
) { ) {
val fromTitle = GetString(R.string.message_details_header__from) val fromTitle = GetString(R.string.message_details_header__from)
val canReply = record?.isOpenGroupInvitation != true
} }
data class Attachment( data class Attachment(

View File

@ -140,6 +140,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
} }
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
quote = message quote = message
// If we already have a link preview View then clear the 'additional content' layout so that // If we already have a link preview View then clear the 'additional content' layout so that
@ -178,7 +180,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
// message we'll bail early if a link preview View already exists and just let // message we'll bail early if a link preview View already exists and just let
// `updateLinkPreview` get called to update the existing View. // `updateLinkPreview` get called to update the existing View.
if (linkPreview != null && linkPreviewDraftView != null) return if (linkPreview != null && linkPreviewDraftView != null) return
linkPreviewDraftView?.let(binding.inputBarAdditionalContentContainer::removeView)
linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this } linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this }
// Add the link preview View. Note: If there's already a quote View in the 'additional // Add the link preview View. Note: If there's already a quote View in the 'additional

View File

@ -65,7 +65,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID // Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible = menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail // Message detail
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend // Resend
@ -77,7 +77,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide()) && firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
// Reply // Reply
menu.findItem(R.id.menu_context_reply).isVisible = menu.findItem(R.id.menu_context_reply).isVisible =
(selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed) (selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed && !firstMessage.isOpenGroupInvitation)
} }
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {

View File

@ -50,7 +50,7 @@ object ConversationMenuHelper {
) { ) {
// Prepare // Prepare
menu.clear() menu.clear()
val isOpenGroup = thread.isOpenGroupRecipient val isOpenGroup = thread.isCommunityRecipient
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
@ -253,7 +253,7 @@ object ConversationMenuHelper {
} }
private fun copyOpenGroupUrl(context: Context, thread: Recipient) { private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val listener = context as? ConversationMenuListener ?: return val listener = context as? ConversationMenuListener ?: return
listener.copyOpenGroupUrl(thread) listener.copyOpenGroupUrl(thread)
} }
@ -300,7 +300,7 @@ object ConversationMenuHelper {
} }
private fun inviteContacts(context: Context, thread: Recipient) { private fun inviteContacts(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val intent = Intent(context, SelectContactsActivity::class.java) val intent = Intent(context, SelectContactsActivity::class.java)
val activity = context as AppCompatActivity val activity = context as AppCompatActivity
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)

View File

@ -32,10 +32,12 @@ import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
@ -64,6 +66,7 @@ private const val TAG = "VisibleMessageView"
@AndroidEntryPoint @AndroidEntryPoint
class VisibleMessageView : LinearLayout { class VisibleMessageView : LinearLayout {
private var replyDisabled: Boolean = false
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase
@ -131,8 +134,10 @@ class VisibleMessageView : LinearLayout {
senderSessionID: String, senderSessionID: String,
lastSeen: Long, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null, delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit,
lastSentMessageId: Long
) { ) {
replyDisabled = message.isOpenGroupInvitation
val threadID = message.threadId val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return val thread = threadDb.getRecipientForThreadId(threadID) ?: return
val isGroupThread = thread.isGroupRecipient val isGroupThread = thread.isGroupRecipient
@ -164,7 +169,7 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.publicKey = senderSessionID binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener { binding.profilePictureView.setOnClickListener {
if (thread.isOpenGroupRecipient) { if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon // TODO: support v2 soon
@ -177,7 +182,7 @@ class VisibleMessageView : LinearLayout {
maybeShowUserDetails(senderSessionID, threadID) maybeShowUserDetails(senderSessionID, threadID)
} }
} }
if (thread.isOpenGroupRecipient) { if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = "" var standardPublicKey = ""
var blindedPublicKey: String? = null var blindedPublicKey: String? = null
@ -193,16 +198,20 @@ class VisibleMessageView : LinearLayout {
} }
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext = val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker // Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
// Date break // Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator
// Update message status indicator
showStatusMessage(message) showStatusMessage(message)
// Emoji Reactions // Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
@ -237,42 +246,101 @@ class VisibleMessageView : LinearLayout {
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
} }
// Method to display or hide the status of a message.
// Note: Although most commonly used to display the delivery status of a message, we also use the
// message status area to display the disappearing messages state - so in this latter case we'll
// be displaying the "Sent" and the animating clock icon for outgoing messages or "Read" and the
// animated clock icon for incoming messages.
private fun showStatusMessage(message: MessageRecord) { private fun showStatusMessage(message: MessageRecord) {
val disappearing = message.expiresIn > 0 // We'll start by hiding everything and then only make visible what we need
binding.messageStatusTextView.isVisible = false
binding.messageStatusImageView.isVisible = false
binding.expirationTimerView.isVisible = false
// Get details regarding how we should display the message (it's delivery icon, icon tint colour, and
// the resource string for what text to display (R.string.delivery_status_sent etc.).
val (iconID, iconColor, textId) = getMessageStatusInfo(message)
// If we get any nulls then a message isn't one with a state that we care about (i.e., control messages
// etc.) - so bail. See: `DisplayRecord.is<WHATEVER>` for the full suite of message state methods.
// Also: We set all delivery status elements visibility to false just to make sure we don't display any
// stale data.
if (textId == null) return
binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> { binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> {
gravity = if (message.isOutgoing) Gravity.END else Gravity.START gravity = if (message.isOutgoing) Gravity.END else Gravity.START
} }
binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> { binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> {
horizontalBias = if (message.isOutgoing) 1f else 0f horizontalBias = if (message.isOutgoing) 1f else 0f
} }
binding.expirationTimerView.isGone = true // If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details
val scheduledToDisappear = message.expiresIn > 0
if (message.isIncoming && !scheduledToDisappear) return
if (message.isOutgoing || disappearing) { // Set text & icons as appropriate for the message state. Note: Possible message states we care
val (iconID, iconColor, textId) = getMessageStatusImage(message) // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent.
textId?.let(binding.messageStatusTextView::setText) textId.let(binding.messageStatusTextView::setText)
iconColor?.let(binding.messageStatusTextView::setTextColor) iconColor?.let(binding.messageStatusTextView::setTextColor)
iconID?.let { ContextCompat.getDrawable(context, it) } iconID?.let { ContextCompat.getDrawable(context, it) }
?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this }
?.let(binding.messageStatusImageView::setImageDrawable) ?.let(binding.messageStatusImageView::setImageDrawable)
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) // Potential options at this point are that the message is:
val isLastMessage = message.id == lastMessageID // i.) incoming AND scheduled to disappear.
binding.messageStatusTextView.isVisible = // ii.) outgoing but NOT scheduled to disappear, or
textId != null && (!message.isSent || isLastMessage || disappearing) // iii.) outgoing AND scheduled to disappear.
val showTimer = disappearing && !message.isPending
binding.messageStatusImageView.isVisible =
iconID != null && !showTimer && (!message.isSent || isLastMessage)
binding.messageStatusImageView.bringToFront() // ----- Case i..) Message is incoming and scheduled to disappear -----
if (message.isIncoming && scheduledToDisappear) {
// Display the status ('Read') and the show the timer only (no delivery icon)
binding.messageStatusTextView.isVisible = true
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.bringToFront() binding.expirationTimerView.bringToFront()
binding.expirationTimerView.isVisible = showTimer updateExpirationTimer(message)
if (showTimer) updateExpirationTimer(message) return
}
// --- If we got here then we know the message is outgoing ---
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId)
val isLastSentMessage = lastSentMessageId == message.id
// ----- Case ii.) Message is outgoing but NOT scheduled to disappear -----
if (!scheduledToDisappear) {
// If this isn't a disappearing message then we never show the timer
// If the message has NOT been successfully sent then always show the delivery status text and icon..
val neitherSentNorRead = !(message.isSent || message.isRead)
if (neitherSentNorRead) {
binding.messageStatusTextView.isVisible = true
binding.messageStatusImageView.isVisible = true
} else { } else {
binding.messageStatusTextView.isVisible = false // ..but if the message HAS been successfully sent or read then only display the delivery status
binding.messageStatusImageView.isVisible = false // text and image if this is the last sent message.
binding.messageStatusTextView.isVisible = isLastSentMessage
binding.messageStatusImageView.isVisible = isLastSentMessage
if (isLastSentMessage) { binding.messageStatusImageView.bringToFront() }
}
}
else // ----- Case iii.) Message is outgoing AND scheduled to disappear -----
{
// Always display the delivery status text on all outgoing disappearing messages
binding.messageStatusTextView.isVisible = true
// If the message is sent or has been read..
val sentOrRead = message.isSent || message.isRead
if (sentOrRead) {
// ..then display the timer icon for this disappearing message (but keep the message status icon hidden)
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.bringToFront()
updateExpirationTimer(message)
} else {
// If the message has NOT been sent or read (or it has failed) then show the delivery status icon rather than the timer icon
binding.messageStatusImageView.isVisible = true
binding.messageStatusImageView.bringToFront()
}
} }
} }
@ -294,10 +362,9 @@ class VisibleMessageView : LinearLayout {
@ColorInt val iconTint: Int?, @ColorInt val iconTint: Int?,
@StringRes val messageText: Int?) @StringRes val messageText: Int?)
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when { private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when {
message.isFailed -> message.isFailed ->
MessageStatusInfo( MessageStatusInfo(R.drawable.ic_delivery_status_failed,
R.drawable.ic_delivery_status_failed,
resources.getColor(R.color.destructive, context.theme), resources.getColor(R.color.destructive, context.theme),
R.string.delivery_status_failed R.string.delivery_status_failed
) )
@ -310,24 +377,32 @@ class VisibleMessageView : LinearLayout {
message.isPending -> message.isPending ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sending
) )
message.isResyncing -> message.isSyncing || message.isResyncing ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sending // We COULD tell the user that we're `syncing` (R.string.delivery_status_syncing) but it will likely make more sense to them if we say "Sending"
) )
message.isRead || !message.isOutgoing -> message.isRead || message.isIncoming ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_read, R.drawable.ic_delivery_status_read,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_read
) )
else -> message.isSent ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sent, R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color), context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent R.string.delivery_status_sent
) )
else -> {
// The message isn't one we care about for message statuses we display to the user (i.e.,
// control messages etc. - see the `DisplayRecord.is<WHATEVER>` suite of methods for options).
MessageStatusInfo(null, null, null)
}
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
@ -401,6 +476,7 @@ class VisibleMessageView : LinearLayout {
} else { } else {
longPressCallback?.let { gestureHandler.removeCallbacks(it) } longPressCallback?.let { gestureHandler.removeCallbacks(it) }
} }
if (replyDisabled) return
if (translationX > 0) { return } // Only allow swipes to the left if (translationX > 0) { return } // Only allow swipes to the left
// The idea here is to asymptotically approach a maximum drag distance // The idea here is to asymptotically approach a maximum drag distance
val damping = 50.0f val damping = 50.0f

View File

@ -241,7 +241,21 @@ public class AttachmentManager {
} }
public static void selectDocument(Activity activity, int requestCode) { public static void selectDocument(Activity activity, int requestCode) {
selectMediaType(activity, "*/*", null, requestCode); Permissions.PermissionsBuilder builder = Permissions.with(activity);
// The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on
// Android 13 and above (API 33 - 'Tiramisu') we must ask for READ_MEDIA_VIDEO/IMAGES/AUDIO instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.request(Manifest.permission.READ_MEDIA_IMAGES)
.request(Manifest.permission.READ_MEDIA_AUDIO);
} else {
builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this.
.execute();
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {

View File

@ -1,21 +1,28 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Application
import android.content.Context import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Range import android.util.Range
import androidx.appcompat.widget.ThemeUtils
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2 import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
import java.util.regex.Pattern import java.util.regex.Pattern
object MentionUtilities { object MentionUtilities {
@ -58,15 +65,37 @@ object MentionUtilities {
} }
} }
val result = SpannableString(text) val result = SpannableString(text)
val isLightMode = UiModeUtilities.isDayUiMode(context)
val color = if (isOutgoingMessage) { var mentionTextColour: Int? = null
ResourcesCompat.getColor(context.resources, if (isLightMode) R.color.white else R.color.black, context.theme) // In dark themes..
} else { if (ThemeUtil.isDarkTheme(context)) {
context.getAccentColor() // ..we use the standard outgoing message colour for outgoing messages..
if (isOutgoingMessage) {
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
} }
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
{
mentionTextColour = context.getAccentColor()
}
}
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
{
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
}
for (mention in mentions) { for (mention in mentions) {
result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// If we're using a light theme then we change the background colour of the mention to be the accent colour
if (ThemeUtil.isLightTheme(context)) {
val backgroundColour = context.getAccentColor();
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} }
return result return result
} }

View File

@ -26,6 +26,7 @@ import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -77,11 +78,11 @@ public abstract class Database {
notifyConversationListListeners(); notifyConversationListListeners();
} }
protected void setNotifyConverationListeners(Cursor cursor, long threadId) { protected void setNotifyConversationListeners(Cursor cursor, long threadId) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId)); cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
} }
protected void setNotifyConverationListListeners(Cursor cursor) { protected void setNotifyConversationListListeners(Cursor cursor) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI); cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI);
} }

View File

@ -6,8 +6,8 @@ import android.database.Cursor
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX
import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_INBOX_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -38,8 +38,8 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_INBOX_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
""".trimIndent() """.trimIndent()

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol {
@ -72,7 +73,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
"${Companion.messageID} = ? AND $messageType = ?", "${Companion.messageID} = ? AND $messageType = ?",
arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor ->
cursor.getInt(serverID).toLong() cursor.getInt(serverID).toLong()
} ?: return }
if (serverID == null) {
Log.w(this::class.simpleName, "Could not get server ID to delete message with ID: $messageID")
return
}
database.beginTransaction() database.beginTransaction()

View File

@ -68,7 +68,7 @@ public class MediaDatabase extends Database {
public Cursor getGalleryMediaForThread(long threadId) { public Cursor getGalleryMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""}); Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""});
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }
@ -83,7 +83,7 @@ public class MediaDatabase extends Database {
public Cursor getDocumentMediaForThread(long threadId) { public Cursor getDocumentMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""}); Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""});
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }

View File

@ -19,9 +19,9 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.provider.ContactsContract.CommonDataKinds.BaseTypes
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.PduHeaders import com.google.android.mms.pdu_alt.PduHeaders
import org.apache.commons.lang3.StringUtils
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@ -214,7 +214,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
fun getMessage(messageId: Long): Cursor { fun getMessage(messageId: Long): Cursor {
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId))
return cursor return cursor
} }
@ -630,6 +630,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup })
val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) { if (messageId == -1L) {
Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.")
return Optional.absent() return Optional.absent()
} }
markAsSent(messageId, true) markAsSent(messageId, true)
@ -859,8 +860,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
*/ */
private fun deleteMessages(messageIds: Array<String?>) { private fun deleteMessages(messageIds: Array<String?>) {
if (messageIds.isEmpty()) { if (messageIds.isEmpty()) {
Log.w(TAG, "No message Ids provided to MmsDatabase.deleteMessages - aborting delete operation!")
return return
} }
// don't need thread IDs // don't need thread IDs
val queryBuilder = StringBuilder() val queryBuilder = StringBuilder()
for (i in messageIds.indices) { for (i in messageIds.indices) {
@ -883,6 +886,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyStickerPackListeners() notifyStickerPackListeners()
} }
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
override fun deleteMessage(messageId: Long): Boolean { override fun deleteMessage(messageId: Long): Boolean {
val threadId = getThreadIdForMessage(messageId) val threadId = getThreadIdForMessage(messageId)
val attachmentDatabase = get(context).attachmentDatabase() val attachmentDatabase = get(context).attachmentDatabase()
@ -899,14 +904,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean { override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
val attachmentDatabase = get(context).attachmentDatabase() val argsArray = messageIds.map { "?" }
val groupReceiptDatabase = get(context).groupReceiptDatabase() val argValues = messageIds.map { it.toString() }.toTypedArray()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) val db = databaseHelper.writableDatabase
groupReceiptDatabase.deleteRowsForMessages(messageIds) db.delete(
TABLE_NAME,
val database = databaseHelper.writableDatabase ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) argValues
)
val threadDeleted = get(context).threadDatabase().update(threadId, false, true) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
@ -1089,8 +1095,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val whereString = where.substring(0, where.length - 4) val whereString = where.substring(0, where.length - 4)
try { try {
cursor = cursor = db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, null, null, null, null)
db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, null, null, null, null)
val toDeleteStringMessageIds = mutableListOf<String>() val toDeleteStringMessageIds = mutableListOf<String>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
toDeleteStringMessageIds += cursor.getLong(0).toString() toDeleteStringMessageIds += cursor.getLong(0).toString()

View File

@ -30,6 +30,7 @@ import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
@ -115,6 +116,53 @@ public class MmsSmsDatabase extends Database {
return null; return null;
} }
public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) {
// Early exit if the author is not us
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
if (!isOwnNumber) {
Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null.");
return null;
}
try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
MmsSmsDatabase.Reader reader = readerFor(cursor);
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
if (messageRecord.isOutgoing())
{
return messageRecord;
}
}
}
Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null.");
return null;
}
public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) {
// Early exit if the author is not us
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
if (!isOwnNumber) {
Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null.");
return null;
}
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
if (messageRecord.isOutgoing()) { return messageRecord; }
}
}
}
Log.i(TAG, "Could not find last sent message from us in given thread - returning null.");
return null;
}
public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { public @Nullable MessageRecord getMessageFor(long timestamp, Address author) {
return getMessageFor(timestamp, author.serialize()); return getMessageFor(timestamp, author.serialize());
} }
@ -183,7 +231,7 @@ public class MmsSmsDatabase extends Database {
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }
@ -209,6 +257,82 @@ public class MmsSmsDatabase extends Database {
} }
} }
// Builds up and returns a list of all all the messages sent by this user in the given thread.
// Used to do a pass through our local database to remove records when a user has "Ban & Delete"
// called on them in a Community.
public Set<MessageRecord> getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
Set<MessageRecord> identifiedMessages = new HashSet<MessageRecord>();
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
identifiedMessages.add(messageRecord);
}
}
}
return identifiedMessages;
}
// Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message
// Ids rather than the set of MessageRecords - currently unused by potentially useful in the future.
public Set<Long> getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
Set<Long> identifiedMessages = new HashSet<Long>();
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
identifiedMessages.add(messageRecord.id);
}
}
}
return identifiedMessages;
}
public long getLastSentMessageFromSender(long threadId, String serializedAuthor) {
// Early exit
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
if (!isOwnNumber) {
Log.i(TAG, "Asked to find last sent message but sender isn't us - returning null.");
return -1;
}
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
if (messageRecord.isOutgoing()) { return messageRecord.id; }
}
}
}
Log.i(TAG, "Could not find last sent message from us - returning -1.");
return -1;
}
public long getLastMessageTimestamp(long threadId) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) {
if (cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT));
}
}
return -1;
}
public Cursor getUnread() { public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC";
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0"; String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";

View File

@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
@ -123,18 +123,18 @@ public class RecipientDatabase extends Database {
public static String getUpdateApprovedCommand() { public static String getUpdateApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " + return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " + "SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " +
"WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
} }
public static String getUpdateResetApprovedCommand() { public static String getUpdateResetApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " + return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " + "SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " +
"WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
} }
public static String getUpdateApprovedSelectConversations() { public static String getUpdateApprovedSelectConversations() {
return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+ return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+
"WHERE "+ADDRESS+ " NOT LIKE '"+OPEN_GROUP_PREFIX+"%' " + "WHERE "+ADDRESS+ " NOT LIKE '"+ COMMUNITY_PREFIX +"%' " +
"AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE ("+ThreadDatabase.MESSAGE_COUNT+" != 0) "+ "AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE ("+ThreadDatabase.MESSAGE_COUNT+" != 0) "+
"OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
} }

View File

@ -10,6 +10,7 @@ import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import java.util.List; import java.util.List;
@ -115,11 +116,9 @@ public class SearchDatabase extends Database {
public Cursor queryMessages(@NonNull String query) { public Cursor queryMessages(@NonNull String query) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query); String prefixQuery = adjustQuery(query);
int queryLimit = Math.min(query.length()*50,500); int queryLimit = Math.min(query.length()*50,500);
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) }); Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) });
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }
@ -128,7 +127,7 @@ public class SearchDatabase extends Database {
String prefixQuery = adjustQuery(query); String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) }); Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }

View File

@ -22,15 +22,11 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteStatement; import net.zetetic.database.sqlcipher.SQLiteStatement;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.calls.CallMessageType;
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingGroupMessage;
@ -51,7 +47,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.security.SecureRandom; import java.security.SecureRandom;
@ -621,17 +616,20 @@ public class SmsDatabase extends MessagingDatabase {
public Cursor getMessageCursor(long messageId) { public Cursor getMessageCursor(long messageId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null); Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null);
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)); setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId));
return cursor; return cursor;
} }
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
@Override @Override
public boolean deleteMessage(long messageId) { public boolean deleteMessage(long messageId) {
Log.i("MessageDatabase", "Deleting: " + messageId); Log.i("MessageDatabase", "Deleting: " + messageId);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
return threadDeleted; return threadDeleted;
} }
@ -645,9 +643,6 @@ public class SmsDatabase extends MessagingDatabase {
argValues[i] = (messageIds[i] + ""); argValues[i] = (messageIds[i] + "");
} }
String combinedMessageIdArgss = StringUtils.join(messageIds, ',');
String combinedMessageIds = StringUtils.join(messageIds, ',');
Log.i("MessageDatabase", "Deleting: " + combinedMessageIds);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete( db.delete(
TABLE_NAME, TABLE_NAME,
@ -701,12 +696,7 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
/*package */void deleteThread(long threadId) { void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
}
/*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND (CASE " + TYPE; String where = THREAD_ID + " = ? AND (CASE " + TYPE;
@ -719,7 +709,12 @@ public class SmsDatabase extends MessagingDatabase {
db.delete(TABLE_NAME, where, new String[] {threadId + ""}); db.delete(TABLE_NAME, where, new String[] {threadId + ""});
} }
/*package*/ void deleteThreads(Set<Long> threadIds) { void deleteThread(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
}
void deleteThreads(Set<Long> threadIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = ""; String where = "";
@ -727,23 +722,23 @@ public class SmsDatabase extends MessagingDatabase {
where += THREAD_ID + " = '" + threadId + "' OR "; where += THREAD_ID + " = '" + threadId + "' OR ";
} }
where = where.substring(0, where.length() - 4); where = where.substring(0, where.length() - 4); // Remove the final: "' OR "
db.delete(TABLE_NAME, where, null); db.delete(TABLE_NAME, where, null);
} }
/*package */ void deleteAllThreads() { void deleteAllThreads() {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, null, null); db.delete(TABLE_NAME, null, null);
} }
/*package*/ SQLiteDatabase beginTransaction() { SQLiteDatabase beginTransaction() {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction(); database.beginTransaction();
return database; return database;
} }
/*package*/ void endTransaction(SQLiteDatabase database) { void endTransaction(SQLiteDatabase database) {
database.setTransactionSuccessful(); database.setTransactionSuccessful();
database.endTransaction(); database.endTransaction();
} }

View File

@ -92,7 +92,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.time.Duration.Companion.days
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
private const val TAG = "Storage" private const val TAG = "Storage"
@ -100,7 +99,7 @@ private const val TAG = "Storage"
open class Storage( open class Storage(
context: Context, context: Context,
helper: SQLCipherOpenHelper, helper: SQLCipherOpenHelper,
private val configFactory: ConfigFactory val configFactory: ConfigFactory
) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { ) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) { override fun threadCreated(address: Address, threadId: Long) {
@ -121,7 +120,7 @@ open class Storage(
) )
volatile.set(newVolatileParams) volatile.set(newVolatileParams)
} }
} else if (address.isOpenGroup) { } else if (address.isCommunity) {
// these should be added on the group join / group info fetch // these should be added on the group join / group info fetch
Log.w("Loki", "Thread created called for open group address, not adding any extra information") Log.w("Loki", "Thread created called for open group address, not adding any extra information")
} }
@ -152,7 +151,7 @@ open class Storage(
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId) volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId) groups.eraseLegacyGroup(sessionId)
} else if (address.isOpenGroup) { } else if (address.isCommunity) {
// these should be removed in the group leave / handling new configs // these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
} }
@ -182,7 +181,7 @@ open class Storage(
} }
override fun getUserProfile(): Profile { override fun getUserProfile(): Profile {
val displayName = TextSecurePreferences.getProfileName(context)!! val displayName = TextSecurePreferences.getProfileName(context)
val profileKey = ProfileKeyUtil.getProfileKey(context) val profileKey = ProfileKeyUtil.getProfileKey(context)
val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context) val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context)
return Profile(displayName, profileKey, profilePictureUrl) return Profile(displayName, profileKey, profilePictureUrl)
@ -257,7 +256,7 @@ open class Storage(
// recipient closed group // recipient closed group
recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
// recipient is open group // recipient is open group
recipient.isOpenGroupRecipient -> { recipient.isCommunityRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey) config.getOrConstructCommunity(base, room, pubKey)
@ -327,7 +326,7 @@ open class Storage(
setRecipientApprovedMe(targetRecipient, true) setRecipientApprovedMe(targetRecipient, true)
} }
} }
if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) { if (message.threadID == null && !targetRecipient.isCommunityRecipient) {
// open group recipients should explicitly create threads // open group recipients should explicitly create threads
message.threadID = getOrCreateThreadIdFor(targetAddress) message.threadID = getOrCreateThreadIdFor(targetAddress)
} }
@ -767,13 +766,36 @@ open class Storage(
override fun markAsSent(timestamp: Long, author: String) { override fun markAsSent(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return val messageRecord = database.getSentMessageFor(timestamp, author)
if (messageRecord == null) {
Log.w(TAG, "Failed to retrieve local message record in Storage.markAsSent - aborting.")
return
}
if (messageRecord.isMms) { if (messageRecord.isMms) {
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true)
mmsDatabase.markAsSent(messageRecord.getId(), true)
} else { } else {
val smsDatabase = DatabaseComponent.get(context).smsDatabase() DatabaseComponent.get(context).smsDatabase().markAsSent(messageRecord.getId(), true)
smsDatabase.markAsSent(messageRecord.getId(), true) }
}
// Method that marks a message as sent in Communities (only!) - where the server modifies the
// message timestamp and as such we cannot use that to identify the local message.
override fun markAsSentToCommunity(threadId: Long, messageID: Long) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
// Ensure we can find the local message..
if (message == null) {
Log.w(TAG, "Could not find local message in Storage.markAsSentToCommunity - aborting.")
return
}
// ..and mark as sent if found.
if (message.isMms) {
DatabaseComponent.get(context).mmsDatabase().markAsSent(message.getId(), true)
} else {
DatabaseComponent.get(context).smsDatabase().markAsSent(message.getId(), true)
} }
} }
@ -808,7 +830,11 @@ open class Storage(
override fun markUnidentified(timestamp: Long, author: String) { override fun markUnidentified(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return val messageRecord = database.getMessageFor(timestamp, author)
if (messageRecord == null) {
Log.w(TAG, "Could not identify message with timestamp: $timestamp from author: $author")
return
}
if (messageRecord.isMms) { if (messageRecord.isMms) {
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
mmsDatabase.markUnidentified(messageRecord.getId(), true) mmsDatabase.markUnidentified(messageRecord.getId(), true)
@ -818,6 +844,26 @@ open class Storage(
} }
} }
// Method that marks a message as unidentified in Communities (only!) - where the server
// modifies the message timestamp and as such we cannot use that to identify the local message.
override fun markUnidentifiedInCommunity(threadId: Long, messageId: Long) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
// Check to ensure the message exists
if (message == null) {
Log.w(TAG, "Could not find local message in Storage.markUnidentifiedInCommunity - aborting.")
return
}
// Mark it as unidentified if we found the message successfully
if (message.isMms) {
DatabaseComponent.get(context).mmsDatabase().markUnidentified(message.getId(), true)
} else {
DatabaseComponent.get(context).smsDatabase().markUnidentified(message.getId(), true)
}
}
override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) { override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return val messageRecord = database.getMessageFor(timestamp, author) ?: return
@ -971,7 +1017,10 @@ open class Storage(
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf())
val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsDB = DatabaseComponent.get(context).mmsDatabase()
val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase()
if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) {
Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!")
return
}
val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true)
mmsDB.markAsSent(infoMessageID, true) mmsDB.markAsSent(infoMessageID, true)
} }
@ -1289,7 +1338,7 @@ open class Storage(
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
) )
groups.set(newGroupInfo) groups.set(newGroupInfo)
} else if (threadRecipient.isOpenGroupRecipient) { } else if (threadRecipient.isCommunityRecipient) {
val openGroup = getOpenGroup(threadID) ?: return val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
@ -1322,31 +1371,31 @@ open class Storage(
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
val groupDB = DatabaseComponent.get(context).groupDatabase() val groupDB = DatabaseComponent.get(context).groupDatabase()
threadDB.deleteConversation(threadID) threadDB.deleteConversation(threadID)
val recipient = getRecipientForThread(threadID) ?: return
when { val recipient = getRecipientForThread(threadID)
recipient.isContactRecipient -> { if (recipient == null) {
if (recipient.isLocalNumber) return Log.w(TAG, "Got null recipient when deleting conversation - aborting.");
val contacts = configFactory.contacts ?: return return
contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
recipient.isClosedGroupRecipient -> {
// TODO: handle closed group // There is nothing further we need to do if this is a 1-on-1 conversation, and it's not
// possible to delete communities in this manner so bail.
if (recipient.isContactRecipient || recipient.isCommunityRecipient) return
// If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient)
val volatile = configFactory.convoVolatile ?: return val volatile = configFactory.convoVolatile ?: return
val groups = configFactory.userGroups ?: return val groups = configFactory.userGroups ?: return
val groupID = recipient.address.toGroupString() val groupID = recipient.address.toGroupString()
val closedGroup = getGroup(groupID) val closedGroup = getGroup(groupID)
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
if (closedGroup != null) { if (closedGroup != null) {
groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it) groupDB.delete(groupID)
volatile.eraseLegacyClosedGroup(groupPublicKey) volatile.eraseLegacyClosedGroup(groupPublicKey)
groups.eraseLegacyGroup(groupPublicKey) groups.eraseLegacyGroup(groupPublicKey)
} else { } else {
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}") Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
} }
} }
}
}
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
return PartAuthority.getAttachmentDataUri(attachmentId) return PartAuthority.getAttachmentDataUri(attachmentId)

View File

@ -18,7 +18,7 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX;
import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
import android.content.ContentValues; import android.content.ContentValues;
@ -26,14 +26,10 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.database.MergeCursor; import android.database.MergeCursor;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.snode.SnodeAPI; import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
@ -61,7 +57,6 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.io.Closeable; import java.io.Closeable;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -83,7 +78,7 @@ public class ThreadDatabase extends Database {
public static final String TABLE_NAME = "thread"; public static final String TABLE_NAME = "thread";
public static final String ID = "_id"; public static final String ID = "_id";
public static final String DATE = "date"; public static final String THREAD_CREATION_DATE = "date";
public static final String MESSAGE_COUNT = "message_count"; public static final String MESSAGE_COUNT = "message_count";
public static final String ADDRESS = "recipient_ids"; public static final String ADDRESS = "recipient_ids";
public static final String SNIPPET = "snippet"; public static final String SNIPPET = "snippet";
@ -91,7 +86,7 @@ public class ThreadDatabase extends Database {
public static final String READ = "read"; public static final String READ = "read";
public static final String UNREAD_COUNT = "unread_count"; public static final String UNREAD_COUNT = "unread_count";
public static final String UNREAD_MENTION_COUNT = "unread_mention_count"; public static final String UNREAD_MENTION_COUNT = "unread_mention_count";
public static final String TYPE = "type"; public static final String DISTRIBUTION_TYPE = "type"; // See: DistributionTypes.kt
private static final String ERROR = "error"; private static final String ERROR = "error";
public static final String SNIPPET_TYPE = "snippet_type"; public static final String SNIPPET_TYPE = "snippet_type";
public static final String SNIPPET_URI = "snippet_uri"; public static final String SNIPPET_URI = "snippet_uri";
@ -105,23 +100,23 @@ public class ThreadDatabase extends Database {
public static final String IS_PINNED = "is_pinned"; public static final String IS_PINNED = "is_pinned";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " + ID + " INTEGER PRIMARY KEY, " + THREAD_CREATION_DATE + " INTEGER DEFAULT 0, " +
MESSAGE_COUNT + " INTEGER DEFAULT 0, " + ADDRESS + " TEXT, " + SNIPPET + " TEXT, " + MESSAGE_COUNT + " INTEGER DEFAULT 0, " + ADDRESS + " TEXT, " + SNIPPET + " TEXT, " +
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " + SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " +
TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " + DISTRIBUTION_TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " + SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " +
ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " + ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " + LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);"; READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = { public static final String[] CREATE_INDEXES = {
"CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + ADDRESS + ");", "CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + ADDRESS + ");",
"CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");", "CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");",
}; };
private static final String[] THREAD_PROJECTION = { private static final String[] THREAD_PROJECTION = {
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, TYPE, ERROR, SNIPPET_TYPE, ID, THREAD_CREATION_DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, DISTRIBUTION_TYPE, ERROR, SNIPPET_TYPE,
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED
}; };
@ -158,11 +153,10 @@ public class ThreadDatabase extends Database {
ContentValues contentValues = new ContentValues(4); ContentValues contentValues = new ContentValues(4);
long date = SnodeAPI.getNowWithOffset(); long date = SnodeAPI.getNowWithOffset();
contentValues.put(DATE, date - date % 1000); contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
contentValues.put(ADDRESS, address.serialize()); contentValues.put(ADDRESS, address.serialize());
if (group) if (group) contentValues.put(DISTRIBUTION_TYPE, distributionType);
contentValues.put(TYPE, distributionType);
contentValues.put(MESSAGE_COUNT, 0); contentValues.put(MESSAGE_COUNT, 0);
@ -175,7 +169,7 @@ public class ThreadDatabase extends Database {
long expiresIn, int readReceiptCount) long expiresIn, int readReceiptCount)
{ {
ContentValues contentValues = new ContentValues(7); ContentValues contentValues = new ContentValues(7);
contentValues.put(DATE, date - date % 1000); contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
contentValues.put(MESSAGE_COUNT, count); contentValues.put(MESSAGE_COUNT, count);
if (!body.isEmpty()) { if (!body.isEmpty()) {
contentValues.put(SNIPPET, body); contentValues.put(SNIPPET, body);
@ -187,9 +181,7 @@ public class ThreadDatabase extends Database {
contentValues.put(READ_RECEIPT_COUNT, readReceiptCount); contentValues.put(READ_RECEIPT_COUNT, readReceiptCount);
contentValues.put(EXPIRES_IN, expiresIn); contentValues.put(EXPIRES_IN, expiresIn);
if (unarchive) { if (unarchive) { contentValues.put(ARCHIVED, 0); }
contentValues.put(ARCHIVED, 0);
}
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""});
@ -199,7 +191,7 @@ public class ThreadDatabase extends Database {
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) { public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
ContentValues contentValues = new ContentValues(4); ContentValues contentValues = new ContentValues(4);
contentValues.put(DATE, date - date % 1000); contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
if (!snippet.isEmpty()) { if (!snippet.isEmpty()) {
contentValues.put(SNIPPET, snippet); contentValues.put(SNIPPET, snippet);
} }
@ -230,9 +222,7 @@ public class ThreadDatabase extends Database {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = ""; String where = "";
for (long threadId : threadIds) { for (long threadId : threadIds) { where += ID + " = '" + threadId + "' OR "; }
where += ID + " = '" + threadId + "' OR ";
}
where = where.substring(0, where.length() - 4); where = where.substring(0, where.length() - 4);
@ -358,7 +348,7 @@ public class ThreadDatabase extends Database {
public void setDistributionType(long threadId, int distributionType) { public void setDistributionType(long threadId, int distributionType) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(TYPE, distributionType); contentValues.put(DISTRIBUTION_TYPE, distributionType);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
@ -367,7 +357,7 @@ public class ThreadDatabase extends Database {
public void setDate(long threadId, long date) { public void setDate(long threadId, long date) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(DATE, date); contentValues.put(THREAD_CREATION_DATE, date);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
if (updated > 0) notifyConversationListListeners(); if (updated > 0) notifyConversationListListeners();
@ -375,11 +365,11 @@ public class ThreadDatabase extends Database {
public int getDistributionType(long threadId) { public int getDistributionType(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); Cursor cursor = db.query(TABLE_NAME, new String[]{DISTRIBUTION_TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try { try {
if (cursor != null && cursor.moveToNext()) { if (cursor != null && cursor.moveToNext()) {
return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); return cursor.getInt(cursor.getColumnIndexOrThrow(DISTRIBUTION_TYPE));
} }
return DistributionTypes.DEFAULT; return DistributionTypes.DEFAULT;
@ -427,7 +417,7 @@ public class ThreadDatabase extends Database {
} }
Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0);
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }
@ -469,7 +459,7 @@ public class ThreadDatabase extends Database {
Cursor cursor = null; Cursor cursor = null;
try { try {
String where = "SELECT " + DATE + " FROM " + TABLE_NAME + String where = "SELECT " + THREAD_CREATION_DATE + " FROM " + TABLE_NAME +
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
@ -477,7 +467,7 @@ public class ThreadDatabase extends Database {
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + DATE + " DESC LIMIT 1"; GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + THREAD_CREATION_DATE + " DESC LIMIT 1";
cursor = db.rawQuery(where, null); cursor = db.rawQuery(where, null);
if (cursor != null && cursor.moveToFirst()) if (cursor != null && cursor.moveToFirst())
@ -491,7 +481,7 @@ public class ThreadDatabase extends Database {
} }
public Cursor getConversationList() { public Cursor getConversationList() {
String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
@ -502,7 +492,7 @@ public class ThreadDatabase extends Database {
} }
public Cursor getApprovedConversationList() { public Cursor getApprovedConversationList() {
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
@ -515,18 +505,12 @@ public class ThreadDatabase extends Database {
return getConversationList(where); return getConversationList(where);
} }
public Cursor getArchivedConversationList() {
String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
"AND " + ARCHIVED + " = 1 ";
return getConversationList(where);
}
private Cursor getConversationList(String where) { private Cursor getConversationList(String where) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(where, 0); String query = createQuery(where, 0);
Cursor cursor = db.rawQuery(query, null); Cursor cursor = db.rawQuery(query, null);
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }
@ -547,7 +531,7 @@ public class ThreadDatabase extends Database {
// edge case where we set the last seen time for a conversation before it loads messages (joining community for example) // edge case where we set the last seen time for a conversation before it loads messages (joining community for example)
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
Recipient forThreadId = getRecipientForThreadId(threadId); Recipient forThreadId = getRecipientForThreadId(threadId);
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false; if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isCommunityRecipient()) return false;
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
@ -601,7 +585,7 @@ public class ThreadDatabase extends Database {
public Long getLastUpdated(long threadId) { public Long getLastUpdated(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); Cursor cursor = db.query(TABLE_NAME, new String[]{THREAD_CREATION_DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try { try {
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
@ -742,7 +726,7 @@ public class ThreadDatabase extends Database {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId); long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId); boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
if (count == 0 && shouldDeleteEmptyThread) { if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
@ -750,10 +734,7 @@ public class ThreadDatabase extends Database {
return true; return true;
} }
MmsSmsDatabase.Reader reader = null; try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
try {
reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
MessageRecord record = null; MessageRecord record = null;
if (reader != null) { if (reader != null) {
record = reader.getNext(); record = reader.getNext();
@ -771,11 +752,10 @@ public class ThreadDatabase extends Database {
deleteThread(threadId); deleteThread(threadId);
return true; return true;
} }
// todo: add empty snippet that clears existing data
return false; return false;
} }
} finally { } finally {
if (reader != null)
reader.close();
notifyConversationListListeners(); notifyConversationListListeners();
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -820,9 +800,9 @@ public class ThreadDatabase extends Database {
return setLastSeen(threadId, lastSeenTime); return setLastSeen(threadId, lastSeenTime);
} }
private boolean deleteThreadOnEmpty(long threadId) { private boolean possibleToDeleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId); Recipient threadRecipient = getRecipientForThreadId(threadId);
return threadRecipient != null && !threadRecipient.isOpenGroupRecipient(); return threadRecipient != null && !threadRecipient.isCommunityRecipient();
} }
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
@ -865,7 +845,7 @@ public class ThreadDatabase extends Database {
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + where + " WHERE " + where +
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + DATE + " DESC"; " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
if (limit > 0) { if (limit > 0) {
query += " LIMIT " + limit; query += " LIMIT " + limit;
@ -910,7 +890,7 @@ public class ThreadDatabase extends Database {
public ThreadRecord getCurrent() { public ThreadRecord getCurrent() {
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID)); long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID));
int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE)); int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DISTRIBUTION_TYPE));
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS))); Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS)));
Optional<RecipientSettings> settings; Optional<RecipientSettings> settings;
@ -926,7 +906,7 @@ public class ThreadDatabase extends Database {
Recipient recipient = Recipient.from(context, address, settings, groupRecord, true); Recipient recipient = Recipient.from(context, address, settings, groupRecord, true);
String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET)); String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE)); long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.THREAD_CREATION_DATE));
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)); long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)); int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT)); int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT));
@ -944,7 +924,17 @@ public class ThreadDatabase extends Database {
readReceiptCount = 0; readReceiptCount = 0;
} }
return new ThreadRecord(body, snippetUri, recipient, date, count, MessageRecord lastMessage = null;
if (count > 0) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long messageTimestamp = mmsSmsDatabase.getLastMessageTimestamp(threadId);
if (messageTimestamp > 0) {
lastMessage = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp);
}
}
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned); distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
} }

View File

@ -357,7 +357,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
executeStatements(db, AttachmentDatabase.CREATE_INDEXS); executeStatements(db, AttachmentDatabase.CREATE_INDEXS);
executeStatements(db, ThreadDatabase.CREATE_INDEXS); executeStatements(db, ThreadDatabase.CREATE_INDEXES);
executeStatements(db, DraftDatabase.CREATE_INDEXS); executeStatements(db, DraftDatabase.CREATE_INDEXS);
executeStatements(db, GroupDatabase.CREATE_INDEXS); executeStatements(db, GroupDatabase.CREATE_INDEXS);
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);

View File

@ -22,7 +22,10 @@ import android.text.SpannableString;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
/** /**
@ -48,6 +51,9 @@ public abstract class DisplayRecord {
long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount,
long type, int readReceiptCount) long type, int readReceiptCount)
{ {
// TODO: This gets hit very, very often and it likely shouldn't - place a Log.d statement in it to see.
//Log.d("[ACL]", "Creating a display record with delivery status of: " + deliveryStatus);
this.threadId = threadId; this.threadId = threadId;
this.recipient = recipient; this.recipient = recipient;
this.dateSent = dateSent; this.dateSent = dateSent;
@ -72,13 +78,11 @@ public abstract class DisplayRecord {
public int getReadReceiptCount() { return readReceiptCount; } public int getReadReceiptCount() { return readReceiptCount; }
public boolean isDelivered() { public boolean isDelivered() {
return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE &&
&& deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
} }
public boolean isSent() { public boolean isSent() { return MmsSmsColumns.Types.isSentType(type); }
return !isFailed() && !isPending();
}
public boolean isSyncing() { public boolean isSyncing() {
return MmsSmsColumns.Types.isSyncingType(type); return MmsSmsColumns.Types.isSyncingType(type);
@ -99,9 +103,10 @@ public abstract class DisplayRecord {
} }
public boolean isPending() { public boolean isPending() {
return MmsSmsColumns.Types.isPendingMessageType(type) boolean isPending = MmsSmsColumns.Types.isPendingMessageType(type) &&
&& !MmsSmsColumns.Types.isIdentityVerified(type) !MmsSmsColumns.Types.isIdentityVerified(type) &&
&& !MmsSmsColumns.Types.isIdentityDefault(type); !MmsSmsColumns.Types.isIdentityDefault(type);
return isPending;
} }
public boolean isRead() { return readReceiptCount > 0; } public boolean isRead() { return readReceiptCount > 0; }
@ -109,6 +114,11 @@ public abstract class DisplayRecord {
public boolean isOutgoing() { public boolean isOutgoing() {
return MmsSmsColumns.Types.isOutgoingMessageType(type); return MmsSmsColumns.Types.isOutgoingMessageType(type);
} }
public boolean isIncoming() {
return !MmsSmsColumns.Types.isOutgoingMessageType(type);
}
public boolean isGroupUpdateMessage() { public boolean isGroupUpdateMessage() {
return SmsDatabase.Types.isGroupUpdateMessage(type); return SmsDatabase.Types.isGroupUpdateMessage(type);
} }

View File

@ -31,6 +31,7 @@ import org.session.libsession.messaging.utilities.UpdateMessageData;
import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -120,7 +121,8 @@ public abstract class MessageRecord extends DisplayRecord {
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
} else if (isExpirationTimerUpdate()) { } else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000); int seconds = (int) (getExpiresIn() / 1000);
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getRecipient(), getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted)); boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted));
} else if (isDataExtractionNotification()) { } else if (isDataExtractionNotification()) {
if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize())));
else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize())));

View File

@ -43,6 +43,7 @@ import network.loki.messenger.R;
public class ThreadRecord extends DisplayRecord { public class ThreadRecord extends DisplayRecord {
private @Nullable final Uri snippetUri; private @Nullable final Uri snippetUri;
public @Nullable final MessageRecord lastMessage;
private final long count; private final long count;
private final int unreadCount; private final int unreadCount;
private final int unreadMentionCount; private final int unreadMentionCount;
@ -54,13 +55,14 @@ public class ThreadRecord extends DisplayRecord {
private final int initialRecipientHash; private final int initialRecipientHash;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@NonNull Recipient recipient, long date, long count, int unreadCount, @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
long snippetType, int distributionType, boolean archived, long expiresIn, long snippetType, int distributionType, boolean archived, long expiresIn,
long lastSeen, int readReceiptCount, boolean pinned) long lastSeen, int readReceiptCount, boolean pinned)
{ {
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
this.snippetUri = snippetUri; this.snippetUri = snippetUri;
this.lastMessage = lastMessage;
this.count = count; this.count = count;
this.unreadCount = unreadCount; this.unreadCount = unreadCount;
this.unreadMentionCount = unreadMentionCount; this.unreadMentionCount = unreadMentionCount;

View File

@ -78,7 +78,7 @@ class CreateGroupFragment : Fragment() {
if (name.isEmpty()) { if (name.isEmpty()) {
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show()
} }
if (name.length >= 30) { if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) {
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show()
} }
val selectedMembers = adapter.selectedMembers val selectedMembers = adapter.selectedMembers

View File

@ -11,7 +11,6 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getConversationUnread import org.thoughtcrime.securesms.util.getConversationUnread
import javax.inject.Inject import javax.inject.Inject
@ -75,7 +74,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
} }
binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE
binding.copyConversationId.setOnClickListener(this) binding.copyConversationId.setOnClickListener(this)
binding.copyCommunityUrl.visibility = if (recipient.isOpenGroupRecipient) View.VISIBLE else View.GONE binding.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE
binding.copyCommunityUrl.setOnClickListener(this) binding.copyCommunityUrl.setOnClickListener(this)
binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber
binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber

View File

@ -4,6 +4,8 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.text.SpannableString
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
@ -89,7 +91,7 @@ class ConversationView : LinearLayout {
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true) || (configFactory.convoVolatile?.getConversationUnread(thread) == true)
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
val senderDisplayName = getUserDisplayName(thread.recipient) val senderDisplayName = getTitle(thread.recipient)
?: thread.recipient.address.toString() ?: thread.recipient.address.toString()
binding.conversationViewDisplayNameTextView.text = senderDisplayName binding.conversationViewDisplayNameTextView.text = senderDisplayName
binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) } binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) }
@ -101,9 +103,7 @@ class ConversationView : LinearLayout {
R.drawable.ic_notifications_mentions R.drawable.ic_notifications_mentions
} }
binding.muteIndicatorImageView.setImageResource(drawableRes) binding.muteIndicatorImageView.setImageResource(drawableRes)
val rawSnippet = thread.getDisplayBody(context) binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context)
val snippet = highlightMentions(rawSnippet, thread.threadId, context)
binding.snippetTextView.text = snippet
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) { if (isTyping) {
@ -131,12 +131,21 @@ class ConversationView : LinearLayout {
binding.profilePictureView.recycle() binding.profilePictureView.recycle()
} }
private fun getUserDisplayName(recipient: Recipient): String? { private fun getTitle(recipient: Recipient): String? = when {
return if (recipient.isLocalNumber) { recipient.isLocalNumber -> context.getString(R.string.note_to_self)
context.getString(R.string.note_to_self) else -> recipient.toShortString() // Internally uses the Contact API
} else {
recipient.toShortString() // Internally uses the Contact API
} }
private fun ThreadRecord.getSnippet(): CharSequence =
concatSnippet(getSnippetPrefix(), getDisplayBody(context))
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null
lastMessage?.isOutgoing == true -> resources.getString(R.string.MessageRecord_you)
else -> lastMessage?.individualRecipient?.toShortString()
} }
// endregion // endregion
} }

View File

@ -293,7 +293,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
val newData = contactResults + messageResults val newData = contactResults + messageResults
globalSearchAdapter.setNewData(result.query, newData) globalSearchAdapter.setNewData(result.query, newData)
} }
} }
@ -496,7 +495,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
manager.setPrimaryClip(clip) manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
} }
else if (thread.recipient.isOpenGroupRecipient) { else if (thread.recipient.isCommunityRecipient) {
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit
val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit

View File

@ -21,6 +21,7 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPathBinding import network.loki.messenger.databinding.ActivityPathBinding
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.Snode
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.GlowViewUtilities

View File

@ -25,7 +25,6 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.mms.GlideApp
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -34,6 +33,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
private lateinit var binding: FragmentUserDetailsBottomSheetBinding private lateinit var binding: FragmentUserDetailsBottomSheetBinding
private var previousContactNickname: String = ""
companion object { companion object {
const val ARGUMENT_PUBLIC_KEY = "publicKey" const val ARGUMENT_PUBLIC_KEY = "publicKey"
const val ARGUMENT_THREAD_ID = "threadId" const val ARGUMENT_THREAD_ID = "threadId"
@ -89,10 +91,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
&& !threadRecipient.isOpenGroupInboxRecipient && !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient && !threadRecipient.isOpenGroupOutboxRecipient
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient
&& !threadRecipient.isOpenGroupInboxRecipient && !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient && !threadRecipient.isOpenGroupOutboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
publicKeyTextView.text = publicKey publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener { publicKeyTextView.setOnLongClickListener {
val clipboard = val clipboard =
@ -130,9 +132,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.visibility = View.VISIBLE
nameEditTextContainer.visibility = View.INVISIBLE nameEditTextContainer.visibility = View.INVISIBLE
var newNickName: String? = null var newNickName: String? = null
if (nicknameEditText.text.isNotEmpty()) { if (nicknameEditText.text.isNotEmpty() && nicknameEditText.text.trim().length != 0) {
newNickName = nicknameEditText.text.toString() newNickName = nicknameEditText.text.toString()
} }
else { newNickName = previousContactNickname }
val publicKey = recipient.address.serialize() val publicKey = recipient.address.serialize()
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
@ -145,6 +148,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
fun showSoftKeyboard() { fun showSoftKeyboard() {
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(binding.nicknameEditText, 0) imm?.showSoftInput(binding.nicknameEditText, 0)
// Keep track of the original nickname to re-use if an empty / blank nickname is entered
previousContactNickname = binding.nameTextView.text.toString()
} }
fun hideSoftKeyboard() { fun hideSoftKeyboard() {

View File

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.home.search
import android.content.Context import android.content.Context
import android.text.Editable import android.text.Editable
import android.text.InputFilter
import android.text.InputFilter.LengthFilter
import android.text.TextWatcher import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
@ -34,6 +36,7 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
binding.searchInput.onFocusChangeListener = this binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this) binding.searchInput.addTextChangedListener(this)
binding.searchInput.setOnEditorActionListener(this) binding.searchInput.setOnEditorActionListener(this)
binding.searchInput.setFilters( arrayOf<InputFilter>(LengthFilter(100)) ) // 100 char search limit
binding.searchCancel.setOnClickListener(this) binding.searchCancel.setOnClickListener(this)
binding.searchClear.setOnClickListener(this) binding.searchClear.setOnClickListener(this)
} }

View File

@ -24,8 +24,7 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
private val executor = viewModelScope + SupervisorJob() private val executor = viewModelScope + SupervisorJob()
private val _result: MutableStateFlow<GlobalSearchResult> = private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY)
MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow<GlobalSearchResult> = _result val result: StateFlow<GlobalSearchResult> = _result
@ -41,13 +40,14 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
_queryText _queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query -> .mapLatest { query ->
if (query.trim().length < 2) { // Early exit on empty search query
if (query.trim().isEmpty()) {
SearchResult.EMPTY SearchResult.EMPTY
} else { } else {
// user input delay here in case we get a new query within a few hundred ms // User input delay in case we get a new query within a few hundred ms this
// this coroutine will be cancelled and expensive query will not be run if typing quickly // coroutine will be cancelled and the expensive query will not be run.
// first query of 2 characters will be instant however
delay(300) delay(300)
val settableFuture = SettableFuture<SearchResult>() val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set) searchRepository.query(query.toString(), settableFuture::set)
try { try {
@ -64,6 +64,4 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
} }
.launchIn(executor) .launchIn(executor)
} }
} }

View File

@ -349,11 +349,17 @@ public class DefaultMessageNotifier implements MessageNotifier {
builder.setThread(notifications.get(0).getRecipient()); builder.setThread(notifications.get(0).getRecipient());
builder.setMessageCount(notificationState.getMessageCount()); builder.setMessageCount(notificationState.getMessageCount());
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context); MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
// TODO: attributes to perform the colour lookup. Also, it makes little sense to highlight the mentions using
// TODO: the app theme as it may result in insufficient contrast with the notification background which will
// TODO: be using the SYSTEM theme.
builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(), builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(),
MentionUtilities.highlightMentions(text == null ? "" : text, //MentionUtilities.highlightMentions(text == null ? "" : text, notifications.get(0).getThreadId(), context), // Removing hightlighting mentions -ACL
notifications.get(0).getThreadId(), text == null ? "" : text,
context),
notifications.get(0).getSlideDeck()); notifications.get(0).getSlideDeck());
builder.setContentIntent(notifications.get(0).getPendingIntent(context)); builder.setContentIntent(notifications.get(0).getPendingIntent(context));
builder.setDeleteIntent(notificationState.getDeleteIntent(context)); builder.setDeleteIntent(notificationState.getDeleteIntent(context));
builder.setOnlyAlertOnce(!signal); builder.setOnlyAlertOnce(!signal);

View File

@ -61,11 +61,15 @@ class MarkReadReceiver : BroadcastReceiver() {
val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
val threadDb = DatabaseComponent.get(context).threadDatabase()
// start disappear after read messages except TimerUpdates in groups. // start disappear after read messages except TimerUpdates in groups.
markedReadMessages markedReadMessages
.filter { it.expiryType == ExpiryType.AFTER_READ } .filter { it.expiryType == ExpiryType.AFTER_READ }
.map { it.syncMessageId } .map { it.syncMessageId }
.filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { isExpirationTimerUpdate && recipient.isClosedGroupRecipient } == false } .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run {
isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupRecipient == true } == false
}
.forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) }
hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToDisappearAfterReadMessage(context, markedReadMessages)?.let {

View File

@ -53,7 +53,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) { public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) {
String displayName = recipient.toShortString(); String displayName = recipient.toShortString();
if (threadRecipient.isGroupRecipient()) { if (threadRecipient.isGroupRecipient()) {
displayName = getGroupDisplayName(recipient, threadRecipient.isOpenGroupRecipient()); displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient());
} }
if (privacy.isDisplayContact()) { if (privacy.isDisplayContact()) {
setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName)); setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName));
@ -79,7 +79,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) { public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) {
String displayName = sender.toShortString(); String displayName = sender.toShortString();
if (threadRecipient.isGroupRecipient()) { if (threadRecipient.isGroupRecipient()) {
displayName = getGroupDisplayName(sender, threadRecipient.isOpenGroupRecipient()); displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient());
} }
if (privacy.isDisplayMessage()) { if (privacy.isDisplayMessage()) {
SpannableStringBuilder builder = new SpannableStringBuilder(); SpannableStringBuilder builder = new SpannableStringBuilder();

View File

@ -125,7 +125,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); SpannableStringBuilder stringBuilder = new SpannableStringBuilder();
if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) {
String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient());
stringBuilder.append(Util.getBoldedString(displayName + ": ")); stringBuilder.append(Util.getBoldedString(displayName + ": "));
} }
@ -215,7 +215,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); SpannableStringBuilder stringBuilder = new SpannableStringBuilder();
if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) {
String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient());
stringBuilder.append(Util.getBoldedString(displayName + ": ")); stringBuilder.append(Util.getBoldedString(displayName + ": "));
} }

View File

@ -1,114 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.StyleSpan
import android.view.View
import android.widget.Toast
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
TextSecurePreferences.apply {
setHasViewedSeed(this@RecoveryPhraseRestoreActivity, true)
setConfigurationMessageSynced(this@RecoveryPhraseRestoreActivity, false)
setRestorationTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis())
setLastProfileUpdateTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis())
}
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() {
override fun onClick(widget: View) {
openURL("https://getsession.org/terms-of-service/")
}
}, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
openURL("https://getsession.org/privacy-policy/")
}
}, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.termsTextView.movementMethod = LinkMovementMethod.getInstance()
binding.termsTextView.text = termsExplanation
}
// endregion
// region Interaction
private fun restore() {
val mnemonic = binding.mnemonicEditText.text.toString()
try {
// This is here to resolve a case where the app restarts before a user completes onboarding
// which can result in an invalid database state
database.clearAllLastMessageHashes()
database.clearReceivedMessageHashValues()
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName)
}
val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
val seed = Hex.fromStringCondensed(hexEncodedSeed)
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
val intent = Intent(this, DisplayNameActivity::class.java)
push(intent)
} catch (e: Exception) {
val message = if (e is MnemonicCodec.DecodingError) e.description else MnemonicCodec.DecodingError.Generic.description
return Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private fun openURL(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show()
}
}
// endregion
}

View File

@ -5,9 +5,14 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isInvisible
import androidx.preference.Preference import androidx.preference.Preference
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
@ -67,6 +72,19 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() {
} }
} }
private fun updateExportButtonAndProgressBarUI(exportJobRunning: Boolean) {
this.activity?.runOnUiThread(Runnable {
// Change export logs button text
val exportLogsButton = this.activity?.findViewById(R.id.export_logs_button) as TextView?
if (exportLogsButton == null) { Log.w("Loki", "Could not find export logs button view.") }
exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.activity_help_settings__export_logs)
// Show progress bar
val exportProgressBar = this.activity?.findViewById(R.id.export_progress_bar) as ProgressBar?
exportProgressBar?.isInvisible = !exportJobRunning
})
}
private fun shareLogs() { private fun shareLogs() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@ -76,7 +94,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() {
Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()
} }
.onAllGranted { .onAllGranted {
ShareLogsDialog().show(parentFragmentManager,"Share Logs Dialog") ShareLogsDialog(::updateExportButtonAndProgressBarUI).show(parentFragmentManager,"Share Logs Dialog")
} }
.execute() .execute()
} }

View File

@ -17,6 +17,7 @@ import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -203,6 +204,21 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.displayNameEditText.selectAll() binding.displayNameEditText.selectAll()
binding.displayNameEditText.requestFocus() binding.displayNameEditText.requestFocus()
inputMethodManager.showSoftInput(binding.displayNameEditText, 0) inputMethodManager.showSoftInput(binding.displayNameEditText, 0)
// Save the updated display name when the user presses enter on the soft keyboard
binding.displayNameEditText.setOnEditorActionListener { v, actionId, event ->
when (actionId) {
// Note: IME_ACTION_DONE is how we've configured the soft keyboard to respond,
// while IME_ACTION_UNSPECIFIED is what triggers when we hit enter on a
// physical keyboard.
EditorInfo.IME_ACTION_DONE, EditorInfo.IME_ACTION_UNSPECIFIED -> {
saveDisplayName()
displayNameEditActionMode?.finish()
true
}
else -> false
}
}
} else { } else {
inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0)
} }

View File

@ -11,55 +11,73 @@ import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isInvisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.ExternalStorageUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.FileProviderUtil
import org.thoughtcrime.securesms.util.StreamUtil import org.thoughtcrime.securesms.util.StreamUtil
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Objects import java.util.Objects
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ShareLogsDialog : DialogFragment() {
class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() {
private val TAG = "ShareLogsDialog"
private var shareJob: Job? = null private var shareJob: Job? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_share_logs_title) title(R.string.dialog_share_logs_title)
text(R.string.dialog_share_logs_explanation) text(R.string.dialog_share_logs_explanation)
button(R.string.share, dismiss = false) { shareLogs() } button(R.string.share, dismiss = false) { runShareLogsJob() }
cancelButton { dismiss() } cancelButton { updateCallback(false) }
} }
private fun shareLogs() { // If the share logs dialog loses focus the job gets cancelled so we'll update the UI state
override fun onPause() {
super.onPause()
updateCallback(false)
}
private fun runShareLogsJob() {
// Cancel any existing share job that might already be running to start anew
shareJob?.cancel() shareJob?.cancel()
updateCallback(true)
shareJob = lifecycleScope.launch(Dispatchers.IO) { shareJob = lifecycleScope.launch(Dispatchers.IO) {
val persistentLogger = ApplicationContext.getInstance(context).persistentLogger val persistentLogger = ApplicationContext.getInstance(context).persistentLogger
try { try {
Log.d(TAG, "Starting share logs job...")
val context = requireContext() val context = requireContext()
val outputUri: Uri = ExternalStorageUtil.getDownloadUri() val outputUri: Uri = ExternalStorageUtil.getDownloadUri()
val mediaUri = getExternalFile() val mediaUri = getExternalFile() ?: return@launch
if (mediaUri == null) {
// show toast saying media saved
dismiss()
return@launch
}
val inputStream = persistentLogger.logs.get().byteInputStream() val inputStream = persistentLogger.logs.get().byteInputStream()
val updateValues = ContentValues() val updateValues = ContentValues()
// Add details into the output or media files as appropriate
if (outputUri.scheme == ContentResolver.SCHEME_FILE) { if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
FileOutputStream(mediaUri.path).use { outputStream -> FileOutputStream(mediaUri.path).use { outputStream ->
StreamUtil.copy(inputStream, outputStream) StreamUtil.copy(inputStream, outputStream)
@ -73,6 +91,7 @@ class ShareLogsDialog : DialogFragment() {
} }
} }
} }
if (Build.VERSION.SDK_INT > 28) { if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
} }
@ -95,13 +114,35 @@ class ShareLogsDialog : DialogFragment() {
} }
startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
} }
dismiss()
} catch (e: Exception) { } catch (e: Exception) {
withContext(Main) { withContext(Main) {
Log.e("Loki", "Error saving logs", e) Log.e("Loki", "Error saving logs", e)
Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show()
} }
}
}.also { shareJob ->
shareJob.invokeOnCompletion { handler ->
// Note: Don't show Toasts here directly - use `withContext(Main)` or such if req'd
handler?.message.let { msg ->
if (shareJob.isCancelled) {
if (msg.isNullOrBlank()) {
Log.w(TAG, "Share logs job was cancelled.")
} else {
Log.d(TAG, "Share logs job was cancelled. Reason: $msg")
}
}
else if (shareJob.isCompleted) {
Log.d(TAG, "Share logs job completed. Msg: $msg")
}
else {
Log.w(TAG, "Share logs job finished while still Active. Msg: $msg")
}
}
// Regardless of the job's success it has now completed so update the UI
updateCallback(false)
dismiss() dismiss()
} }
} }
@ -158,5 +199,4 @@ class ShareLogsDialog : DialogFragment() {
return context.contentResolver.insert(outputUri, contentValues) return context.contentResolver.insert(outputUri, contentValues)
} }
} }

View File

@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.repository package org.thoughtcrime.securesms.repository
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import app.cash.copper.Query import app.cash.copper.Query
import app.cash.copper.flow.observeQuery import app.cash.copper.flow.observeQuery
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
@ -22,6 +26,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.DraftDatabase
@ -40,9 +45,6 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
interface ConversationRepository { interface ConversationRepository {
fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun maybeGetRecipientForThreadId(threadId: Long): Recipient?
@ -55,37 +57,19 @@ interface ConversationRepository {
fun inviteContacts(threadId: Long, contacts: List<Recipient>) fun inviteContacts(threadId: Long, contacts: List<Recipient>)
fun setBlocked(recipient: Recipient, blocked: Boolean) fun setBlocked(recipient: Recipient, blocked: Boolean)
fun deleteLocally(recipient: Recipient, message: MessageRecord) fun deleteLocally(recipient: Recipient, message: MessageRecord)
fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord)
fun setApproved(recipient: Recipient, isApproved: Boolean) fun setApproved(recipient: Recipient, isApproved: Boolean)
suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): ResultOf<Unit>
suspend fun deleteForEveryone(
threadId: Long,
recipient: Recipient,
message: MessageRecord
): ResultOf<Unit>
fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest?
suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set<MessageRecord>): ResultOf<Unit>
suspend fun deleteMessageWithoutUnsendRequest(
threadId: Long,
messages: Set<MessageRecord>
): ResultOf<Unit>
suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf<Unit> suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf<Unit>
suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit>
suspend fun deleteThread(threadId: Long): ResultOf<Unit> suspend fun deleteThread(threadId: Long): ResultOf<Unit>
suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit>
suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit> suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit>
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
fun declineMessageRequest(threadId: Long) fun declineMessageRequest(threadId: Long)
fun hasReceived(threadId: Long): Boolean fun hasReceived(threadId: Long): Boolean
} }
class DefaultConversationRepository @Inject constructor( class DefaultConversationRepository @Inject constructor(
@ -184,6 +168,15 @@ class DefaultConversationRepository @Inject constructor(
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)
} }
override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) {
val threadId = messageRecord.threadId
val senderId = messageRecord.recipient.address.contactIdentifier()
val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId)
for (message in messageRecordsToRemoveFromLocalStorage) {
messageDataProvider.deleteMessage(message.id, !message.isMms)
}
}
override fun setApproved(recipient: Recipient, isApproved: Boolean) { override fun setApproved(recipient: Recipient, isApproved: Boolean) {
storage.setRecipientApproved(recipient, isApproved) storage.setRecipientApproved(recipient, isApproved)
} }
@ -196,18 +189,38 @@ class DefaultConversationRepository @Inject constructor(
buildUnsendRequest(recipient, message)?.let { unsendRequest -> buildUnsendRequest(recipient, message)?.let { unsendRequest ->
MessageSender.send(unsendRequest, recipient.address) MessageSender.send(unsendRequest, recipient.address)
} }
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) val openGroup = lokiThreadDb.getOpenGroupChat(threadId)
if (openGroup != null) { if (openGroup != null) {
lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID ->
OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success { .success {
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
}.fail { error -> }.fail { error ->
Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..")
continuation.resumeWithException(error) continuation.resumeWithException(error)
} }
} }
// If the server ID is null then this message is stuck in limbo (it has likely been
// deleted remotely but that deletion did not occur locally) - so we'll delete the
// message locally to clean up.
if (serverId == null) {
Log.w("ConversationRepository","Found community message without a server ID - deleting locally.")
// Caution: The bool returned from `deleteMessage` is NOT "Was the message
// successfully deleted?" - it is "Was the thread itself also deleted because
// removing that message resulted in an empty thread?".
if (message.isMms) {
mmsDb.deleteMessage(message.id)
} else { } else {
smsDb.deleteMessage(message.id)
}
}
}
else // If this thread is NOT in a Community
{
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)
messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash ->
var publicKey = recipient.address.serialize() var publicKey = recipient.address.serialize()
@ -218,6 +231,7 @@ class DefaultConversationRepository @Inject constructor(
.success { .success {
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
}.fail { error -> }.fail { error ->
Log.w("ConversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..")
continuation.resumeWithException(error) continuation.resumeWithException(error)
} }
} }
@ -225,7 +239,7 @@ class DefaultConversationRepository @Inject constructor(
} }
override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? {
if (recipient.isOpenGroupRecipient) return null if (recipient.isCommunityRecipient) return null
messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null
return UnsendRequest( return UnsendRequest(
author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(),
@ -279,8 +293,10 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> = override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> =
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
// Note: This sessionId could be the blinded Id
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!
OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server)
.success { .success {
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
@ -306,9 +322,7 @@ class DefaultConversationRepository @Inject constructor(
while (reader.next != null) { while (reader.next != null) {
deleteMessageRequest(reader.current) deleteMessageRequest(reader.current)
val recipient = reader.current.recipient val recipient = reader.current.recipient
if (block) { if (block) { setBlocked(recipient, true) }
setBlocked(recipient, true)
}
} }
} }
return ResultOf.Success(Unit) return ResultOf.Success(Unit)
@ -335,9 +349,7 @@ class DefaultConversationRepository @Inject constructor(
val cursor = mmsSmsDb.getConversation(threadId, true) val cursor = mmsSmsDb.getConversation(threadId, true)
mmsSmsDb.readerFor(cursor).use { reader -> mmsSmsDb.readerFor(cursor).use { reader ->
while (reader.next != null) { while (reader.next != null) {
if (!reader.current.isOutgoing) { if (!reader.current.isOutgoing) { return true }
return true
}
} }
} }
return false return false

View File

@ -4,12 +4,8 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.database.DatabaseUtils; import android.database.DatabaseUtils;
import android.database.MergeCursor; import android.database.MergeCursor;
import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupRecord; import org.session.libsession.utilities.GroupRecord;
@ -27,37 +23,25 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.search.model.MessageResult; import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.search.model.SearchResult; import org.thoughtcrime.securesms.search.model.SearchResult;
import org.thoughtcrime.securesms.util.Stopwatch; import org.thoughtcrime.securesms.util.Stopwatch;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import kotlin.Pair; import kotlin.Pair;
/** // Class to manage data retrieval for search
* Manages data retrieval for search.
*/
public class SearchRepository { public class SearchRepository {
private static final String TAG = SearchRepository.class.getSimpleName(); private static final String TAG = SearchRepository.class.getSimpleName();
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>(); private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
static { static {
// Several ranges of invalid ASCII characters // Construct a list containing several ranges of invalid ASCII characters
for (int i = 33; i <= 47; i++) { // See: https://www.ascii-code.com/
BANNED_CHARACTERS.add((char) i); for (int i = 33; i <= 47; i++) { BANNED_CHARACTERS.add((char) i); } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
} for (int i = 58; i <= 64; i++) { BANNED_CHARACTERS.add((char) i); } // :, ;, <, =, >, ?, @
for (int i = 58; i <= 64; i++) { for (int i = 91; i <= 96; i++) { BANNED_CHARACTERS.add((char) i); } // [, \, ], ^, _, `
BANNED_CHARACTERS.add((char) i); for (int i = 123; i <= 126; i++) { BANNED_CHARACTERS.add((char) i); } // {, |, }, ~
}
for (int i = 91; i <= 96; i++) {
BANNED_CHARACTERS.add((char) i);
}
for (int i = 123; i <= 126; i++) {
BANNED_CHARACTERS.add((char) i);
}
} }
private final Context context; private final Context context;
@ -86,25 +70,25 @@ public class SearchRepository {
} }
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) { public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
if (TextUtils.isEmpty(query)) { // If the sanitized search is empty then abort without search
String cleanQuery = sanitizeQuery(query).trim();
if (cleanQuery.isEmpty()) {
callback.onResult(SearchResult.EMPTY); callback.onResult(SearchResult.EMPTY);
return; return;
} }
executor.execute(() -> { executor.execute(() -> {
Stopwatch timer = new Stopwatch("FtsQuery"); Stopwatch timer = new Stopwatch("FtsQuery");
String cleanQuery = sanitizeQuery(query);
timer.split("clean"); timer.split("clean");
Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery); Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery);
timer.split("contacts"); timer.split("Contacts");
CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond()); CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond());
timer.split("conversations"); timer.split("Conversations");
CursorList<MessageResult> messages = queryMessages(cleanQuery); CursorList<MessageResult> messages = queryMessages(cleanQuery);
timer.split("messages"); timer.split("Messages");
timer.stop(TAG); timer.stop(TAG);
@ -113,22 +97,20 @@ public class SearchRepository {
} }
public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) { public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) {
if (TextUtils.isEmpty(query)) { // If the sanitized search query is empty then abort the search
String cleanQuery = sanitizeQuery(query).trim();
if (cleanQuery.isEmpty()) {
callback.onResult(CursorList.emptyList()); callback.onResult(CursorList.emptyList());
return; return;
} }
executor.execute(() -> { executor.execute(() -> {
long startTime = System.currentTimeMillis(); CursorList<MessageResult> messages = queryMessages(cleanQuery, threadId);
CursorList<MessageResult> messages = queryMessages(sanitizeQuery(query), threadId);
Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms");
callback.onResult(messages); callback.onResult(messages);
}); });
} }
private Pair<CursorList<Contact>, List<String>> queryContacts(String query) { private Pair<CursorList<Contact>, List<String>> queryContacts(String query) {
Cursor contacts = contactDatabase.queryContactsByName(query); Cursor contacts = contactDatabase.queryContactsByName(query);
List<Address> contactList = new ArrayList<>(); List<Address> contactList = new ArrayList<>();
List<String> contactStrings = new ArrayList<>(); List<String> contactStrings = new ArrayList<>();
@ -155,7 +137,6 @@ public class SearchRepository {
MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients}); MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients});
return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings); return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings);
} }
private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) { private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) {
@ -178,9 +159,7 @@ public class SearchRepository {
membersGroupList.close(); membersGroupList.close();
} }
Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses)); Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses));
return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase)) return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase))
: CursorList.emptyList(); : CursorList.emptyList();
} }
@ -245,9 +224,7 @@ public class SearchRepository {
private final Context context; private final Context context;
RecipientModelBuilder(@NonNull Context context) { RecipientModelBuilder(@NonNull Context context) { this.context = context; }
this.context = context;
}
@Override @Override
public Recipient build(@NonNull Cursor cursor) { public Recipient build(@NonNull Cursor cursor) {
@ -290,9 +267,7 @@ public class SearchRepository {
private final Context context; private final Context context;
MessageModelBuilder(@NonNull Context context) { MessageModelBuilder(@NonNull Context context) { this.context = context; }
this.context = context;
}
@Override @Override
public MessageResult build(@NonNull Cursor cursor) { public MessageResult build(@NonNull Cursor cursor) {

View File

@ -151,8 +151,8 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco
val userPublicKey = getLocalNumber(context) val userPublicKey = getLocalNumber(context)
val senderPublicKey = message.sender val senderPublicKey = message.sender
val sentTimestamp = if (message.sentTimestamp == null) 0 else message.sentTimestamp!! val sentTimestamp = message.sentTimestamp ?: 0
val expireStartedAt = if (expiryMode is AfterSend || message.isSenderSelf) sentTimestamp else 0 val expireStartedAt = if ((expiryMode is AfterSend || message.isSenderSelf) && !message.isGroup) sentTimestamp else 0
// Notify the user // Notify the user
if (senderPublicKey == null || userPublicKey == senderPublicKey) { if (senderPublicKey == null || userPublicKey == senderPublicKey) {

View File

@ -182,9 +182,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
} }
fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) { fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) {
val intent = Intent(ACTION_WANTS_TO_ANSWER) val intent = Intent(ACTION_WANTS_TO_ANSWER).putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer)
.putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent) LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
} }
@ -506,9 +504,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
} }
private fun handleAnswerCall(intent: Intent) { private fun handleAnswerCall(intent: Intent) {
val recipient = callManager.recipient ?: return val recipient = callManager.recipient ?: return Log.e(TAG, "No recipient to answer in handleAnswerCall")
val pending = callManager.pendingOffer ?: return val pending = callManager.pendingOffer ?: return Log.e(TAG, "No pending offer in handleAnswerCall")
val callId = callManager.callId ?: return val callId = callManager.callId ?: return Log.e(TAG, "No callId in handleAnswerCall")
val timestamp = callManager.pendingOfferTime val timestamp = callManager.pendingOfferTime
if (callManager.currentConnectionState != CallState.RemoteRing) { if (callManager.currentConnectionState != CallState.RemoteRing) {
@ -526,9 +524,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
insertMissedCall(recipient, true) insertMissedCall(recipient, true)
terminate() terminate()
} }
if (didHangup) { if (didHangup) { return }
return
}
} }
callManager.postConnectionEvent(Event.SendAnswer) { callManager.postConnectionEvent(Event.SendAnswer) {
@ -686,7 +682,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
private fun registerPowerButtonReceiver() { private fun registerPowerButtonReceiver() {
if (powerButtonReceiver == null) { if (powerButtonReceiver == null) {
powerButtonReceiver = PowerButtonReceiver() powerButtonReceiver = PowerButtonReceiver()
registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
} }
} }
@ -719,7 +714,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
} }
} }
private fun handleCheckTimeout(intent: Intent) { private fun handleCheckTimeout(intent: Intent) {
val callId = callManager.callId ?: return val callId = callManager.callId ?: return
val callState = callManager.currentConnectionState val callState = callManager.currentConnectionState
@ -746,9 +740,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
} }
if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) { if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) {
// start an intent for the fullscreen // Start an intent for the fullscreen call activity
val foregroundIntent = Intent(this, WebRtcCallActivity::class.java) val foregroundIntent = Intent(this, WebRtcCallActivity::class.java)
.setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT) .setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
.setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT)
startActivity(foregroundIntent) startActivity(foregroundIntent)
} }

View File

@ -193,7 +193,7 @@ object ConfigurationMessageUtilities {
while (current != null) { while (current != null) {
val recipient = current.recipient val recipient = current.recipient
val contact = when { val contact = when {
recipient.isOpenGroupRecipient -> { recipient.isCommunityRecipient -> {
val openGroup = storage.getOpenGroup(current.threadId) ?: continue val openGroup = storage.getOpenGroup(current.threadId) ?: continue
val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue
convoConfig.getOrConstructCommunity(base, room, pubKey) convoConfig.getOrConstructCommunity(base, room, pubKey)
@ -279,7 +279,7 @@ object ConfigurationMessageUtilities {
@JvmField @JvmField
val DELETE_INACTIVE_ONE_TO_ONES: String = """ val DELETE_INACTIVE_ONE_TO_ONES: String = """
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%';
""".trimIndent() """.trimIndent()
} }

View File

@ -37,3 +37,8 @@ val RecyclerView.isScrolledToBottom: Boolean
get() = computeVerticalScrollOffset().coerceAtLeast(0) + get() = computeVerticalScrollOffset().coerceAtLeast(0) +
computeVerticalScrollExtent() + computeVerticalScrollExtent() +
toPx(50, resources) >= computeVerticalScrollRange() toPx(50, resources) >= computeVerticalScrollRange()
val RecyclerView.isScrolledToWithin30dpOfBottom: Boolean
get() = computeVerticalScrollOffset().coerceAtLeast(0) +
computeVerticalScrollExtent() +
toPx(30, resources) >= computeVerticalScrollRange()

View File

@ -14,7 +14,7 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool
return getOneToOne(recipient.address.serialize())?.unread == true return getOneToOne(recipient.address.serialize())?.unread == true
} else if (recipient.isClosedGroupRecipient) { } else if (recipient.isClosedGroupRecipient) {
return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true
} else if (recipient.isOpenGroupRecipient) { } else if (recipient.isCommunityRecipient) {
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false
return getCommunity(openGroup.server, openGroup.room)?.unread == true return getCommunity(openGroup.server, openGroup.room)?.unread == true
} }

View File

@ -37,12 +37,10 @@ public class Stopwatch {
for (int i = 1; i < splits.size(); i++) { for (int i = 1; i < splits.size(); i++) {
out.append(splits.get(i).label).append(": "); out.append(splits.get(i).label).append(": ");
out.append(splits.get(i).time - splits.get(i - 1).time); out.append(splits.get(i).time - splits.get(i - 1).time);
out.append(" "); out.append("ms ");
} }
out.append("total: ").append(splits.get(splits.size() - 1).time - startTime).append("ms.");
out.append("total: ").append(splits.get(splits.size() - 1).time - startTime);
} }
Log.d(tag, out.toString()); Log.d(tag, out.toString());
} }

View File

@ -9,13 +9,17 @@ import android.graphics.Bitmap
import android.graphics.PointF import android.graphics.PointF
import android.graphics.Rect import android.graphics.Rect
import android.util.Size import android.util.Size
import android.util.TypedValue
import android.view.View import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
import androidx.core.graphics.applyCanvas import androidx.core.graphics.applyCanvas
import org.session.libsignal.utilities.Log
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun View.contains(point: PointF): Boolean { fun View.contains(point: PointF): Boolean {
@ -32,6 +36,24 @@ val View.hitRect: Rect
@ColorInt @ColorInt
fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent) fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent)
// Method to grab the appropriate attribute for a message colour.
// Note: This is an attribute, NOT a resource Id - see `getColorResourceIdFromAttr` for that.
@AttrRes
fun getMessageTextColourAttr(messageIsOutgoing: Boolean): Int {
return if (messageIsOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
}
// Method to get an actual R.id.<SOME_COLOUR> resource Id from an attribute such as R.attr.message_sent_text_color etc.
@ColorRes
fun getColorResourceIdFromAttr(context: Context, attr: Int): Int {
val outTypedValue = TypedValue()
val successfullyFoundAttribute = context.theme.resolveAttribute(attr, outTypedValue, true)
if (successfullyFoundAttribute) { return outTypedValue.resourceId }
Log.w("ViewUtils", "Could not find colour attribute $attr in theme - using grey as a safe fallback")
return R.color.gray50
}
fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) { fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) {
val startSize = resources.getDimension(startSizeID) val startSize = resources.getDimension(startSizeID)
val endSize = resources.getDimension(endSizeID) val endSize = resources.getDimension(endSizeID)
@ -70,7 +92,6 @@ fun View.hideKeyboard() {
imm.hideSoftInputFromWindow(this.windowToken, 0) imm.hideSoftInputFromWindow(this.windowToken, 0)
} }
fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888, longestWidth: Int = 2000): Bitmap { fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888, longestWidth: Int = 2000): Bitmap {
val size = Size(measuredWidth, measuredHeight).coerceAtMost(longestWidth) val size = Size(measuredWidth, measuredHeight).coerceAtMost(longestWidth)
val scale = size.width / measuredWidth.toFloat() val scale = size.width / measuredWidth.toFloat()

View File

@ -408,6 +408,10 @@ class CallManager(
override fun onCameraSwitchCompleted(newCameraState: CameraState) { override fun onCameraSwitchCompleted(newCameraState: CameraState) {
localCameraState = newCameraState localCameraState = newCameraState
// If the camera we've switched to is the front one then mirror it to match what someone
// would see when looking in the mirror rather than the left<-->right flipped version.
localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
} }
fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) {
@ -639,7 +643,11 @@ class CallManager(
peerConnection?.let { connection -> peerConnection?.let { connection ->
connection.flipCamera() connection.flipCamera()
localCameraState = connection.getCameraState() localCameraState = connection.getCameraState()
localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
// Note: We cannot set the mirrored state of the localRenderer here because
// localCameraState.activeDirection is still PENDING (not FRONT or BACK) until the flip
// completes and we hit Camera.onCameraSwitchDone (followed by PeerConnectionWrapper.onCameraSwitchCompleted
// and CallManager.onCameraSwitchCompleted).
} }
} }

View File

@ -326,8 +326,6 @@ class PeerConnectionWrapper(private val context: Context,
} }
override fun onCameraSwitchCompleted(newCameraState: CameraState) { override fun onCameraSwitchCompleted(newCameraState: CameraState) {
// mirror rotation offset
rotationVideoSink.mirrored = newCameraState.activeDirection == CameraState.Direction.FRONT
cameraEventListener.onCameraSwitchCompleted(newCameraState) cameraEventListener.onCameraSwitchCompleted(newCameraState)
} }

View File

@ -54,7 +54,7 @@ class Camera(context: Context,
Log.w(TAG, "Tried to flip camera without capturer or less than 2 cameras") Log.w(TAG, "Tried to flip camera without capturer or less than 2 cameras")
return return
} }
activeDirection = PENDING activeDirection = PENDING // Note: The activeDirection will be PENDING until `onCameraSwitchDone`
capturer.switchCamera(this) capturer.switchCamera(this)
} }

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M0,0 L100,100 M0,100 L100,0"
android:strokeWidth="1"
android:strokeColor="@android:color/white" />
</vector>

View File

@ -4,7 +4,6 @@
<item android:id="@android:id/mask"> <item android:id="@android:id/mask">
<shape android:shape="rectangle"> <shape android:shape="rectangle">
<solid android:color="?android:textColorPrimary"/> <solid android:color="?android:textColorPrimary"/>
<corners android:radius="@dimen/medium_button_corner_radius" />
</shape> </shape>
</item> </item>
</ripple> </ripple>

View File

@ -4,7 +4,6 @@
<item android:id="@android:id/mask"> <item android:id="@android:id/mask">
<shape android:shape="rectangle"> <shape android:shape="rectangle">
<solid android:color="?android:textColorPrimary"/> <solid android:color="?android:textColorPrimary"/>
<corners android:radius="@dimen/medium_button_corner_radius" />
</shape> </shape>
</item> </item>
</ripple> </ripple>

View File

@ -43,6 +43,8 @@
android:paddingBottom="0dp" android:paddingBottom="0dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:inputType="textCapWords" android:inputType="textCapWords"
android:maxLength="@integer/max_user_nickname_length_chars"
android:maxLines="1"
android:hint="@string/activity_display_name_edit_text_hint" /> android:hint="@string/activity_display_name_edit_text_hint" />
<View <View

View File

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginRight="@dimen/very_large_spacing"
android:textSize="@dimen/very_large_font_size"
android:textStyle="bold"
android:textColor="?android:textColorPrimary"
android:text="@string/activity_restore_title" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginTop="7dp"
android:layout_marginRight="@dimen/very_large_spacing"
android:textSize="@dimen/medium_font_size"
android:textColor="?android:textColorPrimary"
android:text="@string/activity_restore_explanation" />
<EditText
style="@style/SessionEditText"
android:id="@+id/mnemonicEditText"
android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginTop="12dp"
android:layout_marginRight="@dimen/very_large_spacing"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:gravity="center_vertical"
android:inputType="textMultiLine"
android:maxLines="3"
android:hint="@string/activity_restore_seed_edit_text_hint" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<Button
style="@style/Widget.Session.Button.Common.ProminentFilled"
android:id="@+id/restoreButton"
android:contentDescription="@string/AccessibilityId_continue"
android:layout_width="match_parent"
android:layout_height="@dimen/medium_button_height"
android:layout_marginLeft="@dimen/massive_spacing"
android:layout_marginRight="@dimen/massive_spacing"
android:text="@string/continue_2" />
<TextView
android:id="@+id/termsTextView"
android:layout_width="match_parent"
android:layout_height="@dimen/onboarding_button_bottom_offset"
android:layout_marginLeft="@dimen/massive_spacing"
android:layout_marginRight="@dimen/massive_spacing"
android:gravity="center"
android:textColor="?android:textColorTertiary"
android:textColorLink="?colorAccent"
android:textSize="@dimen/very_small_font_size"
android:text="By using this service, you agree to our Terms of Service and Privacy Policy"
tools:ignore="HardcodedText" /> <!-- Intentionally not yet translated -->
</LinearLayout>

View File

@ -23,6 +23,10 @@
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
<!--
Add this to the below recycler view if you need to debug activity `adjustResize` issues:
android:background="@drawable/cross"
-->
<org.thoughtcrime.securesms.conversation.v2.ConversationRecyclerView <org.thoughtcrime.securesms.conversation.v2.ConversationRecyclerView
android:focusable="false" android:focusable="false"
android:id="@+id/conversationRecyclerView" android:id="@+id/conversationRecyclerView"
@ -31,6 +35,7 @@
android:layout_above="@+id/typingIndicatorViewContainer" android:layout_above="@+id/typingIndicatorViewContainer"
android:layout_below="@id/toolbar" /> android:layout_below="@id/toolbar" />
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
android:focusable="false" android:focusable="false"
android:id="@+id/typingIndicatorViewContainer" android:id="@+id/typingIndicatorViewContainer"

View File

@ -43,6 +43,8 @@
android:paddingBottom="0dp" android:paddingBottom="0dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:inputType="textCapWords" android:inputType="textCapWords"
android:maxLength="@integer/max_user_nickname_length_chars"
android:maxLines="1"
android:hint="@string/activity_display_name_edit_text_hint" /> android:hint="@string/activity_display_name_edit_text_hint" />
<View <View

View File

@ -32,6 +32,7 @@
android:id="@+id/btnCancelGroupNameEdit" android:id="@+id/btnCancelGroupNameEdit"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginLeft="@dimen/medium_spacing"
android:contentDescription="@string/AccessibilityId_cancel_name_change" android:contentDescription="@string/AccessibilityId_cancel_name_change"
android:src="@drawable/ic_baseline_clear_24"/> android:src="@drawable/ic_baseline_clear_24"/>
@ -49,6 +50,7 @@
android:inputType="text" android:inputType="text"
android:singleLine="true" android:singleLine="true"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:maxLength="@integer/max_group_and_community_name_length_chars"
android:contentDescription="@string/AccessibilityId_group_name" android:contentDescription="@string/AccessibilityId_group_name"
android:hint="@string/activity_edit_closed_group_edit_text_hint" /> android:hint="@string/activity_edit_closed_group_edit_text_hint" />
@ -57,6 +59,7 @@
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginRight="@dimen/medium_spacing"
android:contentDescription="@string/AccessibilityId_accept_name_change" android:contentDescription="@string/AccessibilityId_accept_name_change"
android:src="@drawable/ic_baseline_done_24"/> android:src="@drawable/ic_baseline_done_24"/>

View File

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginRight="@dimen/very_large_spacing"
android:textSize="@dimen/large_font_size"
android:textStyle="bold"
android:textColor="?android:textColorPrimary"
android:text="@string/activity_restore_title" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginTop="4dp"
android:layout_marginRight="@dimen/very_large_spacing"
android:textSize="@dimen/small_font_size"
android:textColor="?android:textColorPrimary"
android:text="@string/activity_restore_explanation" />
<EditText
android:id="@+id/mnemonicEditText"
style="@style/SmallSessionEditText"
android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginLeft="@dimen/very_large_spacing"
android:layout_marginTop="10dp"
android:layout_marginRight="@dimen/very_large_spacing"
android:gravity="center_vertical"
android:hint="@string/activity_restore_seed_edit_text_hint"
android:inputType="textMultiLine"
android:maxLines="3"
android:paddingTop="0dp"
android:paddingBottom="0dp" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<Button
style="@style/Widget.Session.Button.Common.ProminentFilled"
android:contentDescription="@string/AccessibilityId_continue"
android:id="@+id/restoreButton"
android:layout_width="match_parent"
android:layout_height="@dimen/medium_button_height"
android:layout_marginLeft="@dimen/massive_spacing"
android:layout_marginRight="@dimen/massive_spacing"
android:text="@string/continue_2" />
<TextView
android:id="@+id/termsTextView"
android:layout_width="match_parent"
android:layout_height="@dimen/onboarding_button_bottom_offset"
android:layout_marginLeft="@dimen/massive_spacing"
android:layout_marginRight="@dimen/massive_spacing"
android:gravity="center"
android:textColorLink="?android:textColorPrimary"
android:textSize="@dimen/very_small_font_size"
android:textColor="?android:textColorPrimary"
android:text="By using this service, you agree to our Terms of Service and Privacy Policy"
tools:ignore="HardcodedText" /> <!-- Intentionally not yet translated -->
</LinearLayout>

View File

@ -47,7 +47,11 @@
android:paddingTop="12dp" android:paddingTop="12dp"
android:paddingBottom="12dp" android:paddingBottom="12dp"
android:visibility="invisible" android:visibility="invisible"
android:hint="@string/activity_settings_display_name_edit_text_hint" /> android:hint="@string/activity_settings_display_name_edit_text_hint"
android:imeOptions="actionDone"
android:inputType="textCapWords"
android:maxLength="@integer/max_user_nickname_length_chars"
android:maxLines="1" />
<TextView <TextView
android:id="@+id/btnGroupNameDisplay" android:id="@+id/btnGroupNameDisplay"
@ -55,8 +59,10 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:contentDescription="@string/AccessibilityId_username" android:contentDescription="@string/AccessibilityId_username"
android:gravity="center"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="@dimen/very_large_font_size" android:textSize="@dimen/very_large_font_size"
android:maxLength="@integer/max_user_nickname_length_chars"
android:textStyle="bold" /> android:textStyle="bold" />
</RelativeLayout> </RelativeLayout>

View File

@ -6,8 +6,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:elevation="4dp" android:elevation="4dp">
android:padding="@dimen/medium_spacing">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -21,6 +20,8 @@
android:id="@+id/dialogDescriptionText" android:id="@+id/dialogDescriptionText"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="@dimen/large_spacing" android:layout_marginTop="@dimen/large_spacing"
android:text="@string/dialog_clear_all_data_message" android:text="@string/dialog_clear_all_data_message"
android:textAlignment="center" android:textAlignment="center"
@ -46,16 +47,15 @@
style="@style/Widget.Session.Button.Dialog.DestructiveText" style="@style/Widget.Session.Button.Dialog.DestructiveText"
android:id="@+id/clearAllDataButton" android:id="@+id/clearAllDataButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="@dimen/small_button_height" android:layout_height="@dimen/dialog_button_height"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginStart="@dimen/medium_spacing"
android:text="@string/dialog_clear_all_data_clear" /> android:text="@string/dialog_clear_all_data_clear" />
<Button <Button
style="@style/Widget.Session.Button.Dialog.UnimportantText" style="@style/Widget.Session.Button.Dialog.UnimportantText"
android:id="@+id/cancelButton" android:id="@+id/cancelButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="@dimen/small_button_height" android:layout_height="@dimen/dialog_button_height"
android:layout_weight="1" android:layout_weight="1"
android:text="@string/cancel" /> android:text="@string/cancel" />

View File

@ -38,7 +38,7 @@
style="@style/Widget.Session.Button.Dialog.UnimportantText" style="@style/Widget.Session.Button.Dialog.UnimportantText"
android:id="@+id/cancelButton" android:id="@+id/cancelButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="@dimen/small_button_height" android:layout_height="@dimen/dialog_button_height"
android:layout_weight="1" android:layout_weight="1"
android:text="@string/cancel" /> android:text="@string/cancel" />
@ -46,7 +46,7 @@
style="@style/Widget.Session.Button.Dialog.DestructiveText" style="@style/Widget.Session.Button.Dialog.DestructiveText"
android:id="@+id/sendSeedButton" android:id="@+id/sendSeedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="@dimen/small_button_height" android:layout_height="@dimen/dialog_button_height"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginStart="@dimen/medium_spacing" android:layout_marginStart="@dimen/medium_spacing"
android:text="@string/dialog_send_seed_send_button_title" /> android:text="@string/dialog_send_seed_send_button_title" />

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<TextView <TextView
android:id="@+id/export_logs_button"
android:layout_gravity="center" android:layout_gravity="center"
style="@style/Widget.Session.Button.Common.Filled" style="@style/Widget.Session.Button.Common.Filled"
android:textStyle="bold" android:textStyle="bold"
@ -12,4 +13,5 @@
android:paddingVertical="12dp" android:paddingVertical="12dp"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
</FrameLayout> </FrameLayout>

View File

@ -62,10 +62,14 @@
android:layout_marginBottom="@dimen/medium_spacing" android:layout_marginBottom="@dimen/medium_spacing"
android:contentDescription="@string/AccessibilityId_group_name_input" android:contentDescription="@string/AccessibilityId_group_name_input"
android:hint="@string/activity_create_closed_group_edit_text_hint" android:hint="@string/activity_create_closed_group_edit_text_hint"
android:maxLength="30" android:imeOptions="actionDone"
android:inputType="textCapWords"
android:maxLength="@integer/max_group_and_community_name_length_chars"
android:maxLines="1"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" /> app:layout_constraintTop_toBottomOf="@id/titleText"
tools:ignore="ContentDescription" />
<org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView <org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
android:id="@+id/contactSearch" android:id="@+id/contactSearch"

View File

@ -29,6 +29,8 @@
android:id="@+id/nameTextViewContainer" android:id="@+id/nameTextViewContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="@dimen/medium_spacing"
android:paddingEnd="@dimen/medium_spacing"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_centerInParent="true" android:layout_centerInParent="true"
@ -42,6 +44,7 @@
android:id="@+id/nameTextView" android:id="@+id/nameTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginStart="@dimen/small_spacing" android:layout_marginStart="@dimen/small_spacing"
android:layout_marginEnd="@dimen/small_spacing" android:layout_marginEnd="@dimen/small_spacing"
@ -57,6 +60,7 @@
android:layout_height="22dp" android:layout_height="22dp"
android:contentDescription="@string/AccessibilityId_edit_user_nickname" android:contentDescription="@string/AccessibilityId_edit_user_nickname"
android:paddingTop="2dp" android:paddingTop="2dp"
android:layout_marginEnd="20dp"
android:src="@drawable/ic_baseline_edit_24" /> android:src="@drawable/ic_baseline_edit_24" />
</LinearLayout> </LinearLayout>
@ -73,6 +77,7 @@
android:id="@+id/cancelNicknameEditingButton" android:id="@+id/cancelNicknameEditingButton"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginLeft="@dimen/large_spacing"
android:contentDescription="@string/AccessibilityId_cancel" android:contentDescription="@string/AccessibilityId_cancel"
android:src="@drawable/ic_baseline_clear_24" /> android:src="@drawable/ic_baseline_clear_24" />
@ -82,12 +87,12 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginHorizontal="@dimen/small_spacing"
android:contentDescription="@string/AccessibilityId_username" android:contentDescription="@string/AccessibilityId_username"
android:textAlignment="center" android:textAlignment="center"
android:paddingVertical="12dp" android:paddingVertical="12dp"
android:inputType="text" android:inputType="text"
android:singleLine="true" android:maxLength="@integer/max_user_nickname_length_chars"
android:maxLines="1"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:textColorHint="?android:textColorSecondary" android:textColorHint="?android:textColorSecondary"
android:hint="@string/fragment_user_details_bottom_sheet_edit_text_hint" /> android:hint="@string/fragment_user_details_bottom_sheet_edit_text_hint" />
@ -96,6 +101,7 @@
android:id="@+id/saveNicknameButton" android:id="@+id/saveNicknameButton"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginRight="@dimen/large_spacing"
android:contentDescription="@string/AccessibilityId_apply" android:contentDescription="@string/AccessibilityId_apply"
android:src="@drawable/ic_baseline_done_24" /> android:src="@drawable/ic_baseline_done_24" />

View File

@ -1,23 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container" android:id="@+id/export_progress_container"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:paddingBottom="16dp" android:layout_gravity="bottom" >
android:gravity="bottom">
<ProgressBar android:id="@+id/progress_bar" <ProgressBar
android:id="@+id/export_progress_bar"
style="?android:attr/progressBarStyleHorizontal" style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:indeterminate="true"/> android:indeterminate="true"
android:visibility="invisible" />
<TextView android:id="@+id/progress_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="1345 messages so far"/>
</LinearLayout> </LinearLayout>

View File

@ -15,7 +15,7 @@
android:layout_width="17dp" android:layout_width="17dp"
android:layout_height="17dp" /> android:layout_height="17dp" />
<Space <View
android:id="@+id/reactions_pill_spacer" android:id="@+id/reactions_pill_spacer"
android:layout_width="4dp" android:layout_width="4dp"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />

View File

@ -25,6 +25,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/medium_spacing" android:layout_marginStart="@dimen/medium_spacing"
android:maxLength="@integer/max_user_nickname_length_chars"
android:maxLines="1" android:maxLines="1"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:ellipsize="end" android:ellipsize="end"

View File

@ -48,7 +48,7 @@
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="@dimen/very_small_font_size" android:textSize="@dimen/very_small_font_size"
android:textStyle="bold" android:textStyle="bold"
tools:text="@string/MessageRecord_you_disabled_disappearing_messages" /> tools:text="You disabled disappearing messages" />
<FrameLayout <FrameLayout
android:id="@+id/call_view" android:id="@+id/call_view"

View File

@ -165,7 +165,7 @@
android:maxLines="1" android:maxLines="1"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="@dimen/medium_font_size" android:textSize="@dimen/medium_font_size"
tools:text="Sorry, gotta go fight crime again" /> tools:text="Sorry, gotta go fight crime again - and more text to make it ellipsize" />
<include layout="@layout/view_typing_indicator" <include layout="@layout/view_typing_indicator"
android:id="@+id/typingIndicatorView" android:id="@+id/typingIndicatorView"

View File

@ -11,6 +11,7 @@
<dimen name="massive_font_size">50sp</dimen> <dimen name="massive_font_size">50sp</dimen>
<!-- Element Sizes --> <!-- Element Sizes -->
<dimen name="dialog_button_height">60dp</dimen>
<dimen name="small_button_height">34dp</dimen> <dimen name="small_button_height">34dp</dimen>
<dimen name="medium_button_height">38dp</dimen> <dimen name="medium_button_height">38dp</dimen>
<dimen name="large_button_height">54dp</dimen> <dimen name="large_button_height">54dp</dimen>

View File

@ -6,4 +6,7 @@
<integer name="reaction_scrubber_reveal_offset">100</integer> <integer name="reaction_scrubber_reveal_offset">100</integer>
<integer name="reaction_scrubber_hide_duration">150</integer> <integer name="reaction_scrubber_hide_duration">150</integer>
<integer name="reaction_scrubber_emoji_reveal_duration_start_delay_factor">10</integer> <integer name="reaction_scrubber_emoji_reveal_duration_start_delay_factor">10</integer>
<integer name="max_user_nickname_length_chars">35</integer>
<integer name="max_group_and_community_name_length_chars">35</integer>
</resources> </resources>

View File

@ -119,6 +119,7 @@
<item name="android:textAllCaps">false</item> <item name="android:textAllCaps">false</item>
<item name="android:textSize">@dimen/small_font_size</item> <item name="android:textSize">@dimen/small_font_size</item>
<item name="android:textColor">?android:textColorPrimary</item> <item name="android:textColor">?android:textColorPrimary</item>
<item name="android:textStyle">bold</item>
</style> </style>
<style name="Widget.Session.Button.Dialog.UnimportantText"> <style name="Widget.Session.Button.Dialog.UnimportantText">

View File

@ -45,7 +45,7 @@
<PreferenceCategory android:title="@string/preferences__link_previews"> <PreferenceCategory android:title="@string/preferences__link_previews">
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat <org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="false"
android:key="pref_link_previews" android:key="pref_link_previews"
android:summary="@string/preferences__link_previews_summary" android:summary="@string/preferences__link_previews_summary"
android:title="@string/preferences__send_link_previews"/> android:title="@string/preferences__send_link_previews"/>

View File

@ -7,38 +7,37 @@
android:title="@string/activity_help_settings__report_bug_title" android:title="@string/activity_help_settings__report_bug_title"
android:summary="@string/activity_help_settings__report_bug_summary" android:summary="@string/activity_help_settings__report_bug_summary"
android:widgetLayout="@layout/export_logs_widget" /> android:widgetLayout="@layout/export_logs_widget" />
<!-- Note: Having this as `android:layout` rather than `android:layoutWidget` allows it to fit the screen width -->
<Preference android:layout="@layout/preference_widget_progress" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="translate_session" android:key="translate_session"
android:title="@string/activity_help_settings__translate_session" android:title="@string/activity_help_settings__translate_session"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="feedback" android:key="feedback"
android:title="@string/activity_help_settings__feedback" android:title="@string/activity_help_settings__feedback"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="faq" android:key="faq"
android:title="@string/activity_help_settings__faq" android:title="@string/activity_help_settings__faq"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="support" android:key="support"
android:title="@string/activity_help_settings__support" android:title="@string/activity_help_settings__support"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms
import org.session.libsignal.utilities.Log.Logger
object NoOpLogger: Logger() {
override fun v(tag: String?, message: String?, t: Throwable?) {}
override fun d(tag: String?, message: String?, t: Throwable?) {}
override fun i(tag: String?, message: String?, t: Throwable?) {}
override fun w(tag: String?, message: String?, t: Throwable?) {}
override fun e(tag: String?, message: String?, t: Throwable?) {}
override fun wtf(tag: String?, message: String?, t: Throwable?) {}
override fun blockUntilAllWritesFinished() {}
}

View File

@ -1,10 +1,20 @@
package org.thoughtcrime.securesms package org.thoughtcrime.securesms
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import org.junit.BeforeClass
import org.junit.Rule import org.junit.Rule
import org.session.libsignal.utilities.Log
open class BaseViewModelTest: BaseCoroutineTest() { open class BaseViewModelTest: BaseCoroutineTest() {
companion object {
@BeforeClass
@JvmStatic
fun setupLogger() {
Log.initialize(NoOpLogger)
}
}
@get:Rule @get:Rule
var instantExecutorRule = InstantTaskExecutorRule() var instantExecutorRule = InstantTaskExecutorRule()

View File

@ -39,7 +39,7 @@ import kotlin.time.Duration.Companion.minutes
private const val THREAD_ID = 1L private const val THREAD_ID = 1L
private const val LOCAL_NUMBER = "05---local---address" private const val LOCAL_NUMBER = "05---local---address"
private val LOCAL_ADDRESS = Address.fromSerialized(LOCAL_NUMBER) private val LOCAL_ADDRESS = Address.fromSerialized(LOCAL_NUMBER)
private const val GROUP_NUMBER = "${GroupUtil.OPEN_GROUP_PREFIX}4133" private const val GROUP_NUMBER = "${GroupUtil.COMMUNITY_PREFIX}4133"
private val GROUP_ADDRESS = Address.fromSerialized(GROUP_NUMBER) private val GROUP_ADDRESS = Address.fromSerialized(GROUP_NUMBER)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -53,6 +53,7 @@ class DisappearingMessagesViewModelTest {
@Mock lateinit var application: Application @Mock lateinit var application: Application
@Mock lateinit var textSecurePreferences: TextSecurePreferences @Mock lateinit var textSecurePreferences: TextSecurePreferences
@Mock lateinit var messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol @Mock lateinit var messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol
@Mock lateinit var disappearingMessages: DisappearingMessages
@Mock lateinit var threadDb: ThreadDatabase @Mock lateinit var threadDb: ThreadDatabase
@Mock lateinit var groupDb: GroupDatabase @Mock lateinit var groupDb: GroupDatabase
@Mock lateinit var storage: Storage @Mock lateinit var storage: Storage
@ -114,9 +115,9 @@ class DisappearingMessagesViewModelTest {
isSelfAdmin = true, isSelfAdmin = true,
address = LOCAL_ADDRESS, address = LOCAL_ADDRESS,
isNoteToSelf = true, isNoteToSelf = true,
expiryMode = ExpiryMode.NONE, expiryMode = ExpiryMode.Legacy(0),
isNewConfigEnabled = false, isNewConfigEnabled = false,
persistedMode = ExpiryMode.NONE, persistedMode = ExpiryMode.Legacy(0),
showDebugOptions = false showDebugOptions = false
) )
) )
@ -127,7 +128,7 @@ class DisappearingMessagesViewModelTest {
UiState( UiState(
OptionsCard( OptionsCard(
R.string.activity_disappearing_messages_timer, R.string.activity_disappearing_messages_timer,
typeOption(ExpiryMode.NONE, selected = true), typeOption(ExpiryMode.NONE, selected = false),
timeOption(ExpiryType.LEGACY, 12.hours), timeOption(ExpiryType.LEGACY, 12.hours),
timeOption(ExpiryType.LEGACY, 1.days), timeOption(ExpiryType.LEGACY, 1.days),
timeOption(ExpiryType.LEGACY, 7.days), timeOption(ExpiryType.LEGACY, 7.days),
@ -555,6 +556,7 @@ class DisappearingMessagesViewModelTest {
application, application,
textSecurePreferences, textSecurePreferences,
messageExpirationManager, messageExpirationManager,
disappearingMessages,
threadDb, threadDb,
groupDb, groupDb,
storage, storage,

View File

@ -3,12 +3,14 @@ package org.thoughtcrime.securesms.conversation.v2
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.CoreMatchers.endsWith
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.notNullValue import org.hamcrest.CoreMatchers.notNullValue
import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.CoreMatchers.nullValue
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.mockito.Mockito.anyLong import org.mockito.Mockito.anyLong
@ -18,7 +20,9 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.BaseViewModelTest
import org.thoughtcrime.securesms.NoOpLogger
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
@ -32,6 +36,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private val threadId = 123L private val threadId = 123L
private val edKeyPair = mock<KeyPair>() private val edKeyPair = mock<KeyPair>()
private lateinit var recipient: Recipient private lateinit var recipient: Recipient
private lateinit var messageRecord: MessageRecord
private val viewModel: ConversationViewModel by lazy { private val viewModel: ConversationViewModel by lazy {
ConversationViewModel(threadId, edKeyPair, repository, storage) ConversationViewModel(threadId, edKeyPair, repository, storage)
@ -40,6 +45,9 @@ class ConversationViewModelTest: BaseViewModelTest() {
@Before @Before
fun setUp() { fun setUp() {
recipient = mock() recipient = mock()
messageRecord = mock { record ->
whenever(record.individualRecipient).thenReturn(recipient)
}
whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient) whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient)
whenever(repository.recipientUpdateFlow(anyLong())).thenReturn(emptyFlow()) whenever(repository.recipientUpdateFlow(anyLong())).thenReturn(emptyFlow())
} }
@ -144,7 +152,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
val error = Throwable() val error = Throwable()
whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Failure(error)) whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Failure(error))
viewModel.banAndDeleteAll(recipient) viewModel.banAndDeleteAll(messageRecord)
assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error")) assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error"))
} }
@ -153,7 +161,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
fun `should emit a message on ban user and delete all success`() = runBlockingTest { fun `should emit a message on ban user and delete all success`() = runBlockingTest {
whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Success(Unit)) whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Success(Unit))
viewModel.banAndDeleteAll(recipient) viewModel.banAndDeleteAll(messageRecord)
assertThat( assertThat(
viewModel.uiState.first().uiMessages.first().message, viewModel.uiState.first().uiMessages.first().message,
@ -189,7 +197,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
@Test @Test
fun `open group recipient should have no blinded recipient`() { fun `open group recipient should have no blinded recipient`() {
whenever(recipient.isOpenGroupRecipient).thenReturn(true) whenever(recipient.isCommunityRecipient).thenReturn(true)
whenever(recipient.isOpenGroupOutboxRecipient).thenReturn(false) whenever(recipient.isOpenGroupOutboxRecipient).thenReturn(false)
whenever(recipient.isOpenGroupInboxRecipient).thenReturn(false) whenever(recipient.isOpenGroupInboxRecipient).thenReturn(false)
assertThat(viewModel.blindedRecipient, nullValue()) assertThat(viewModel.blindedRecipient, nullValue())

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