Merge pull request #1451 from bemusementpark/on-2

[SES-48 SES-824] Onboarding Overhaul
This commit is contained in:
Andrew
2024-07-18 11:20:32 +09:30
committed by GitHub
310 changed files with 8008 additions and 6755 deletions

View File

@@ -376,14 +376,25 @@ dependencies {
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.5.2' implementation "androidx.compose.ui:ui:$composeVersion"
implementation 'androidx.compose.ui:ui-tooling:1.5.2' implementation "androidx.compose.animation:animation:$composeVersion"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "androidx.compose.ui:ui-tooling:$composeVersion"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha" implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:1.5.2" implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
implementation "androidx.compose.material:material:$composeVersion"
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
implementation 'androidx.compose.foundation:foundation-layout:1.5.2' implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation 'androidx.compose.material:material:1.5.2' implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
implementation "androidx.camera:camera-camera2:1.3.2"
implementation "androidx.camera:camera-lifecycle:1.3.2"
implementation "androidx.camera:camera-view:1.3.2"
implementation 'com.google.firebase:firebase-core:21.1.1'
implementation "com.google.mlkit:barcode-scanning:17.2.0"
} }
static def getLastCommitTimestamp() { static def getLastCommitTimestamp() {

View File

@@ -22,6 +22,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher import org.hamcrest.Matcher
@@ -49,9 +51,14 @@ class HomeActivityTests {
private val activityMonitor = Instrumentation.ActivityMonitor(ConversationActivityV2::class.java.name, null, false) private val activityMonitor = Instrumentation.ActivityMonitor(ConversationActivityV2::class.java.name, null, false)
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Before @Before
fun setUp() { fun setUp() {
InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor) InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor)
} }
@After @After
@@ -72,25 +79,34 @@ class HomeActivityTests {
onView(isRoot()).perform(waitFor(500)) onView(isRoot()).perform(waitFor(500))
} }
private fun objectFromDesc(id: Int) = device.findObject(By.desc(context.getString(id)))
private fun setupLoggedInState(hasViewedSeed: Boolean = false) { private fun setupLoggedInState(hasViewedSeed: Boolean = false) {
// landing activity // landing activity
onView(withId(R.id.registerButton)).perform(ViewActions.click()) objectFromDesc(R.string.onboardingAccountCreate).click()
// session ID - register activity
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// display name selection // display name selection
onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123")) objectFromDesc(R.string.displayNameEnter).click()
onView(withId(R.id.registerButton)).perform(ViewActions.click()) device.pressKeyCode(65)
device.pressKeyCode(66)
device.pressKeyCode(67)
// Continue with display name
objectFromDesc(R.string.continue_2).click()
// Continue with default push notification setting
objectFromDesc(R.string.continue_2).click()
// PN select // PN select
if (hasViewedSeed) { if (hasViewedSeed) {
// has viewed seed is set to false after register activity // has viewed seed is set to false after register activity
TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true) TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true)
} }
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission // allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
} }
private fun goToMyChat() { private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
@@ -111,8 +127,8 @@ class HomeActivityTests {
@Test @Test
fun testLaunches_dismiss_seedView() { fun testLaunches_dismiss_seedView() {
setupLoggedInState() setupLoggedInState()
onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click()) objectFromDesc(R.string.continue_2).click()
onView(withId(R.id.copyButton)).perform(ViewActions.click()) objectFromDesc(R.string.copy).click()
pressBack() pressBack()
onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
} }
@@ -133,7 +149,7 @@ class HomeActivityTests {
fun testChat_withSelf() { fun testChat_withSelf() {
setupLoggedInState() setupLoggedInState()
goToMyChat() goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) TextSecurePreferences.setLinkPreviewsEnabled(context, true)
sendMessage("howdy") sendMessage("howdy")
sendMessage("test") sendMessage("test")
// tests url rewriter doesn't crash // tests url rewriter doesn't crash

View File

@@ -39,7 +39,7 @@ class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0 private var fakeHashI = 0
private val nextFakeHash: String private val nextFakeHash: String
@@ -102,7 +102,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage) val storageSpy = spy(app.storage)
app.storage = storageSpy app.storage = storageSpy
val newContactId = randomSessionId() val newContactId = randomAccountId()
val singleContact = Contact( val singleContact = Contact(
id = newContactId, id = newContactId,
approved = true, approved = true,
@@ -123,7 +123,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage) val storageSpy = spy(app.storage)
app.storage = storageSpy app.storage = storageSpy
val randomRecipient = randomSessionId() val randomRecipient = randomAccountId()
val newContact = Contact( val newContact = Contact(
id = randomRecipient, id = randomRecipient,
approved = true, approved = true,
@@ -158,7 +158,7 @@ class LibSessionTests {
app.storage = storageSpy app.storage = storageSpy
// Initial state // Initial state
val randomRecipient = randomSessionId() val randomRecipient = randomAccountId()
val currentContact = Contact( val currentContact = Contact(
id = randomRecipient, id = randomRecipient,
approved = true, approved = true,

View File

@@ -136,29 +136,29 @@ class SodiumUtilitiesTest {
} }
@Test @Test
fun sessionIdSuccess() { fun accountIdSuccess() {
val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", serverPublicKey) val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", serverPublicKey)
assertTrue(result) assertTrue(result)
} }
@Test @Test
fun sessionIdFailureInvalidSessionId() { fun accountIdFailureInvalidAccountId() {
val result = SodiumUtilities.sessionId("AB$publicKey", "15$blindedPublicKey", serverPublicKey) val result = SodiumUtilities.accountId("AB$publicKey", "15$blindedPublicKey", serverPublicKey)
assertFalse(result) assertFalse(result)
} }
@Test @Test
fun sessionIdFailureInvalidBlindedId() { fun accountIdFailureInvalidBlindedId() {
val result = SodiumUtilities.sessionId("05$publicKey", "AB$blindedPublicKey", serverPublicKey) val result = SodiumUtilities.accountId("05$publicKey", "AB$blindedPublicKey", serverPublicKey)
assertFalse(result) assertFalse(result)
} }
@Test @Test
fun sessionIdFailureBlindingFactor() { fun accountIdFailureBlindingFactor() {
val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", "Test") val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", "Test")
assertFalse(result) assertFalse(result)
} }

View File

@@ -100,31 +100,32 @@
android:value="false" /> android:value="false" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.LandingActivity" android:name="org.thoughtcrime.securesms.onboarding.landing.LandingActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity" android:name="org.thoughtcrime.securesms.onboarding.loadaccount.LoadAccountActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.DisplayNameActivity" android:name="org.thoughtcrime.securesms.onboarding.loading.LoadingActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.PNModeActivity" android:name="org.thoughtcrime.securesms.onboarding.pickname.PickDisplayNameActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.home.HomeActivity" android:name="org.thoughtcrime.securesms.home.HomeActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:launchMode="singleTask" android:launchMode="standard"
android:theme="@style/Theme.Session.DayNight.NoActionBar" /> android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity" android:name="org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity"
@@ -153,7 +154,7 @@
android:label="@string/activity_edit_closed_group_title" android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.SeedActivity" android:name="org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity <activity
android:name="org.thoughtcrime.securesms.contacts.SelectContactsActivity" android:name="org.thoughtcrime.securesms.contacts.SelectContactsActivity"

View File

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import static nl.komponents.kovenant.android.KovenantAndroid.startKovenant; import static nl.komponents.kovenant.android.KovenantAndroid.startKovenant;
import static nl.komponents.kovenant.android.KovenantAndroid.stopKovenant; import static nl.komponents.kovenant.android.KovenantAndroid.stopKovenant;
import android.annotation.SuppressLint;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@@ -137,7 +138,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public MessageNotifier messageNotifier = null; public MessageNotifier messageNotifier = null;
public Poller poller = null; public Poller poller = null;
public Broadcaster broadcaster = null; public Broadcaster broadcaster = null;
private Job firebaseInstanceIdJob;
private WindowDebouncer conversationListDebouncer; private WindowDebouncer conversationListDebouncer;
private HandlerThread conversationListHandlerThread; private HandlerThread conversationListHandlerThread;
private Handler conversationListHandler; private Handler conversationListHandler;
@@ -261,7 +261,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
// If the user account hasn't been created or onboarding wasn't finished then don't start // If the user account hasn't been created or onboarding wasn't finished then don't start
// the pollers // the pollers
if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) { if (textSecurePreferences.getLocalNumber() == null) {
return; return;
} }
@@ -451,6 +451,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
ClosedGroupPollerV2.getShared().start(); ClosedGroupPollerV2.getShared().start();
} }
public void retrieveUserProfile() {
setUpPollingIfNeeded();
if (poller != null) {
poller.retrieveUserProfile();
}
}
private void resubmitProfilePictureIfNeeded() { private void resubmitProfilePictureIfNeeded() {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture // Files expire on the file server after a while, so we simply re-upload the user's profile picture
// at a certain interval to ensure it's always available. // at a certain interval to ensure it's always available.
@@ -505,20 +512,11 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
/** /**
* Clear all local profile data and message history then restart the app after a brief delay. * Clear all local profile data and message history then restart the app after a brief delay.
* @param isMigratingToV2KeyPair whether we're upgrading to a more recent V2 key pair or not.
* @return true on success, false otherwise. * @return true on success, false otherwise.
*/ */
public boolean clearAllData(boolean isMigratingToV2KeyPair) { @SuppressLint("ApplySharedPref")
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) { public boolean clearAllData() {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this); TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database."); Log.d("Loki", "Failed to delete database.");

View File

@@ -15,7 +15,7 @@ import androidx.fragment.app.Fragment;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.onboarding.LandingActivity; import org.thoughtcrime.securesms.onboarding.landing.LandingActivity;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.Locale; import java.util.Locale;
@@ -125,12 +125,12 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
} }
private int getApplicationState(boolean locked) { private int getApplicationState(boolean locked) {
if (locked) { if (TextSecurePreferences.getLocalNumber(this) == null) {
return STATE_WELCOME_SCREEN;
} else if (locked) {
return STATE_PROMPT_PASSPHRASE; return STATE_PROMPT_PASSPHRASE;
} else if (DatabaseUpgradeActivity.isUpdate(this)) { } else if (DatabaseUpgradeActivity.isUpdate(this)) {
return STATE_UPGRADE_DATABASE; return STATE_UPGRADE_DATABASE;
} else if (!TextSecurePreferences.hasSeenWelcomeScreen(this)) {
return STATE_WELCOME_SCREEN;
} else { } else {
return STATE_NORMAL; return STATE_NORMAL;
} }

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms package org.thoughtcrime.securesms
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
@@ -15,7 +17,7 @@ import androidx.annotation.LayoutRes
import androidx.annotation.StringRes 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.text.HtmlCompat
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
@@ -80,6 +82,10 @@ class SessionDialogBuilder(val context: Context) {
}.let(topView::addView) }.let(topView::addView)
} }
fun htmlText(@StringRes id: Int, @StyleRes style: Int = 0, modify: TextView.() -> Unit = {}) {
text(HtmlCompat.fromHtml(context.resources.getString(id), 0))
}
fun view(view: View) = contentView.addView(view) fun view(view: View) = contentView.addView(view)
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView) fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
@@ -143,6 +149,20 @@ class SessionDialogBuilder(val context: Context) {
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply { build() }.show() SessionDialogBuilder(this).apply { build() }.show()
fun Context.showOpenUrlDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply {
title(R.string.urlOpen)
text(R.string.urlOpenBrowser)
build()
}.show()
fun Context.showOpenUrlDialog(url: String): AlertDialog =
showOpenUrlDialog {
okButton { openUrl(url) }
cancelButton()
}
fun Context.openUrl(url: String) = Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity)
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.show() SessionDialogBuilder(requireContext()).apply { build() }.show()

View File

@@ -445,7 +445,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
private fun getUserDisplayName(publicKey: String): String { private fun getUserDisplayName(publicKey: String): String {
val contact = val contact =
DatabaseComponent.get(this).sessionContactDatabase().getContactWithSessionID(publicKey) DatabaseComponent.get(this).sessionContactDatabase().getContactWithAccountID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }

View File

@@ -15,6 +15,7 @@ import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.avatars.ResourceContactPhoto import org.session.libsession.avatars.ResourceContactPhoto
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.AppTextSecurePreferences
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
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.Log
@@ -29,6 +30,8 @@ class ProfilePictureView @JvmOverloads constructor(
private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
private val glide: GlideRequests = GlideApp.with(this) private val glide: GlideRequests = GlideApp.with(this)
private val prefs = AppTextSecurePreferences(context)
private val userPublicKey = prefs.getLocalNumber()
var publicKey: String? = null var publicKey: String? = null
var displayName: String? = null var displayName: String? = null
var additionalPublicKey: String? = null var additionalPublicKey: String? = null
@@ -40,25 +43,28 @@ class ProfilePictureView @JvmOverloads constructor(
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
constructor(context: Context, sender: Recipient): this(context) { constructor(context: Context, sender: Recipient): this(context) {
update(sender) update(sender)
} }
// region Updating
fun update(recipient: Recipient) { fun update(recipient: Recipient) {
fun getUserDisplayName(publicKey: String): String { recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }
if (recipient.isClosedGroupRecipient) { fun update(
address: Address,
isClosedGroupRecipient: Boolean = false,
isOpenGroupInboxRecipient: Boolean = false
) {
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
?: publicKey
if (isClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase() val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(recipient.address.toGroupString(), true) .getGroupMemberAddresses(address.toGroupString(), true)
.sorted() .sorted()
.take(2) .take(2)
.toMutableList()
if (members.size <= 1) { if (members.size <= 1) {
publicKey = "" publicKey = ""
displayName = "" displayName = ""
@@ -72,13 +78,13 @@ class ProfilePictureView @JvmOverloads constructor(
additionalPublicKey = apk additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk) additionalDisplayName = getUserDisplayName(apk)
} }
} else if(recipient.isOpenGroupInboxRecipient) { } else if(isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize()) val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize())
this.publicKey = publicKey this.publicKey = publicKey
displayName = getUserDisplayName(publicKey) displayName = getUserDisplayName(publicKey)
additionalPublicKey = null additionalPublicKey = null
} else { } else {
val publicKey = recipient.address.toString() val publicKey = address.serialize()
this.publicKey = publicKey this.publicKey = publicKey
displayName = getUserDisplayName(publicKey) displayName = getUserDisplayName(publicKey)
additionalPublicKey = null additionalPublicKey = null

View File

@@ -49,7 +49,7 @@ class UserView : LinearLayout {
val isLocalUser = user.isLocalNumber val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String { fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you) if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }
val address = user.address.serialize() val address = user.address.serialize()

View File

@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.setThemedContent
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -45,7 +45,7 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
setUpToolbar() setUpToolbar()
binding.container.setContent { DisappearingMessagesScreen() } binding.container.setThemedContent { DisappearingMessagesScreen() }
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -87,8 +87,6 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
@Composable @Composable
fun DisappearingMessagesScreen() { fun DisappearingMessagesScreen() {
val uiState by viewModel.uiState.collectAsState(UiState()) val uiState by viewModel.uiState.collectAsState(UiState())
AppTheme {
DisappearingMessages(uiState, callbacks = viewModel) DisappearingMessages(uiState, callbacks = viewModel)
} }
} }
}

View File

@@ -11,22 +11,21 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.Callbacks
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.NoOpCallbacks
import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.OptionsCard
import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.RadioOption
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.extraSmall
import org.thoughtcrime.securesms.ui.fadingEdges import org.thoughtcrime.securesms.ui.fadingEdges
typealias ExpiryCallbacks = Callbacks<ExpiryMode> typealias ExpiryCallbacks = Callbacks<ExpiryMode>
@@ -40,33 +39,34 @@ fun DisappearingMessages(
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column(modifier = modifier.padding(horizontal = 32.dp)) { Column(modifier = modifier.padding(horizontal = LocalDimensions.current.margin)) {
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(bottom = 20.dp) .padding(bottom = 20.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.fadingEdges(scrollState), .fadingEdges(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) { ) {
state.cards.forEach { state.cards.forEach {
OptionsCard(it, callbacks) OptionsCard(it, callbacks)
} }
if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer), if (state.showGroupFooter) Text(
style = TextStyle( text = stringResource(R.string.activity_disappearing_messages_group_footer),
fontSize = 11.sp, style = extraSmall,
fontWeight = FontWeight(400), fontWeight = FontWeight(400),
color = Color(0xFFA1A2A1), color = LocalColors.current.textSecondary,
textAlign = TextAlign.Center), textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
} }
} }
if (state.showSetButton) OutlineButton( if (state.showSetButton) SlimOutlineButton(
GetString(R.string.disappearing_messages_set_button_title), stringResource(R.string.disappearing_messages_set_button_title),
modifier = Modifier modifier = Modifier
.contentDescription(GetString(R.string.AccessibilityId_set_button)) .contentDescription(R.string.AccessibilityId_set_button)
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
.padding(bottom = 20.dp), .padding(bottom = 20.dp),
onClick = callbacks::onSetClick onClick = callbacks::onSetClick

View File

@@ -7,19 +7,19 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.conversation.disappearingmessages.State import org.thoughtcrime.securesms.conversation.disappearingmessages.State
import org.thoughtcrime.securesms.ui.PreviewTheme import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
@Preview(widthDp = 450, heightDp = 700) @Preview(widthDp = 450, heightDp = 700)
@Composable @Composable
fun PreviewStates( fun PreviewStates(
@PreviewParameter(StatePreviewParameterProvider::class) state: State @PreviewParameter(StatePreviewParameterProvider::class) state: State
) { ) {
PreviewTheme(R.style.Classic_Dark) { PreviewTheme {
DisappearingMessages( DisappearingMessages(
state.toUiState() state.toUiState()
) )
@@ -51,9 +51,9 @@ class StatePreviewParameterProvider : PreviewParameterProvider<State> {
@Preview @Preview
@Composable @Composable
fun PreviewThemes( fun PreviewThemes(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) { ) {
PreviewTheme(themeResId) { PreviewTheme(colors) {
DisappearingMessages( DisappearingMessages(
State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(),
modifier = Modifier.size(400.dp, 600.dp) modifier = Modifier.size(400.dp, 600.dp)

View File

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

View File

@@ -1,103 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.databinding.ContactSectionHeaderBinding
import network.loki.messenger.databinding.ViewContactBinding
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideRequests
sealed class ContactListItem {
class Header(val name: String) : ContactListItem()
class Contact(val recipient: Recipient, val displayName: String) : ContactListItem()
}
class ContactListAdapter(
private val context: Context,
private val glide: GlideRequests,
private val listener: (Recipient) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var items = listOf<ContactListItem>()
set(value) {
field = value
notifyDataSetChanged()
}
private object ViewType {
const val Contact = 0
const val Header = 1
}
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName
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() { binding.profilePictureView.recycle() }
}
class HeaderViewHolder(
private val binding: ContactSectionHeaderBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContactListItem.Header) {
with(binding) {
label.text = item.name
}
}
}
override fun getItemCount(): Int { return items.size }
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
if (holder is ContactViewHolder) { holder.unbind() }
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is ContactListItem.Header -> ViewType.Header
else -> ViewType.Contact
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == ViewType.Contact) {
ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
} else {
HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
if (viewHolder is ContactViewHolder) {
viewHolder.bind(item as ContactListItem.Contact, glide, listener)
} else if (viewHolder is HeaderViewHolder) {
viewHolder.bind(item as ContactListItem.Header)
}
}
}

View File

@@ -1,10 +1,21 @@
package org.thoughtcrime.securesms.conversation.start package org.thoughtcrime.securesms.conversation.start
interface NewConversationDelegate { interface StartConversationDelegate {
fun onNewMessageSelected() fun onNewMessageSelected()
fun onCreateGroupSelected() fun onCreateGroupSelected()
fun onJoinCommunitySelected() fun onJoinCommunitySelected()
fun onContactSelected(address: String) fun onContactSelected(address: String)
fun onDialogBackPressed() fun onDialogBackPressed()
fun onDialogClosePressed() fun onDialogClosePressed()
fun onInviteFriend()
}
object NullStartConversationDelegate: StartConversationDelegate {
override fun onNewMessageSelected() {}
override fun onCreateGroupSelected() {}
override fun onJoinCommunitySelected() {}
override fun onContactSelected(address: String) {}
override fun onDialogBackPressed() {}
override fun onDialogClosePressed() {}
override fun onInviteFriend() {}
} }

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -15,13 +16,16 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.modifyLayoutParams
import org.thoughtcrime.securesms.conversation.start.home.StartConversationHomeFragment
import org.thoughtcrime.securesms.conversation.start.invitefriend.InviteFriendFragment
import org.thoughtcrime.securesms.conversation.start.newmessage.NewMessageFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dms.NewMessageFragment
import org.thoughtcrime.securesms.groups.CreateGroupFragment import org.thoughtcrime.securesms.groups.CreateGroupFragment
import org.thoughtcrime.securesms.groups.JoinCommunityFragment import org.thoughtcrime.securesms.groups.JoinCommunityFragment
@AndroidEntryPoint @AndroidEntryPoint
class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDelegate { class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate {
private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() } private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() }
@@ -35,38 +39,34 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
replaceFragment( replaceFragment(
fragment = NewConversationHomeFragment().apply { delegate = this@NewConversationFragment }, fragment = StartConversationHomeFragment().also { it.delegate.value = this },
fragmentKey = NewConversationHomeFragment::class.java.simpleName fragmentKey = StartConversationHomeFragment::class.java.simpleName
) )
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
val dialog = BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet) BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet).apply {
dialog.setOnShowListener { setOnShowListener { _ ->
val bottomSheetDialog = it as BottomSheetDialog findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.apply {
val parentLayout = modifyLayoutParams<LayoutParams> { height = defaultPeekHeight }
bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) }?.let { BottomSheetBehavior.from(it) }?.apply {
parentLayout?.let { it -> skipCollapsed = true
val behaviour = BottomSheetBehavior.from(it) state = BottomSheetBehavior.STATE_EXPANDED
val layoutParams = it.layoutParams
layoutParams.height = defaultPeekHeight
it.layoutParams = layoutParams
behaviour.state = BottomSheetBehavior.STATE_EXPANDED
} }
} }
return dialog
} }
override fun onNewMessageSelected() { override fun onNewMessageSelected() {
replaceFragment(NewMessageFragment().apply { delegate = this@NewConversationFragment }) replaceFragment(NewMessageFragment().also { it.delegate = this })
} }
override fun onCreateGroupSelected() { override fun onCreateGroupSelected() {
replaceFragment(CreateGroupFragment().apply { delegate = this@NewConversationFragment }) replaceFragment(CreateGroupFragment().also { it.delegate = this })
} }
override fun onJoinCommunitySelected() { override fun onJoinCommunitySelected() {
replaceFragment(JoinCommunityFragment().apply { delegate = this@NewConversationFragment }) replaceFragment(JoinCommunityFragment().also { it.delegate = this })
} }
override fun onContactSelected(address: String) { override fun onContactSelected(address: String) {
@@ -80,6 +80,10 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
childFragmentManager.popBackStack() childFragmentManager.popBackStack()
} }
override fun onInviteFriend() {
replaceFragment(InviteFriendFragment().also { it.delegate = this })
}
override fun onDialogClosePressed() { override fun onDialogClosePressed() {
dismiss() dismiss()
} }

View File

@@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentNewConversationHomeBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import javax.inject.Inject
@AndroidEntryPoint
class NewConversationHomeFragment : Fragment() {
private lateinit var binding: FragmentNewConversationHomeBinding
private val viewModel: NewConversationHomeViewModel by viewModels()
@Inject
lateinit var textSecurePreferences: TextSecurePreferences
lateinit var delegate: NewConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentNewConversationHomeBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
binding.createPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
binding.createClosedGroupButton.setOnClickListener { delegate.onCreateGroupSelected() }
binding.joinCommunityButton.setOnClickListener { delegate.onJoinCommunitySelected() }
val adapter = ContactListAdapter(requireContext(), GlideApp.with(requireContext())) {
delegate.onContactSelected(it.address.serialize())
}
val unknownSectionTitle = getString(R.string.new_conversation_unknown_contacts_section_title)
val recipients = viewModel.recipients.value?.filter { !it.isGroupRecipient && it.address.serialize() != textSecurePreferences.getLocalNumber()!! } ?: emptyList()
val contactGroups = recipients.map {
val sessionId = it.address.serialize()
val contact = DatabaseComponent.get(requireContext()).sessionContactDatabase().getContactWithSessionID(sessionId)
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
ContactListItem.Contact(it, displayName)
}.sortedBy { it.displayName }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
.toMutableMap()
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }
binding.contactsRecyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
binding.contactsRecyclerView.addItemDecoration(divider)
}
}

View File

@@ -1,35 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.ThreadDatabase
import javax.inject.Inject
@HiltViewModel
class NewConversationHomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() {
private val _recipients = MutableLiveData<List<Recipient>>()
val recipients: LiveData<List<Recipient>> = _recipients
init {
viewModelScope.launch {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val threads = mutableListOf<Recipient>()
while (true) {
threads += reader.next?.recipient ?: break
}
withContext(Dispatchers.Main) {
_recipients.value = threads
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.conversation.start.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.small
import org.thoughtcrime.securesms.ui.xl
@Composable
internal fun StartConversationScreen(
accountId: String,
delegate: StartConversationDelegate
) {
Column(modifier = Modifier.background(LocalColors.current.backgroundSecondary)) {
AppBar(stringResource(R.string.dialog_start_conversation_title), onClose = delegate::onDialogClosePressed)
Surface(
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
color = LocalColors.current.backgroundSecondary
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
ItemButton(
textId = R.string.messageNew,
icon = R.drawable.ic_message,
modifier = Modifier.contentDescription(R.string.AccessibilityId_new_direct_message),
onClick = delegate::onNewMessageSelected)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.activity_create_group_title,
icon = R.drawable.ic_group,
modifier = Modifier.contentDescription(R.string.AccessibilityId_create_group),
onClick = delegate::onCreateGroupSelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.dialog_join_community_title,
icon = R.drawable.ic_globe,
modifier = Modifier.contentDescription(R.string.AccessibilityId_join_community),
onClick = delegate::onJoinCommunitySelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.activity_settings_invite_button_title,
icon = R.drawable.ic_invite_friend,
Modifier.contentDescription(R.string.AccessibilityId_invite_friend_button),
onClick = delegate::onInviteFriend
)
Column(
modifier = Modifier
.padding(horizontal = LocalDimensions.current.margin)
.padding(top = LocalDimensions.current.itemSpacing)
.padding(bottom = LocalDimensions.current.margin)
) {
Text(stringResource(R.string.accountIdYours), style = xl)
Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsItemSpacing))
Text(
text = stringResource(R.string.qrYoursDescription),
color = LocalColors.current.textSecondary,
style = small
)
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
QrImage(
string = accountId,
Modifier.contentDescription(R.string.AccessibilityId_qr_code),
icon = R.drawable.session
)
}
}
}
}
}
@Preview
@Composable
private fun PreviewStartConversationScreen(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
StartConversationScreen(
accountId = "059287129387123",
NullStartConversationDelegate
)
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.conversation.start.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.collectAsState
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import org.thoughtcrime.securesms.ui.createThemedComposeView
import javax.inject.Inject
@AndroidEntryPoint
class StartConversationHomeFragment : Fragment() {
@Inject
lateinit var textSecurePreferences: TextSecurePreferences
var delegate = MutableStateFlow<StartConversationDelegate>(NullStartConversationDelegate)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
StartConversationScreen(
accountId = TextSecurePreferences.getLocalNumber(requireContext())!!,
delegate = delegate.collectAsState().value
)
}
}

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.conversation.start.invitefriend
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
import org.thoughtcrime.securesms.ui.components.border
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.small
@Composable
internal fun InviteFriend(
accountId: String,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
copyPublicKey: () -> Unit = {},
sendInvitation: () -> Unit = {},
) {
Column(modifier = Modifier.background(LocalColors.current.backgroundSecondary)) {
AppBar(stringResource(R.string.invite_a_friend), onBack = onBack, onClose = onClose)
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.itemSpacing),
) {
Text(
accountId,
modifier = Modifier
.contentDescription(R.string.AccessibilityId_account_id)
.fillMaxWidth()
.border()
.padding(LocalDimensions.current.smallMargin),
textAlign = TextAlign.Center,
style = base
)
Spacer(modifier = Modifier.height(LocalDimensions.current.xsItemSpacing))
Text(
stringResource(R.string.invite_your_friend_to_chat_with_you_on_session_by_sharing_your_account_id_with_them),
textAlign = TextAlign.Center,
style = small,
color = LocalColors.current.textSecondary,
modifier = Modifier.padding(horizontal = LocalDimensions.current.smallItemSpacing)
)
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
Row(horizontalArrangement = spacedBy(LocalDimensions.current.smallItemSpacing)) {
SlimOutlineButton(
stringResource(R.string.share),
modifier = Modifier
.weight(1f)
.contentDescription("Share button"),
onClick = sendInvitation
)
SlimOutlineCopyButton(
modifier = Modifier.weight(1f),
onClick = copyPublicKey
)
}
}
}
}
@Preview
@Composable
private fun PreviewInviteFriend() {
PreviewTheme {
InviteFriend("050000000")
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.conversation.start.invitefriend
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.preferences.copyPublicKey
import org.thoughtcrime.securesms.preferences.sendInvitationToUseSession
import org.thoughtcrime.securesms.ui.createThemedComposeView
@AndroidEntryPoint
class InviteFriendFragment : Fragment() {
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
InviteFriend(
TextSecurePreferences.getLocalNumber(LocalContext.current)!!,
onBack = { delegate.onDialogBackPressed() },
onClose = { delegate.onDialogClosePressed() },
copyPublicKey = requireContext()::copyPublicKey,
sendInvitation = requireContext()::sendInvitationToUseSession,
)
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
internal interface Callbacks {
fun onChange(value: String) {}
fun onContinue() {}
fun onScanQrCode(value: String) {}
}

View File

@@ -0,0 +1,135 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.small
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun NewMessage(
state: State,
qrErrors: Flow<String> = emptyFlow(),
callbacks: Callbacks = object: Callbacks {},
onClose: () -> Unit = {},
onBack: () -> Unit = {},
onHelp: () -> Unit = {},
) {
val pagerState = rememberPagerState { TITLES.size }
Column(modifier = Modifier.background(LocalColors.current.backgroundSecondary)) {
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
SessionTabRow(pagerState, TITLES)
HorizontalPager(pagerState) {
when (TITLES[it]) {
R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp)
R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = callbacks::onScanQrCode)
}
}
}
}
@Composable
private fun EnterAccountId(
state: State,
callbacks: Callbacks,
onHelp: () -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
) {
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.xxsMargin, vertical = LocalDimensions.current.xsMargin),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsMargin)
) {
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.smallMargin),
contentDescription = "Session id input box",
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
error = state.error?.string(),
isTextErrorColor = state.isTextErrorColor
)
BorderlessButtonWithIcon(
text = stringResource(R.string.messageNewDescription),
modifier = Modifier
.contentDescription(R.string.AccessibilityId_help_desk_link)
.padding(horizontal = LocalDimensions.current.margin)
.fillMaxWidth(),
style = small,
color = LocalColors.current.textSecondary,
iconRes = R.drawable.ic_circle_question_mark,
onClick = onHelp
)
}
AnimatedVisibility(state.isNextButtonVisible) {
PrimaryOutlineButton(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = LocalDimensions.current.largeMargin)
.fillMaxWidth()
.contentDescription(R.string.next),
onClick = callbacks::onContinue
) {
LoadingArcOr(state.loading) {
Text(stringResource(R.string.next))
}
}
}
}
}
@Preview
@Composable
private fun PreviewNewMessage(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
NewMessage(State("z"))
}
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.openUrl
import org.thoughtcrime.securesms.ui.createThemedComposeView
class NewMessageFragment : Fragment() {
private val viewModel: NewMessageViewModel by viewModels()
lateinit var delegate: StartConversationDelegate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.success.collect {
createPrivateChat(it.publicKey)
}
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
val uiState by viewModel.state.collectAsState(State())
NewMessage(
uiState,
viewModel.qrErrors,
viewModel,
onClose = { delegate.onDialogClosePressed() },
onBack = { delegate.onDialogBackPressed() },
onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") }
)
}
private fun createPrivateChat(hexEncodedPublicKey: String) {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false)
Intent(requireContext(), ConversationActivityV2::class.java).apply {
putExtra(ConversationActivityV2.ADDRESS, recipient.address)
setDataAndType(requireActivity().intent.data, requireActivity().intent.type)
putExtra(ConversationActivityV2.THREAD_ID, DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient))
}.let(requireContext()::startActivity)
delegate.onDialogClosePressed()
}
}

View File

@@ -0,0 +1,118 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.PublicKeyValidation
import org.session.libsignal.utilities.timeout
import org.thoughtcrime.securesms.ui.GetString
import java.util.concurrent.TimeoutException
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
internal class NewMessageViewModel @Inject constructor(
private val application: Application
): AndroidViewModel(application), Callbacks {
private val _state = MutableStateFlow(State())
val state = _state.asStateFlow()
private val _success = MutableSharedFlow<Success>()
val success get() = _success.asSharedFlow()
private val _qrErrors = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val qrErrors = _qrErrors.asSharedFlow()
private var loadOnsJob: Job? = null
override fun onChange(value: String) {
loadOnsJob?.cancel()
loadOnsJob = null
_state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) }
}
override fun onContinue() {
val idOrONS = state.value.newMessageIdOrOns
if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) {
onUnvalidatedPublicKey(publicKey = idOrONS)
} else {
resolveONS(ons = idOrONS)
}
}
override fun onScanQrCode(value: String) {
if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) {
onPublicKey(value)
} else {
_qrErrors.tryEmit(application.getString(R.string.this_qr_code_does_not_contain_an_account_id))
}
}
private fun resolveONS(ons: String) {
if (loadOnsJob?.isActive == true) return
// This could be an ONS name
_state.update { it.copy(isTextErrorColor = false, error = null, loading = true) }
loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
try {
val publicKey = SnodeAPI.getAccountID(ons).timeout(30_000).get()
if (isActive) onPublicKey(publicKey)
} catch (e: Exception) {
if (isActive) onError(e)
}
}
}
private fun onError(e: Exception) {
_state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) }
}
private fun onPublicKey(publicKey: String) {
_state.update { it.copy(loading = false) }
viewModelScope.launch { _success.emit(Success(publicKey)) }
}
private fun onUnvalidatedPublicKey(publicKey: String) {
if (PublicKeyValidation.hasValidPrefix(publicKey)) {
onPublicKey(publicKey)
} else {
_state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) }
}
}
private fun Exception.toMessage() = when (this) {
is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized)
is TimeoutException -> application.getString(R.string.onsErrorUnableToSearch)
else -> application.getString(R.string.fragment_enter_public_key_error_message)
}
}
internal data class State(
val newMessageIdOrOns: String = "",
val isTextErrorColor: Boolean = false,
val error: GetString? = null,
val loading: Boolean = false
) {
val isNextButtonVisible: Boolean get() = newMessageIdOrOns.isNotBlank()
}
internal data class Success(val publicKey: String)

View File

@@ -78,7 +78,7 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
@@ -112,7 +112,6 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
@@ -166,6 +165,7 @@ import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@@ -173,13 +173,12 @@ import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask
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.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.start
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@@ -240,12 +239,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
intent.getParcelableExtra<Address>(ADDRESS)?.let { it -> intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
threadId = threadDb.getThreadIdIfExistsFor(it.serialize()) threadId = threadDb.getThreadIdIfExistsFor(it.serialize())
if (threadId == -1L) { if (threadId == -1L) {
val sessionId = SessionId(it.serialize()) val accountId = AccountId(it.serialize())
val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1))
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { val address = if (accountId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let { storage.getOrCreateBlindedIdMapping(accountId.hexString, openGroup.server, openGroup.publicKey).accountId?.let {
fromSerialized(it) fromSerialized(it)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId) } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, accountId)
} else { } else {
it it
} }
@@ -733,12 +732,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
viewContainer.setTypists(recipients) viewContainer.setTypists(recipients)
} }
if (textSecurePreferences.isTypingIndicatorsEnabled()) { if (textSecurePreferences.isTypingIndicatorsEnabled()) {
binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() { binding.inputBar.addTextChangedListener {
ApplicationContext.getInstance(this).typingStatusSender.onTypingStarted(viewModel.threadId)
override fun onTextChanged(text: String?) {
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId)
} }
})
} }
} }
@@ -761,8 +757,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpBlockedBanner() { private fun setUpBlockedBanner() {
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
val sessionID = recipient.address.toString() val accountID = recipient.address.toString()
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = sessionContactDb.getContactWithAccountID(accountID)?.displayName(Contact.ContactContext.REGULAR) ?: accountID
binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding.blockedBanner.isVisible = recipient.isBlocked binding.blockedBanner.isVisible = recipient.isBlocked
binding.blockedBanner.setOnClickListener { viewModel.unblock() } binding.blockedBanner.setOnClickListener { viewModel.unblock() }
@@ -1135,8 +1131,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
override fun copySessionID(sessionId: String) { override fun copyAccountID(accountId: String) {
val clip = ClipData.newPlainText("Session ID", sessionId) val clip = ClipData.newPlainText("Account ID", accountId)
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
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()
@@ -1594,9 +1590,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val userPublicKey = textSecurePreferences.getLocalNumber() val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) {
val dialog = SendSeedDialog { sendTextOnlyMessage(true) } start<RecoveryPasswordActivity>()
dialog.show(supportFragmentManager, "Send Seed Dialog")
return null
} }
// Create the message // Create the message
val message = VisibleMessage().applyExpiryMode(viewModel.threadId) val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
@@ -2045,9 +2039,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode() endActionMode()
} }
override fun copySessionID(messages: Set<MessageRecord>) { override fun copyAccountID(messages: Set<MessageRecord>) {
val sessionID = messages.first().individualRecipient.address.toString() val accountID = messages.first().individualRecipient.address.toString()
val clip = ClipData.newPlainText("Session ID", sessionID) val clip = ClipData.newPlainText("Account ID", accountID)
val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
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()
@@ -2247,7 +2241,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems) ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems)
ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems) ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems)
ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems) ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems)
ConversationReactionOverlay.Action.COPY_SESSION_ID -> copySessionID(selectedItems) ConversationReactionOverlay.Action.COPY_ACCOUNT_ID -> copyAccountID(selectedItems)
} }
} }
} }

View File

@@ -70,7 +70,7 @@ class ConversationAdapter(
@WorkerThread @WorkerThread
private fun getSenderInfo(sender: String): Contact? { private fun getSenderInfo(sender: String): Contact? {
return contactDB.getContactWithSessionID(sender) return contactDB.getContactWithAccountID(sender)
} }
sealed class ViewType(val rawValue: Int) { sealed class ViewType(val rawValue: Int) {

View File

@@ -539,9 +539,9 @@ class ConversationReactionOverlay : FrameLayout {
if (!containsControlMessage && hasText) { if (!containsControlMessage && hasText) {
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 Account ID
if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && 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_account_id, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) })
} }
// Delete message // Delete message
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
@@ -689,7 +689,7 @@ class ConversationReactionOverlay : FrameLayout {
RESYNC, RESYNC,
DOWNLOAD, DOWNLOAD,
COPY_MESSAGE, COPY_MESSAGE,
COPY_SESSION_ID, COPY_ACCOUNT_ID,
VIEW_INFO, VIEW_INFO,
SELECT, SELECT,
DELETE, DELETE,

View File

@@ -18,7 +18,7 @@ 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
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@@ -77,7 +77,7 @@ class ConversationViewModel(
val blindedPublicKey: String? val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
} }
val isMessageRequestThread : Boolean val isMessageRequestThread : Boolean

View File

@@ -26,7 +26,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
val contact by lazy { val contact by lazy {
val senderId = recipient.address.serialize() val senderId = recipient.address.serialize()
// this dialog won't show for open group contacts // this dialog won't show for open group contacts
contactDatabase.getContactWithSessionID(senderId) contactDatabase.getContactWithAccountID(senderId)
?.displayName(Contact.ContactContext.REGULAR) ?.displayName(Contact.ContactContext.REGULAR)
} }

View File

@@ -28,7 +28,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,15 +37,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
@@ -59,7 +54,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselNextButton
import org.thoughtcrime.securesms.ui.CarouselPrevButton import org.thoughtcrime.securesms.ui.CarouselPrevButton
@@ -70,12 +64,19 @@ import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator
import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.ui.TitledText
import org.thoughtcrime.securesms.ui.blackAlpha40 import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.colorDestructive import org.thoughtcrime.securesms.ui.baseBold
import org.thoughtcrime.securesms.ui.destructiveButtonColors import org.thoughtcrime.securesms.ui.baseMonospace
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.color.blackAlpha40
import org.thoughtcrime.securesms.ui.color.destructiveButtonColors
import org.thoughtcrime.securesms.ui.setComposeContent
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -102,9 +103,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
ComposeView(this) setComposeContent { MessageDetailsScreen() }
.apply { setContent { MessageDetailsScreen() } }
.let(::setContentView)
lifecycleScope.launch { lifecycleScope.launch {
viewModel.eventFlow.collect { viewModel.eventFlow.collect {
@@ -121,7 +120,6 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable @Composable
private fun MessageDetailsScreen() { private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState() val state by viewModel.stateFlow.collectAsState()
AppTheme {
MessageDetails( MessageDetails(
state = state, state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
@@ -131,7 +129,6 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
) )
} }
}
private fun setResultAndFinish(code: Int) { private fun setResultAndFinish(code: Int) {
Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) } Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) }
@@ -155,12 +152,12 @@ fun MessageDetails(
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 16.dp), .padding(vertical = LocalDimensions.current.smallItemSpacing),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) { ) {
state.record?.let { message -> state.record?.let { message ->
AndroidView( AndroidView(
modifier = Modifier.padding(horizontal = 32.dp), modifier = Modifier.padding(horizontal = LocalDimensions.current.margin),
factory = { factory = {
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply { ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
bind( bind(
@@ -196,7 +193,7 @@ fun CellMetadata(
state.apply { state.apply {
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
CellWithPaddingAndMargin { CellWithPaddingAndMargin {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)) {
TitledText(sent) TitledText(sent)
TitledText(received) TitledText(received)
TitledErrorText(error) TitledErrorText(error)
@@ -222,23 +219,23 @@ fun CellButtons(
Cell { Cell {
Column { Column {
onReply?.let { onReply?.let {
ItemButton( LargeItemButton(
stringResource(R.string.reply), R.string.reply,
R.drawable.ic_message_details__reply, R.drawable.ic_message_details__reply,
onClick = it onClick = it
) )
Divider() Divider()
} }
onResend?.let { onResend?.let {
ItemButton( LargeItemButton(
stringResource(R.string.resend), R.string.resend,
R.drawable.ic_message_details__refresh, R.drawable.ic_message_details__refresh,
onClick = it onClick = it
) )
Divider() Divider()
} }
ItemButton( LargeItemButton(
stringResource(R.string.delete), R.string.delete,
R.drawable.ic_message_details__trash, R.drawable.ic_message_details__trash,
colors = destructiveButtonColors(), colors = destructiveButtonColors(),
onClick = onDelete onClick = onDelete
@@ -254,7 +251,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
val pagerState = rememberPagerState { attachments.size } val pagerState = rememberPagerState { attachments.size }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)) {
Row { Row {
CarouselPrevButton(pagerState) CarouselPrevButton(pagerState)
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
@@ -263,7 +260,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
ExpandButton( ExpandButton(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(8.dp) .padding(LocalDimensions.current.xxsItemSpacing)
) { onClick(pagerState.currentPage) } ) { onClick(pagerState.currentPage) }
} }
CarouselNextButton(pagerState) CarouselNextButton(pagerState)
@@ -316,9 +313,9 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
@Preview @Preview
@Composable @Composable
fun PreviewMessageDetails( fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int @PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) { ) {
PreviewTheme(themeResId) { PreviewTheme(colors) {
MessageDetails( MessageDetails(
state = MessageDetailsState( state = MessageDetailsState(
nonImageAttachmentFileDetails = listOf( nonImageAttachmentFileDetails = listOf(
@@ -341,10 +338,10 @@ fun PreviewMessageDetails(
fun FileDetails(fileDetails: List<TitledText>) { fun FileDetails(fileDetails: List<TitledText>) {
if (fileDetails.isEmpty()) return if (fileDetails.isEmpty()) return
CellWithPaddingAndMargin(padding = 0.dp) { Cell {
FlowRow( FlowRow(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp), modifier = Modifier.padding(horizontal = LocalDimensions.current.xsItemSpacing, vertical = LocalDimensions.current.itemSpacing),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) { ) {
fileDetails.forEach { fileDetails.forEach {
BoxWithConstraints { BoxWithConstraints {
@@ -352,7 +349,7 @@ fun FileDetails(fileDetails: List<TitledText>) {
it, it,
modifier = Modifier modifier = Modifier
.widthIn(min = maxWidth.div(2)) .widthIn(min = maxWidth.div(2))
.padding(horizontal = 12.dp) .padding(horizontal = LocalDimensions.current.xsItemSpacing)
.width(IntrinsicSize.Max) .width(IntrinsicSize.Max)
) )
} }
@@ -365,7 +362,8 @@ fun FileDetails(fileDetails: List<TitledText>) {
fun TitledErrorText(titledText: TitledText?) { fun TitledErrorText(titledText: TitledText?) {
TitledText( TitledText(
titledText, titledText,
valueStyle = LocalTextStyle.current.copy(color = colorDestructive) style = base,
color = LocalColors.current.danger
) )
} }
@@ -373,7 +371,7 @@ fun TitledErrorText(titledText: TitledText?) {
fun TitledMonospaceText(titledText: TitledText?) { fun TitledMonospaceText(titledText: TitledText?) {
TitledText( TitledText(
titledText, titledText,
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) style = baseMonospace
) )
} }
@@ -381,24 +379,25 @@ fun TitledMonospaceText(titledText: TitledText?) {
fun TitledText( fun TitledText(
titledText: TitledText?, titledText: TitledText?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
valueStyle: TextStyle = LocalTextStyle.current, style: TextStyle = base,
color: Color = Color.Unspecified
) { ) {
titledText?.apply { titledText?.apply {
TitledView(title, modifier) { TitledView(title, modifier) {
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth()) Text(
text,
style = style,
color = color,
modifier = Modifier.fillMaxWidth()
)
} }
} }
} }
@Composable @Composable
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) { fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsItemSpacing)) {
Title(title) Text(title.string(), style = baseBold)
content() content()
} }
} }
@Composable
fun Title(title: GetString) {
Text(title.string(), fontWeight = FontWeight.Bold)
}

View File

@@ -20,9 +20,9 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString() val accountID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
val explanation = resources.getString(R.string.dialog_blocked_explanation, name) val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)

View File

@@ -26,9 +26,9 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase @Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString() val accountID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
title(resources.getString(R.string.dialog_download_title, name)) title(resources.getString(R.string.dialog_download_title, name))
val explanation = resources.getString(R.string.dialog_download_explanation, name) val explanation = resources.getString(R.string.dialog_download_explanation, name)
@@ -42,8 +42,8 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
} }
private fun trust() { private fun trust() {
val sessionID = recipient.address.toString() val accountID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) ?: return val contact = contactDB.getContactWithAccountID(accountID) ?: return
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID) contactDB.setContactIsTrusted(contact, true, threadID)
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)

View File

@@ -2,12 +2,10 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.PointF import android.graphics.PointF
import android.net.Uri import android.net.Uri
import android.text.Editable import android.text.Editable
import android.text.InputType import android.text.InputType
import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -30,9 +28,8 @@ import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.addTextChangedListener
import org.thoughtcrime.securesms.util.contains import org.thoughtcrime.securesms.util.contains
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
// Enums to keep track of the state of our voice recording mechanism as the user can // Enums to keep track of the state of our voice recording mechanism as the user can
// manipulate the UI faster than we can setup & teardown. // manipulate the UI faster than we can setup & teardown.
@@ -43,16 +40,24 @@ enum class VoiceRecorderState {
ShuttingDownAfterRecord ShuttingDownAfterRecord
} }
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate, @SuppressLint("ClickableViewAccessibility")
class InputBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(
context,
attrs,
defStyleAttr
), InputBarEditTextDelegate,
QuoteViewDelegate,
LinkPreviewDraftViewDelegate,
TextView.OnEditorActionListener { TextView.OnEditorActionListener {
private lateinit var binding: ViewInputBarBinding
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private var binding: ViewInputBarBinding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
private val vMargin by lazy { toDp(4, resources) }
private val minHeight by lazy { toPx(56, resources) }
private var linkPreviewDraftView: LinkPreviewDraftView? = null private var linkPreviewDraftView: LinkPreviewDraftView? = null
private var quoteView: QuoteView? = null private var quoteView: QuoteView? = null
var delegate: InputBarDelegate? = null var delegate: InputBarDelegate? = null
var additionalContentHeight = 0
var quote: MessageRecord? = null var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null var linkPreview: LinkPreview? = null
var showInput: Boolean = true var showInput: Boolean = true
@@ -65,7 +70,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
} }
var text: String var text: String
get() { return binding.inputBarEditText.text?.toString() ?: "" } get() = binding.inputBarEditText.text?.toString() ?: ""
set(value) { binding.inputBarEditText.setText(value) } set(value) { binding.inputBarEditText.setText(value) }
// Keep track of when the user pressed the record voice message button, the duration that // Keep track of when the user pressed the record voice message button, the duration that
@@ -74,21 +79,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
var voiceMessageDurationMS = 0L var voiceMessageDurationMS = 0L
var voiceRecorderState = VoiceRecorderState.Idle var voiceRecorderState = VoiceRecorderState.Idle
val attachmentButtonsContainerHeight: Int private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)}
get() = binding.attachmentsButtonContainer.height val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)}
private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)}
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} } init {
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} }
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} }
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
@SuppressLint("ClickableViewAccessibility")
private fun initialize() {
binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
// Attachments button // Attachments button
binding.attachmentsButtonContainer.addView(attachmentsButton) binding.attachmentsButtonContainer.addView(attachmentsButton)
attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
@@ -107,6 +102,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
// `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress! // `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress!
microphoneButton.setOnTouchListener(object : OnTouchListener { microphoneButton.setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean { override fun onTouch(v: View, event: MotionEvent): Boolean {
if (!microphoneButton.snIsEnabled) return true
// We only handle single finger touch events so just consume the event and bail if there are more // We only handle single finger touch events so just consume the event and bail if there are more
if (event.pointerCount > 1) return true if (event.pointerCount > 1) return true
@@ -157,12 +153,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.setOnEditorActionListener(this) binding.inputBarEditText.setOnEditorActionListener(this)
if (TextSecurePreferences.isEnterSendsEnabled(context)) { if (TextSecurePreferences.isEnterSendsEnabled(context)) {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND
binding.inputBarEditText.inputType = binding.inputBarEditText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} else { } else {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
binding.inputBarEditText.inputType = binding.inputBarEditText.inputType =
binding.inputBarEditText.inputType or binding.inputBarEditText.inputType
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} }
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
@@ -179,9 +174,6 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
return false return false
} }
// endregion
// region Updating
override fun inputBarEditTextContentChanged(text: CharSequence) { override fun inputBarEditTextContentChanged(text: CharSequence) {
microphoneButton.isVisible = text.trim().isEmpty() microphoneButton.isVisible = text.trim().isEmpty()
sendButton.isVisible = microphoneButton.isGone sendButton.isVisible = microphoneButton.isGone
@@ -276,19 +268,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls } setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls }
} }
fun addTextChangedListener(textWatcher: TextWatcher) { fun addTextChangedListener(listener: (String) -> Unit) {
binding.inputBarEditText.addTextChangedListener(textWatcher) binding.inputBarEditText.addTextChangedListener(listener)
} }
fun setInputBarEditableFactory(factory: Editable.Factory) { fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setEditableFactory(factory) binding.inputBarEditText.setEditableFactory(factory)
} }
// endregion
} }
interface InputBarDelegate { interface InputBarDelegate {
fun inputBarEditTextContentChanged(newContent: CharSequence) fun inputBarEditTextContentChanged(newContent: CharSequence)
fun toggleAttachmentOptions() fun toggleAttachmentOptions()
fun showVoiceMessageUI() fun showVoiceMessageUI()

View File

@@ -117,9 +117,9 @@ class MentionViewModel(
contactDatabase.getContacts(memberIDs).map { contact -> contactDatabase.getContacts(memberIDs).map { contact ->
Member( Member(
publicKey = contact.sessionID, publicKey = contact.accountID,
name = contact.displayName(contactContext).orEmpty(), name = contact.displayName(contactContext).orEmpty(),
isModerator = contact.sessionID in moderatorIDs, isModerator = contact.accountID in moderatorIDs,
) )
} }
} }

View File

@@ -6,7 +6,7 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.AccountId
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.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
@@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
fun userCanDeleteSelectedItems(): Boolean { fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
@@ -63,7 +63,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_ban_and_delete_all).isVisible = userCanBanSelectedUsers() menu.findItem(R.id.menu_context_ban_and_delete_all).isVisible = userCanBanSelectedUsers()
// Copy message text // Copy message text
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID // Copy Account ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible = menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail // Message detail
@@ -91,7 +91,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) R.id.menu_context_ban_user -> delegate?.banUser(selectedItems)
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems) R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) R.id.menu_context_copy_public_key -> delegate?.copyAccountID(selectedItems)
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems) R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
@@ -115,7 +115,7 @@ interface ConversationActionModeCallbackDelegate {
fun banUser(messages: Set<MessageRecord>) fun banUser(messages: Set<MessageRecord>)
fun banAndDeleteAll(messages: Set<MessageRecord>) fun banAndDeleteAll(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>) fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>) fun copyAccountID(messages: Set<MessageRecord>)
fun resyncMessage(messages: Set<MessageRecord>) fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>) fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>) fun showMessageDetail(messages: Set<MessageRecord>)

View File

@@ -57,9 +57,9 @@ object ConversationMenuHelper {
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu) inflater.inflate(R.menu.menu_conversation_expiration, menu)
} }
// One-on-one chat menu allows copying the session id // One-on-one chat menu allows copying the account id
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
inflater.inflate(R.menu.menu_conversation_copy_session_id, menu) inflater.inflate(R.menu.menu_conversation_copy_account_id, menu)
} }
// One-on-one chat menu (options that should only be present for one-on-one chats) // One-on-one chat menu (options that should only be present for one-on-one chats)
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
@@ -135,7 +135,7 @@ object ConversationMenuHelper {
R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block -> { block(context, thread, deleteThread = false) }
R.id.menu_block_delete -> { blockAndDelete(context, thread) } R.id.menu_block_delete -> { blockAndDelete(context, thread) }
R.id.menu_copy_session_id -> { copySessionID(context, thread) } R.id.menu_copy_account_id -> { copyAccountID(context, thread) }
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) }
R.id.menu_edit_group -> { editClosedGroup(context, thread) } R.id.menu_edit_group -> { editClosedGroup(context, thread) }
R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
@@ -246,10 +246,10 @@ object ConversationMenuHelper {
listener.block(deleteThread = true) listener.block(deleteThread = true)
} }
private fun copySessionID(context: Context, thread: Recipient) { private fun copyAccountID(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return } if (!thread.isContactRecipient) { return }
val listener = context as? ConversationMenuListener ?: return val listener = context as? ConversationMenuListener ?: return
listener.copySessionID(thread.address.toString()) listener.copyAccountID(thread.address.toString())
} }
private fun copyOpenGroupUrl(context: Context, thread: Recipient) { private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
@@ -271,8 +271,8 @@ object ConversationMenuHelper {
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context) val accountID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == sessionID } val isCurrentUserAdmin = admins.any { it.toString() == accountID }
val message = if (isCurrentUserAdmin) { val message = if (isCurrentUserAdmin) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone." "Because you are the creator of this group it will be deleted for everyone. This cannot be undone."
} else { } else {
@@ -325,7 +325,7 @@ object ConversationMenuHelper {
interface ConversationMenuListener { interface ConversationMenuListener {
fun block(deleteThread: Boolean = false) fun block(deleteThread: Boolean = false)
fun unblock() fun unblock()
fun copySessionID(sessionId: String) fun copyAccountID(accountId: String)
fun copyOpenGroupUrl(thread: Recipient) fun copyOpenGroupUrl(thread: Recipient)
fun showDisappearingMessages(thread: Recipient) fun showDisappearingMessages(thread: Recipient)
} }

View File

@@ -70,7 +70,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long, isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long,
isOriginalMissing: Boolean, glide: GlideRequests) { isOriginalMissing: Boolean, glide: GlideRequests) {
// Author // Author
val author = contactDb.getContactWithSessionID(authorPublicKey) val author = contactDb.getContactWithAccountID(authorPublicKey)
val localNumber = TextSecurePreferences.getLocalNumber(context) val localNumber = TextSecurePreferences.getLocalNumber(context)
val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber

View File

@@ -143,7 +143,7 @@ class VisibleMessageView : FrameLayout {
glide: GlideRequests = GlideApp.with(this), glide: GlideRequests = GlideApp.with(this),
searchQuery: String? = null, searchQuery: String? = null,
contact: Contact? = null, contact: Contact? = null,
senderSessionID: String, senderAccountID: String,
lastSeen: Long, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null, delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
@@ -178,30 +178,30 @@ class VisibleMessageView : FrameLayout {
if (isGroupThread && !message.isOutgoing) { if (isGroupThread && !message.isOutgoing) {
if (isEndOfMessageCluster) { if (isEndOfMessageCluster) {
binding.profilePictureView.publicKey = senderSessionID binding.profilePictureView.publicKey = senderAccountID
binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener { binding.profilePictureView.setOnClickListener {
if (thread.isCommunityRecipient) { 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(senderAccountID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon // TODO: support v2 soon
val intent = Intent(context, ConversationActivityV2::class.java) val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderAccountID))
context.startActivity(intent) context.startActivity(intent)
} }
} else { } else {
maybeShowUserDetails(senderSessionID, threadID) maybeShowUserDetails(senderAccountID, threadID)
} }
} }
if (thread.isCommunityRecipient) { 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
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) { if (IdPrefix.fromValue(senderAccountID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID blindedPublicKey = senderAccountID
} else { } else {
standardPublicKey = senderSessionID standardPublicKey = senderAccountID
} }
val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey) val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey)
binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator
@@ -211,7 +211,7 @@ class VisibleMessageView : FrameLayout {
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext = val contactContext =
if (thread.isCommunityRecipient) 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) ?: senderAccountID
// Unread marker // Unread marker
val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing

View File

@@ -62,7 +62,7 @@ object MentionUtilities {
val userDisplayName: String? = if (isYou) { val userDisplayName: String? = if (isYou) {
context.getString(R.string.MessageRecord_you) context.getString(R.string.MessageRecord_you)
} else { } else {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
@Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR
contact?.displayName(context) ?: truncateIdForDisplay(publicKey) contact?.displayName(context) ?: truncateIdForDisplay(publicKey)
} }
@@ -157,7 +157,7 @@ object MentionUtilities {
} }
private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean {
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey
} }
} }

View File

@@ -31,7 +31,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) :
private fun readBlindedIdMapping(cursor: Cursor): BlindedIdMapping { private fun readBlindedIdMapping(cursor: Cursor): BlindedIdMapping {
return BlindedIdMapping( return BlindedIdMapping(
blindedId = cursor.getString(cursor.getColumnIndexOrThrow(BLINDED_PK)), blindedId = cursor.getString(cursor.getColumnIndexOrThrow(BLINDED_PK)),
sessionId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)), accountId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)),
serverUrl = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_URL)), serverUrl = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_URL)),
serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_PK)), serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_PK)),
) )
@@ -58,7 +58,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) :
try { try {
val values = ContentValues().apply { val values = ContentValues().apply {
put(BLINDED_PK, blindedIdMapping.blindedId) put(BLINDED_PK, blindedIdMapping.blindedId)
put(SERVER_PK, blindedIdMapping.sessionId) put(SERVER_PK, blindedIdMapping.accountId)
put(SERVER_URL, blindedIdMapping.serverUrl) put(SERVER_URL, blindedIdMapping.serverUrl)
put(SERVER_PK, blindedIdMapping.serverId) put(SERVER_PK, blindedIdMapping.serverId)
} }

View File

@@ -6,7 +6,7 @@ import android.database.Cursor
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.json.JSONArray import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -15,7 +15,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
companion object { companion object {
private const val sessionContactTable = "session_contact_database" private const val sessionContactTable = "session_contact_database"
const val sessionID = "session_id" const val accountID = "session_id"
const val name = "name" const val name = "name"
const val nickname = "nickname" const val nickname = "nickname"
const val profilePictureURL = "profile_picture_url" const val profilePictureURL = "profile_picture_url"
@@ -25,7 +25,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
const val isTrusted = "is_trusted" const val isTrusted = "is_trusted"
@JvmStatic val createSessionContactTableCommand = @JvmStatic val createSessionContactTableCommand =
"CREATE TABLE $sessionContactTable " + "CREATE TABLE $sessionContactTable " +
"($sessionID STRING PRIMARY KEY, " + "($accountID STRING PRIMARY KEY, " +
"$name TEXT DEFAULT NULL, " + "$name TEXT DEFAULT NULL, " +
"$nickname TEXT DEFAULT NULL, " + "$nickname TEXT DEFAULT NULL, " +
"$profilePictureURL TEXT DEFAULT NULL, " + "$profilePictureURL TEXT DEFAULT NULL, " +
@@ -35,19 +35,19 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
"$isTrusted INTEGER DEFAULT 0);" "$isTrusted INTEGER DEFAULT 0);"
} }
fun getContactWithSessionID(sessionID: String): Contact? { fun getContactWithAccountID(accountID: String): Contact? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(sessionContactTable, "${Companion.sessionID} = ?", arrayOf( sessionID )) { cursor -> return database.get(sessionContactTable, "${Companion.accountID} = ?", arrayOf( accountID )) { cursor ->
contactFromCursor(cursor) contactFromCursor(cursor)
} }
} }
fun getContacts(sessionIDs: Collection<String>): List<Contact> { fun getContacts(accountIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll( return database.getAll(
sessionContactTable, sessionContactTable,
"$sessionID IN (SELECT value FROM json_each(?))", "$accountID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString()) arrayOf(JSONArray(accountIDs).toString())
) { cursor -> contactFromCursor(cursor) } ) { cursor -> contactFromCursor(cursor) }
} }
@@ -56,8 +56,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
return database.getAll(sessionContactTable, null, null) { cursor -> return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor) contactFromCursor(cursor)
}.filter { contact -> }.filter { contact ->
val sessionId = SessionId(contact.sessionID) contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD
sessionId.prefix == IdPrefix.STANDARD
}.toSet() }.toSet()
} }
@@ -65,7 +64,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues(1) val contentValues = ContentValues(1)
contentValues.put(Companion.isTrusted, if (isTrusted) 1 else 0) contentValues.put(Companion.isTrusted, if (isTrusted) 1 else 0)
database.update(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) database.update(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
if (threadID >= 0) { if (threadID >= 0) {
notifyConversationListeners(threadID) notifyConversationListeners(threadID)
} }
@@ -75,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
fun setContact(contact: Contact) { fun setContact(contact: Contact) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues(8) val contentValues = ContentValues(8)
contentValues.put(sessionID, contact.sessionID) contentValues.put(accountID, contact.accountID)
contentValues.put(name, contact.name) contentValues.put(name, contact.name)
contentValues.put(nickname, contact.nickname) contentValues.put(nickname, contact.nickname)
contentValues.put(profilePictureURL, contact.profilePictureURL) contentValues.put(profilePictureURL, contact.profilePictureURL)
@@ -85,13 +84,13 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
} }
contentValues.put(threadID, contact.threadID) contentValues.put(threadID, contact.threadID)
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0) contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
notifyConversationListListeners() notifyConversationListListeners()
} }
fun contactFromCursor(cursor: Cursor): Contact { fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
val contact = Contact(sessionID) val contact = Contact(accountID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))

View File

@@ -6,6 +6,7 @@ import java.security.MessageDigest
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserGroupsConfig
@@ -57,7 +58,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
@@ -68,6 +69,7 @@ import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH
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.libsession.utilities.recipients.Recipient.DisappearingState import org.session.libsession.utilities.recipients.Recipient.DisappearingState
@@ -110,12 +112,12 @@ open class Storage(
if (address.isGroup) { if (address.isGroup) {
val groups = configFactory.userGroups ?: return val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) { if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString()) val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) { if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId)
groups.set(legacyGroup) groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy(
lastRead = SnodeAPI.nowWithOffset, lastRead = SnodeAPI.nowWithOffset,
) )
volatile.set(newVolatileParams) volatile.set(newVolatileParams)
@@ -126,16 +128,16 @@ open class Storage(
} }
} else if (address.isContact) { } else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB // don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) { if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) { contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE priority = PRIORITY_VISIBLE
} }
} else { } else {
val userProfile = configFactory.user ?: return val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE) userProfile.setNtsPriority(PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
} }
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
@@ -148,16 +150,16 @@ open class Storage(
if (address.isGroup) { if (address.isGroup) {
val groups = configFactory.userGroups ?: return val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) { if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId) volatile.eraseLegacyClosedGroup(accountId)
groups.eraseLegacyGroup(sessionId) groups.eraseLegacyGroup(accountId)
} else if (address.isCommunity) { } 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")
} }
} else { } else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize()) volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) { if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return val contacts = configFactory.contacts ?: return
@@ -264,10 +266,8 @@ open class Storage(
} }
// otherwise recipient is one to one // otherwise recipient is one to one
recipient.isContactRecipient -> { recipient.isContactRecipient -> {
// don't process non-standard session IDs though // don't process non-standard account IDs though
val sessionId = SessionId(recipient.address.serialize()) if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return
if (sessionId.prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(recipient.address.serialize()) config.getOrConstructOneToOne(recipient.address.serialize())
} }
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
@@ -298,8 +298,8 @@ open class Storage(
var messageID: Long? = null var messageID: Long? = null
val senderAddress = fromSerialized(message.sender!!) val senderAddress = fromSerialized(message.sender!!)
val isUserSender = (message.sender!! == getUserPublicKey()) val isUserSender = (message.sender!! == getUserPublicKey())
val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let { getOpenGroup(it)?.publicKey } val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey
?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false ?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val group: Optional<SignalServiceGroup> = when { val group: Optional<SignalServiceGroup> = when {
openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT))
groupPublicKey != null -> { groupPublicKey != null -> {
@@ -476,9 +476,11 @@ open class Storage(
val name = userProfile.getName() ?: return val name = userProfile.getName() ?: return
val userPic = userProfile.getPic() val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name) name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let {
profileManager.setName(context, recipient, name) TextSecurePreferences.setProfileName(context, it)
profileManager.setName(context, recipient, it)
if (it != name) userProfile.setName(it)
} }
// Update profile picture // Update profile picture
@@ -537,7 +539,7 @@ open class Storage(
val extracted = convos.all() val extracted = convos.all()
for (conversation in extracted) { for (conversation in extracted) {
val threadId = when (conversation) { val threadId = when (conversation) {
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
} }
@@ -568,7 +570,7 @@ open class Storage(
val existingJoinUrls = existingCommunities.values.map { it.joinURL } val existingJoinUrls = existingCommunities.values.map { it.joinURL }
val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId } val lgcIds = lgc.map { it.accountId }
val toDeleteClosedGroups = existingClosedGroups.filter { group -> val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
} }
@@ -602,8 +604,8 @@ open class Storage(
} }
for (group in lgc) { for (group in lgc) {
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) val groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) { if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) { if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
@@ -622,19 +624,19 @@ open class Storage(
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true) setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for // Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId) addClosedGroupPublicKey(group.accountId)
// Store the encryption key pair // Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) addClosedGroupEncryptionKeyPair(keyPair, group.accountId, SnodeAPI.nowWithOffset)
// Notify the PN server // Notify the PN server
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
// Notify the user // Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp) threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update // Don't create config group here, it's from a config update
// Start polling // Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId) ClosedGroupPollerV2.shared.startPolling(group.accountId)
} }
getThreadId(Address.fromSerialized(groupId))?.let { getThreadId(Address.fromSerialized(groupId))?.let {
setExpirationConfiguration( setExpirationConfiguration(
@@ -935,10 +937,10 @@ open class Storage(
groupVolatileConfig.lastRead = formationTimestamp groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig) volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo( val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey, accountId = groupPublicKey,
name = name, name = name,
members = members, members = members,
priority = ConfigBase.PRIORITY_VISIBLE, priority = PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(), encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = expirationTimer.toLong(), disappearingTimer = expirationTimer.toLong(),
@@ -972,7 +974,7 @@ open class Storage(
members = membersMap, members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(), encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE,
disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
joinedAt = (existingGroup.formationTimestamp / 1000L) joinedAt = (existingGroup.formationTimestamp / 1000L)
) )
@@ -1177,8 +1179,8 @@ open class Storage(
return threadId ?: -1 return threadId ?: -1
} }
override fun getContactWithSessionID(sessionID: String): Contact? { override fun getContactWithAccountID(accountID: String): Contact? {
return DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(sessionID) return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID)
} }
override fun getAllContacts(): Set<Contact> { override fun getAllContacts(): Set<Contact> {
@@ -1187,7 +1189,7 @@ open class Storage(
override fun setContact(contact: Contact) { override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID) val address = fromSerialized(contact.accountID)
if (!getRecipientApproved(address)) return if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact) val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false) val recipient = Recipient.from(context, address, false)
@@ -1205,8 +1207,8 @@ open class Storage(
override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) { override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact -> val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id) val id = AccountId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null } id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null }
} }
val profileManager = SSKEnvironment.shared.profileManager val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact -> moreContacts.forEach { contact ->
@@ -1258,8 +1260,8 @@ open class Storage(
val threadDatabase = DatabaseComponent.get(context).threadDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase()
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact -> val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.publicKey) val id = AccountId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null } id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null }
} }
for (contact in moreContacts) { for (contact in moreContacts) {
val address = fromSerialized(contact.publicKey) val address = fromSerialized(contact.publicKey)
@@ -1326,25 +1328,25 @@ open class Storage(
val threadRecipient = getRecipientForThread(threadID) ?: return val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) { if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return val user = configFactory.user ?: return
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE) user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) { } else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) { contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
} }
} else if (threadRecipient.isGroupRecipient) { } else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) { if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) threadRecipient.address.serialize()
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( .let(GroupUtil::doubleDecodeGroupId)
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE .let(groups::getOrConstructLegacyGroupInfo)
) .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
groups.set(newGroupInfo) .let(groups::set)
} else if (threadRecipient.isCommunityRecipient) { } 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 (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
) )
groups.set(newGroupInfo) groups.set(newGroupInfo)
} }
@@ -1493,14 +1495,8 @@ open class Storage(
val address = recipient.address.serialize() val address = recipient.address.serialize()
val blindedId = when { val blindedId = when {
recipient.isGroupRecipient -> null recipient.isGroupRecipient -> null
recipient.isOpenGroupInboxRecipient -> { recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address)
GroupUtil.getDecodedOpenGroupInboxSessionId(address) else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED }
}
else -> {
if (SessionId(address).prefix == IdPrefix.BLINDED) {
address
} else null
}
} ?: continue } ?: continue
mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let { mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let {
mappings[address] = it mappings[address] = it
@@ -1508,18 +1504,18 @@ open class Storage(
} }
} }
for (mapping in mappings) { for (mapping in mappings) {
if (!SodiumUtilities.sessionId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { if (!SodiumUtilities.accountId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) {
continue continue
} }
mappingDb.addBlindedIdMapping(mapping.value.copy(sessionId = senderPublicKey)) mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey))
val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false))
mmsDb.updateThreadId(blindedThreadId, threadId) mmsDb.updateThreadId(blindedThreadId, threadId)
smsDb.updateThreadId(blindedThreadId, threadId) smsDb.updateThreadId(blindedThreadId, threadId)
threadDB.deleteConversation(blindedThreadId) threadDB.deleteConversation(blindedThreadId)
} }
recipientDb.setApproved(sender, true) setRecipientApproved(sender, true)
recipientDb.setApprovedMe(sender, true) setRecipientApprovedMe(sender, true)
val message = IncomingMediaMessage( val message = IncomingMediaMessage(
sender.address, sender.address,
response.sentTimestamp!!, response.sentTimestamp!!,
@@ -1617,20 +1613,20 @@ open class Storage(
): BlindedIdMapping { ): BlindedIdMapping {
val db = DatabaseComponent.get(context).blindedIdMappingDatabase() val db = DatabaseComponent.get(context).blindedIdMappingDatabase()
val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey) val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey)
if (mapping.sessionId != null) { if (mapping.accountId != null) {
return mapping return mapping
} }
getAllContacts().forEach { contact -> getAllContacts().forEach { contact ->
val sessionId = SessionId(contact.sessionID) val accountId = AccountId(contact.accountID)
if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) { if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) {
val contactMapping = mapping.copy(sessionId = sessionId.hexString) val contactMapping = mapping.copy(accountId = accountId.hexString)
db.addBlindedIdMapping(contactMapping) db.addBlindedIdMapping(contactMapping)
return contactMapping return contactMapping
} }
} }
db.getBlindedIdMappingsExceptFor(server).forEach { db.getBlindedIdMappingsExceptFor(server).forEach {
if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) { if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) {
val otherMapping = mapping.copy(sessionId = it.sessionId) val otherMapping = mapping.copy(accountId = it.accountId)
db.addBlindedIdMapping(otherMapping) db.addBlindedIdMapping(otherMapping)
return otherMapping return otherMapping
} }
@@ -1746,7 +1742,7 @@ open class Storage(
if (recipient.isClosedGroupRecipient) { if (recipient.isClosedGroupRecipient) {
val userGroups = configFactory.userGroups ?: return val userGroups = configFactory.userGroups ?: return
val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address) val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address)
val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
userGroups.set(groupInfo) userGroups.set(groupInfo)
@@ -1807,3 +1803,11 @@ open class Storage(
} }
} }
} }
/**
* Truncate a string to a specified number of bytes
*
* This could split multi-byte characters/emojis.
*/
private fun String.truncate(sizeInBytes: Int): String =
toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
fun Context.threadDatabase() = DatabaseComponent.get(this).threadDatabase()

View File

@@ -1,101 +0,0 @@
package org.thoughtcrime.securesms.dms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.util.QRCodeUtilities
import org.thoughtcrime.securesms.util.hideKeyboard
import org.thoughtcrime.securesms.util.toPx
class EnterPublicKeyFragment : Fragment() {
private lateinit var binding: FragmentEnterPublicKeyBinding
var delegate: EnterPublicKeyDelegate? = null
private val hexEncodedPublicKey: String
get() {
return TextSecurePreferences.getLocalNumber(requireContext())!!
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard
publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT)
publicKeyEditText.setOnEditorActionListener { v, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_DONE) {
v.hideKeyboard()
handlePublicKeyEntered()
true
} else {
false
}
}
publicKeyEditText.addTextChangedListener { text -> createPrivateChatButton.isVisible = !text.isNullOrBlank() }
publicKeyEditText.setOnFocusChangeListener { _, hasFocus -> optionalContentContainer.isVisible = !hasFocus }
mainContainer.setOnTouchListener { _, _ ->
binding.optionalContentContainer.isVisible = true
publicKeyEditText.clearFocus()
publicKeyEditText.hideKeyboard()
true
}
val size = toPx(228, resources)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, isInverted = false, hasTransparentBackground = false)
qrCodeImageView.setImageBitmap(qrCode)
publicKeyTextView.text = hexEncodedPublicKey
publicKeyTextView.setOnCreateContextMenuListener { contextMenu, view, _ ->
contextMenu.add(0, view.id, 0, R.string.copy).setOnMenuItemClickListener {
copyPublicKey()
true
}
}
copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() }
createPrivateChatButton.setOnClickListener { handlePublicKeyEntered(); publicKeyEditText.hideKeyboard() }
}
}
private fun copyPublicKey() {
val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
private fun sharePublicKey() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey)
intent.type = "text/plain"
startActivity(intent)
}
private fun handlePublicKeyEntered() {
val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim()?.toString()
if (hexEncodedPublicKey.isNullOrEmpty()) return
delegate?.handlePublicKeyEntered(hexEncodedPublicKey)
}
}
fun interface EnterPublicKeyDelegate {
fun handlePublicKeyEntered(publicKey: String)
}

View File

@@ -1,107 +0,0 @@
package org.thoughtcrime.securesms.dms
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentNewMessageBinding
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@AndroidEntryPoint
class NewMessageFragment : Fragment() {
private lateinit var binding: FragmentNewMessageBinding
lateinit var delegate: NewConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentNewMessageBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)}
val adapter = NewMessageFragmentAdapter(
parentFragment = this,
enterPublicKeyDelegate = onsOrPkDelegate,
scanPublicKeyDelegate = onsOrPkDelegate
)
binding.viewPager.adapter = adapter
val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos ->
tab.text = when (pos) {
0 -> getString(R.string.activity_create_private_chat_enter_session_id_tab_title)
1 -> getString(R.string.activity_create_private_chat_scan_qr_code_tab_title)
else -> throw IllegalStateException()
}
}
mediator.attach()
}
private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) {
if (PublicKeyValidation.isValid(onsNameOrPublicKey)) {
createPrivateChat(onsNameOrPublicKey)
} else {
// This could be an ONS name
showLoader()
SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey ->
hideLoader()
createPrivateChat(hexEncodedPublicKey)
}.failUi { exception ->
hideLoader()
var message = getString(R.string.fragment_enter_public_key_error_message)
exception.localizedMessage?.let {
message = it
}
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
}
private fun createPrivateChat(hexEncodedPublicKey: String) {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false)
val intent = Intent(requireContext(), ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
intent.setDataAndType(requireActivity().intent.data, requireActivity().intent.type)
val existingThread = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread)
requireContext().startActivity(intent)
delegate.onDialogClosePressed()
}
private fun showLoader() {
binding.loader.visibility = View.VISIBLE
binding.loader.animate().setDuration(150).alpha(1.0f).start()
}
private fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE
}
})
}
}

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.dms
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
class NewMessageFragmentAdapter(
private val parentFragment: Fragment,
private val enterPublicKeyDelegate: EnterPublicKeyDelegate,
private val scanPublicKeyDelegate: ScanQRCodeWrapperFragmentDelegate
) : FragmentStateAdapter(parentFragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> EnterPublicKeyFragment().apply { delegate = enterPublicKeyDelegate }
1 -> ScanQRCodeWrapperFragment().apply { delegate = scanPublicKeyDelegate }
else -> throw IllegalStateException()
}
}
}

View File

@@ -25,7 +25,7 @@ import org.session.libsession.utilities.Device
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.thoughtcrime.securesms.contacts.SelectContactsAdapter import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
@@ -43,7 +43,7 @@ class CreateGroupFragment : Fragment() {
private lateinit var binding: FragmentCreateGroupBinding private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels() private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: NewConversationDelegate lateinit var delegate: StartConversationDelegate
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,

View File

@@ -24,7 +24,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.OpenGroupUrlParser
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.Log
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@@ -33,7 +33,7 @@ class JoinCommunityFragment : Fragment() {
private lateinit var binding: FragmentJoinCommunityBinding private lateinit var binding: FragmentJoinCommunityBinding
lateinit var delegate: NewConversationDelegate lateinit var delegate: StartConversationDelegate
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,

View File

@@ -4,7 +4,6 @@ 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.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
@@ -22,7 +21,6 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
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.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread import org.thoughtcrime.securesms.util.getConversationUnread
@@ -50,7 +48,7 @@ class ConversationView : LinearLayout {
// endregion // endregion
// region Updating // region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) { fun bind(thread: ThreadRecord, isTyping: Boolean) {
this.thread = thread this.thread = thread
if (thread.isPinned) { if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
@@ -141,11 +139,10 @@ class ConversationView : LinearLayout {
else -> recipient.toShortString() // Internally uses the Contact API else -> recipient.toShortString() // Internally uses the Contact API
} }
private fun ThreadRecord.getSnippet(): CharSequence = private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull(
concatSnippet(getSnippetPrefix(), getDisplayBody(context)) getSnippetPrefix(),
getDisplayBody(context)
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence = ).joinToString(": ")
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when { private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.h4
import org.thoughtcrime.securesms.ui.h8
import org.thoughtcrime.securesms.ui.small
@Composable
internal fun EmptyView(newAccount: Boolean) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.homeEmptyViewMargin)
) {
Spacer(modifier = Modifier.weight(1f))
Icon(
painter = painterResource(id = if (newAccount) R.drawable.emoji_tada_large else R.drawable.ic_logo_large),
contentDescription = null,
tint = Color.Unspecified
)
if (newAccount) {
Text(
stringResource(R.string.onboardingAccountCreated),
style = h4,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.welcome_to_session),
style = base,
color = LocalColors.current.primary,
textAlign = TextAlign.Center
)
}
Divider(modifier = Modifier.padding(vertical = LocalDimensions.current.xsMargin))
Text(
stringResource(R.string.conversationsNone),
style = h8,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = LocalDimensions.current.xsItemSpacing))
Text(
stringResource(R.string.onboardingHitThePlusButton),
style = small,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(2f))
}
}
@Preview
@Composable
fun PreviewEmptyView(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
EmptyView(newAccount = false)
}
}
@Preview
@Composable
fun PreviewEmptyViewNew(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
EmptyView(newAccount = true)
}
}

View File

@@ -4,13 +4,14 @@ import android.Manifest
import android.app.NotificationManager import android.app.NotificationManager
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -18,11 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
@@ -44,7 +44,7 @@ import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.start.NewConversationFragment import org.thoughtcrime.securesms.conversation.start.StartConversationFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
@@ -59,36 +59,37 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout
import org.thoughtcrime.securesms.home.search.GlobalSearchResult
import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
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.start
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class HomeActivity : PassphraseRequiredActionBarActivity(), class HomeActivity : PassphraseRequiredActionBarActivity(),
ConversationClickListener, ConversationClickListener,
SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener { GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object { companion object {
const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT"
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
} }
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests private lateinit var glide: GlideRequests
@@ -104,8 +105,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>() private val homeViewModel by viewModels<HomeViewModel>()
private val publicKey: String private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! }
get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: HomeAdapter by lazy { private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests)
@@ -113,47 +113,38 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchAdapter = GlobalSearchAdapter { model -> private val globalSearchAdapter = GlobalSearchAdapter { model ->
when (model) { when (model) {
is GlobalSearchAdapter.Model.Message -> { is GlobalSearchAdapter.Model.Message -> push<ConversationActivityV2> {
val threadId = model.messageResult.threadId model.messageResult.run {
val timestamp = model.messageResult.sentTimestampMs putExtra(ConversationActivityV2.THREAD_ID, threadId)
val author = model.messageResult.messageRecipient.address putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs)
putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address)
}
}
is GlobalSearchAdapter.Model.SavedMessages -> push<ConversationActivityV2> {
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
}
is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> {
putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized))
}
is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId
.let { Recipient.from(this, Address.fromSerialized(it), false) }
.let(threadDb::getThreadIdIfExistsFor)
.takeIf { it >= 0 }
?.let {
push<ConversationActivityV2> { putExtra(ConversationActivityV2.THREAD_ID, it) }
}
else -> Log.d("Loki", "callback with model: $model")
}
}
val intent = Intent(this, ConversationActivityV2::class.java) private val isNewAccount: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author)
push(intent)
}
is GlobalSearchAdapter.Model.SavedMessages -> {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
push(intent)
}
is GlobalSearchAdapter.Model.Contact -> {
val address = model.contact.sessionID
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent)
}
is GlobalSearchAdapter.Model.GroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
push(intent)
}
}
else -> {
Log.d("Loki", "callback with model: $model")
}
}
}
// region Lifecycle // region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
if (!isTaskRoot) { finish(); return }
// Set content view // Set content view
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -164,20 +155,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up toolbar buttons // Set up toolbar buttons
binding.profileButton.setOnClickListener { openSettings() } binding.profileButton.setOnClickListener { openSettings() }
binding.searchViewContainer.setOnClickListener { binding.searchViewContainer.setOnClickListener {
globalSearchViewModel.refresh()
binding.globalSearchInputLayout.requestFocus() binding.globalSearchInputLayout.requestFocus()
} }
binding.sessionToolbar.disableClipping() binding.sessionToolbar.disableClipping()
// Set up seed reminder view // Set up seed reminder view
lifecycleScope.launchWhenStarted { lifecycleScope.launchWhenStarted {
val hasViewedSeed = textSecurePreferences.getHasViewedSeed() binding.seedReminderView.setThemedContent {
if (!hasViewedSeed) { if (!textSecurePreferences.getHasViewedSeed()) SeedReminder { start<RecoveryPasswordActivity>() }
binding.seedReminderView.isVisible = true
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.setProgress(80, false)
binding.seedReminderView.delegate = this@HomeActivity
} else {
binding.seedReminderView.isVisible = false
} }
} }
// Set up recycler view // Set up recycler view
@@ -193,11 +178,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
// Set up empty state view // Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } binding.emptyStateContainer.setThemedContent {
EmptyView(isNewAccount)
}
IP2Country.configureIfNeeded(this@HomeActivity) IP2Country.configureIfNeeded(this@HomeActivity)
// Set up new conversation button // Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() } binding.newConversationButton.setOnClickListener { showStartConversation() }
// Observe blocked contacts changed events // Observe blocked contacts changed events
// subscribe to outdated config updates, this should be removed after long enough time for device migration // subscribe to outdated config updates, this should be removed after long enough time for device migration
@@ -252,51 +240,29 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// monitor the global search VM query // monitor the global search VM query
launch { launch {
binding.globalSearchInputLayout.query binding.globalSearchInputLayout.query
.onEach(globalSearchViewModel::postQuery) .collect(globalSearchViewModel::setQuery)
.collect()
} }
// Get group results and display them // Get group results and display them
launch { launch {
globalSearchViewModel.result.collect { result -> globalSearchViewModel.result.map { result ->
val currentUserPublicKey = publicKey result.query to when {
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + result.query.isEmpty() -> buildList {
result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } add(GlobalSearchAdapter.Model.Header(R.string.contacts))
add(GlobalSearchAdapter.Model.SavedMessages(publicKey))
val contactResults = contactAndGroupList.toMutableList() addAll(result.groupedContacts)
if (contactResults.isEmpty()) {
contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey))
} }
else -> buildList {
val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } result.contactAndGroupList.takeUnless { it.isEmpty() }?.let {
if (userIndex >= 0) { add(GlobalSearchAdapter.Model.Header(R.string.contacts))
contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) addAll(it)
} }
result.messageResults.takeUnless { it.isEmpty() }?.let {
if (contactResults.isNotEmpty()) { add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) addAll(it)
} }
val unreadThreadMap = result.messages
.groupBy { it.threadId }.keys
.map { it to mmsSmsDatabase.getUnreadCount(it) }
.toMap()
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
.map { messageResult ->
GlobalSearchAdapter.Model.Message(
messageResult,
unreadThreadMap[messageResult.threadId] ?: 0
)
}.toMutableList()
if (messageResults.isNotEmpty()) {
messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
} }
val newData = contactResults + messageResults
globalSearchAdapter.setNewData(result.query, newData)
} }
}.collectLatest(globalSearchAdapter::setNewData)
} }
} }
EventBus.getDefault().register(this@HomeActivity) EventBus.getDefault().register(this@HomeActivity)
@@ -308,20 +274,62 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
.request(Manifest.permission.POST_NOTIFICATIONS) .request(Manifest.permission.POST_NOTIFICATIONS)
.execute() .execute()
} }
configFactory.user?.let { user -> configFactory.user
if (!user.isBlockCommunityMessageRequestsSet()) { ?.takeUnless { it.isBlockCommunityMessageRequestsSet() }
user.setCommunityMessageRequests(false) ?.setCommunityMessageRequests(false)
} }
} }
private val GlobalSearchResult.groupedContacts: List<GlobalSearchAdapter.Model> get() {
class NamedValue<T>(val name: String?, val value: T)
// Unknown is temporarily to be grouped together with numbers title.
// https://optf.atlassian.net/browse/SES-2287
val numbersTitle = "#"
val unknownTitle = numbersTitle
return contacts
// Remove ourself, we're shown above.
.filter { it.accountID != publicKey }
// Get the name that we will display and sort by, and uppercase it to
// help with sorting and we need the char uppercased later.
.map { (it.nickname?.takeIf(String::isNotEmpty) ?: it.name?.takeIf(String::isNotEmpty))
.let { name -> NamedValue(name?.uppercase(), it) } }
// Digits are all grouped under a #, the rest are grouped by their first character.uppercased()
// If there is no name, they go under Unknown
.groupBy { it.name?.run { first().takeUnless(Char::isDigit)?.toString() ?: numbersTitle } ?: unknownTitle }
// place the # at the end, after all the names starting with alphabetic chars
.toSortedMap(compareBy {
when (it) {
unknownTitle -> Char.MAX_VALUE
numbersTitle -> Char.MAX_VALUE - 1
else -> it.first()
}
})
// Flatten the map of char to lists into an actual List that can be displayed.
.flatMap { (key, contacts) ->
listOf(
GlobalSearchAdapter.Model.SubHeader(key)
) + contacts.sortedBy { it.name ?: it.value.accountID }.map { it.value }.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) }
}
}
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
threads.map(GlobalSearchAdapter.Model::GroupConversation)
private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() {
val unreadThreadMap = messages
.map { it.threadId }.toSet()
.associateWith { mmsSmsDatabase.getUnreadCount(it) }
return messages.map {
GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0, it.conversationRecipient.isLocalNumber)
} }
} }
override fun onInputFocusChanged(hasFocus: Boolean) { override fun onInputFocusChanged(hasFocus: Boolean) {
if (hasFocus) { setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty())
setSearchShown(true)
} else {
setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty())
}
} }
private fun setSearchShown(isShown: Boolean) { private fun setSearchShown(isShown: Boolean) {
@@ -330,7 +338,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.isVisible = !isShown binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.globalSearchRecycler.isVisible = isShown binding.globalSearchRecycler.isInvisible = !isShown
binding.newConversationButton.isVisible = !isShown binding.newConversationButton.isVisible = !isShown
} }
@@ -397,16 +405,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// region Interaction // region Interaction
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onBackPressed() { override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) { if (binding.globalSearchRecycler.isVisible) binding.globalSearchInputLayout.clearSearch(true)
binding.globalSearchInputLayout.clearSearch(true) else super.onBackPressed()
return
}
super.onBackPressed()
}
override fun handleSeedReminderViewContinueButtonTapped() {
val intent = Intent(this, SeedActivity::class.java)
show(intent)
} }
override fun onConversationClick(thread: ThreadRecord) { override fun onConversationClick(thread: ThreadRecord) {
@@ -431,17 +431,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
bottomSheet.onCopyConversationId = onCopyConversationId@{ bottomSheet.onCopyConversationId = onCopyConversationId@{
bottomSheet.dismiss() bottomSheet.dismiss()
if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) { if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) {
val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString()) val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString())
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
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.isCommunityRecipient) { else if (thread.recipient.isCommunityRecipient) {
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient)
val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
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()
} }
@@ -569,7 +569,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val message = if (recipient.isGroupRecipient) { val message = if (recipient.isGroupRecipient) {
val group = groupDatabase.getGroup(recipient.address.toString()).orNull() val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone." getString(R.string.admin_group_leave_warning)
} else { } else {
resources.getString(R.string.activity_home_leave_group_dialog_message) resources.getString(R.string.activity_home_leave_group_dialog_message)
} }
@@ -625,7 +625,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun hideMessageRequests() { private fun hideMessageRequests() {
showSessionDialog { showSessionDialog {
text("Hide message requests?") text(getString(R.string.hide_message_requests))
button(R.string.yes) { button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests() textSecurePreferences.setHasHiddenMessageRequests()
homeViewModel.tryReload() homeViewModel.tryReload()
@@ -634,9 +634,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
private fun showNewConversation() { private fun showStartConversation() {
NewConversationFragment().show(supportFragmentManager, "NewConversationFragment") StartConversationFragment().show(supportFragmentManager, "StartConversationFragment")
}
} }
// endregion fun Context.startHomeActivity(isNewAccount: Boolean) {
Intent(this, HomeActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(HomeActivity.NEW_ACCOUNT, true)
putExtra(HomeActivity.FROM_ONBOARDING, true)
}.also(::startActivity)
} }

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.ListUpdateCallback
@@ -12,8 +11,6 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
class HomeAdapter( class HomeAdapter(
private val context: Context, private val context: Context,
@@ -115,7 +112,7 @@ class HomeAdapter(
val offset = if (hasHeaderView()) position - 1 else position val offset = if (hasHeaderView()) position - 1 else position
val thread = data.threads[offset] val thread = data.threads[offset]
val isTyping = data.typingThreadIDs.contains(thread.threadId) val isTyping = data.typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide) holder.view.bind(thread, isTyping)
} }
} }
} }

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.SessionShieldIcon
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.h8
import org.thoughtcrime.securesms.ui.small
@Composable
internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) {
Column {
// Color Strip
Box(
Modifier
.fillMaxWidth()
.height(LocalDimensions.current.indicatorHeight)
.background(LocalColors.current.primary)
)
Row(
Modifier
.background(LocalColors.current.backgroundSecondary)
.padding(
horizontal = LocalDimensions.current.smallMargin,
vertical = LocalDimensions.current.xsMargin
)
) {
Column(Modifier.weight(1f)) {
Row {
Text(
stringResource(R.string.save_your_recovery_password),
style = h8
)
Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsItemSpacing))
SessionShieldIcon()
}
Text(
stringResource(R.string.save_your_recovery_password_to_make_sure_you_don_t_lose_access_to_your_account),
style = small
)
}
Spacer(Modifier.width(LocalDimensions.current.xxsMargin))
SlimPrimaryOutlineButton(
text = stringResource(R.string.continue_2),
modifier = Modifier
.align(Alignment.CenterVertically)
.contentDescription(R.string.AccessibilityId_reveal_recovery_phrase_button),
onClick = startRecoveryPasswordActivity
)
}
}
}
@Preview
@Composable
private fun PreviewSeedReminder(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
SeedReminder {}
}
}

View File

@@ -98,7 +98,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
publicKeyTextView.setOnLongClickListener { publicKeyTextView.setOnLongClickListener {
val clipboard = val clipboard =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", publicKey) val clip = ClipData.newPlainText("Account ID", publicKey)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT) Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT)
.show() .show()
@@ -137,7 +137,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
else { newNickName = previousContactNickname } 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.getContactWithAccountID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName contact.nickname = newNickName
storage.setContact(contact) storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally

View File

@@ -9,10 +9,11 @@ import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel import org.session.libsession.messaging.contacts.Contact as ContactModel
@@ -20,12 +21,15 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
companion object { companion object {
const val HEADER_VIEW_TYPE = 0 const val HEADER_VIEW_TYPE = 0
const val CONTENT_VIEW_TYPE = 1 const val SUB_HEADER_VIEW_TYPE = 1
const val CONTENT_VIEW_TYPE = 2
} }
private var data: List<Model> = listOf() private var data: List<Model> = listOf()
private var query: String? = null private var query: String? = null
fun setNewData(data: Pair<String, List<Model>>) = setNewData(data.first, data.second)
fun setNewData(query: String, newData: List<Model>) { fun setNewData(query: String, newData: List<Model>) {
val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData)) val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData))
this.query = query this.query = query
@@ -34,21 +38,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
} }
override fun getItemViewType(position: Int): Int = override fun getItemViewType(position: Int): Int =
if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE when(data[position]) {
is Model.Header -> HEADER_VIEW_TYPE
is Model.SubHeader -> SUB_HEADER_VIEW_TYPE
else -> CONTENT_VIEW_TYPE
}
override fun getItemCount(): Int = data.size override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == HEADER_VIEW_TYPE) { when (viewType) {
HeaderView( HEADER_VIEW_TYPE -> HeaderView(
LayoutInflater.from(parent.context) LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_header, parent, false)
.inflate(R.layout.view_global_search_header, parent, false) )
SUB_HEADER_VIEW_TYPE -> SubHeaderView(
LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_subheader, parent, false)
)
else -> ContentView(
LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false),
modelCallback
) )
} else {
ContentView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_result, parent, false)
, modelCallback)
} }
override fun onBindViewHolder( override fun onBindViewHolder(
@@ -61,10 +70,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
holder.bindPayload(newUpdateQuery, data[position]) holder.bindPayload(newUpdateQuery, data[position])
return return
} }
if (holder is HeaderView) { when (holder) {
holder.bind(data[position] as Model.Header) is HeaderView -> holder.bind(data[position] as Model.Header)
} else if (holder is ContentView) { is SubHeaderView -> holder.bind(data[position] as Model.SubHeader)
holder.bind(query.orEmpty(), data[position]) is ContentView -> holder.bind(query.orEmpty(), data[position])
} }
} }
@@ -77,7 +86,16 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
val binding = ViewGlobalSearchHeaderBinding.bind(view) val binding = ViewGlobalSearchHeaderBinding.bind(view)
fun bind(header: Model.Header) { fun bind(header: Model.Header) {
binding.searchHeader.setText(header.title) binding.searchHeader.setText(header.title.string(binding.root.context))
}
}
class SubHeaderView(view: View) : RecyclerView.ViewHolder(view) {
val binding = ViewGlobalSearchSubheaderBinding.bind(view)
fun bind(header: Model.SubHeader) {
binding.searchHeader.text = header.title.string(binding.root.context)
} }
} }
@@ -102,25 +120,24 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
is Model.Contact -> bindModel(query, model) is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model) is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model) is Model.SavedMessages -> bindModel(model)
is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView") else -> throw InvalidParameterException("Can't display as ContentView")
} }
binding.root.setOnClickListener { modelCallback(model) } binding.root.setOnClickListener { modelCallback(model) }
} }
} }
data class MessageModel(
val threadRecipient: Recipient,
val messageRecipient: Recipient,
val messageSnippet: String
)
sealed class Model { sealed class Model {
data class Header(@StringRes val title: Int) : Model() data class Header(val title: GetString): Model() {
constructor(@StringRes title: Int): this(GetString(title))
constructor(title: String): this(GetString(title))
}
data class SubHeader(val title: GetString): Model() {
constructor(@StringRes title: Int): this(GetString(title))
constructor(title: String): this(GetString(title))
}
data class SavedMessages(val currentUserPublicKey: String): Model() data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : Model() data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
data class GroupConversation(val groupRecord: GroupRecord): Model() data class GroupConversation(val groupRecord: GroupRecord): Model()
data class Message(val messageResult: MessageResult, val unread: Int) : Model() data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model()
} }
} }

View File

@@ -10,11 +10,13 @@ import network.loki.messenger.R
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.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import java.util.Locale import java.util.Locale
@@ -63,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
)) ))
binding.searchResultSubtitle.text = textSpannable binding.searchResultSubtitle.text = textSpannable
binding.searchResultSubtitle.isVisible = true binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
} }
is GroupConversation -> { is GroupConversation -> {
binding.searchResultTitle.text = getHighlight( binding.searchResultTitle.text = getHighlight(
@@ -72,12 +74,12 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
) )
val membersString = model.groupRecord.members.joinToString { address -> val membersString = model.groupRecord.members.joinToString { address ->
val recipient = Recipient.from(binding.root.context, address, false) Recipient.from(binding.root.context, address, false).getSearchName()
recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}"
} }
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
is Header, // do nothing for header is Header, // do nothing for header
is SubHeader, // do nothing for subheader
is SavedMessages -> Unit // do nothing for saved messages (displays note to self) is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
} }
} }
@@ -88,7 +90,6 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
fun ContentView.bindModel(query: String?, model: GroupConversation) { fun ContentView.bindModel(query: String?, model: GroupConversation) {
binding.searchResultProfilePicture.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
@@ -98,64 +99,65 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
val membersString = groupRecipients.joinToString { val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
}
if (model.groupRecord.isClosedGroup) { if (model.groupRecord.isClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
} }
fun ContentView.bindModel(query: String?, model: ContactModel) { fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run {
binding.searchResultProfilePicture.isVisible = true searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false searchResultSubtitle.isVisible = false
binding.searchResultSubtitle.isVisible = false searchResultTimestamp.isVisible = false
binding.searchResultTimestamp.isVisible = false searchResultSubtitle.text = null
binding.searchResultSubtitle.text = null val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false)
val recipient = searchResultProfilePicture.update(recipient)
Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self)
binding.searchResultProfilePicture.update(recipient) else model.contact.getSearchName()
val nameString = model.contact.getSearchName() searchResultTitle.text = getHighlight(query, nameString)
binding.searchResultTitle.text = getHighlight(query, nameString)
} }
fun ContentView.bindModel(model: SavedMessages) { fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.note_to_self) binding.searchResultTitle.setText(R.string.note_to_self)
binding.searchResultProfilePicture.isVisible = false binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey))
binding.searchResultSavedMessages.isVisible = true binding.searchResultProfilePicture.isVisible = true
} }
fun ContentView.bindModel(query: String?, model: Message) { fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
binding.searchResultProfilePicture.isVisible = true searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false searchResultTimestamp.isVisible = true
binding.searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0 // val hasUnreads = model.unread > 0
// binding.unreadCountIndicator.isVisible = hasUnreads // unreadCountIndicator.isVisible = hasUnreads
// if (hasUnreads) { // if (hasUnreads) {
// binding.unreadCountTextView.text = model.unread.toString() // unreadCountTextView.text = model.unread.toString()
// } // }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder() val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind // group chat, bind
val text = "${model.messageResult.messageRecipient.getSearchName()}: " val text = "${model.messageResult.messageRecipient.toShortString()}: "
textSpannable.append(text) textSpannable.append(text)
} }
textSpannable.append(getHighlight( textSpannable.append(getHighlight(
query, query,
model.messageResult.bodySnippet model.messageResult.bodySnippet
)) ))
binding.searchResultSubtitle.text = textSpannable searchResultSubtitle.text = textSpannable
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.note_to_self)
binding.searchResultSubtitle.isVisible = true else model.messageResult.conversationRecipient.getSearchName()
searchResultSubtitle.isVisible = true
} }
fun Recipient.getSearchName(): String = name ?: address.serialize().let { address -> "${address.take(4)}...${address.takeLast(4)}" } fun Recipient.getSearchName(): String =
name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: address.serialize().let(::truncateIdForDisplay)
fun Contact.getSearchName(): String = fun Contact.getSearchName(): String =
if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}" nickname?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)" ?: name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: truncateIdForDisplay(accountID)
private val String.looksLikeAccountId: Boolean get() = length > 60 && all { it.isDigit() || it.isLetter() }

View File

@@ -16,42 +16,37 @@ import android.widget.TextView
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import network.loki.messenger.databinding.ViewGlobalSearchInputBinding import network.loki.messenger.databinding.ViewGlobalSearchInputBinding
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.addTextChangedListener
class GlobalSearchInputLayout @JvmOverloads constructor( class GlobalSearchInputLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs), ) : LinearLayout(context, attrs),
View.OnFocusChangeListener, View.OnFocusChangeListener,
View.OnClickListener, TextView.OnEditorActionListener {
TextWatcher, TextView.OnEditorActionListener {
var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true) var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true)
var listener: GlobalSearchInputLayoutListener? = null var listener: GlobalSearchInputLayoutListener? = null
private val _query = MutableStateFlow<CharSequence?>(null) private val _query = MutableStateFlow<CharSequence>("")
val query: StateFlow<CharSequence?> = _query val query: StateFlow<CharSequence> = _query
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
binding.searchInput.onFocusChangeListener = this binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this) binding.searchInput.addTextChangedListener(::setQuery)
binding.searchInput.setOnEditorActionListener(this) binding.searchInput.setOnEditorActionListener(this)
binding.searchInput.setFilters( arrayOf<InputFilter>(LengthFilter(100)) ) // 100 char search limit binding.searchInput.filters = arrayOf<InputFilter>(LengthFilter(100)) // 100 char search limit
binding.searchCancel.setOnClickListener(this) binding.searchCancel.setOnClickListener { clearSearch(true) }
binding.searchClear.setOnClickListener(this) binding.searchClear.setOnClickListener { clearSearch(false) }
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
} }
override fun onFocusChange(v: View?, hasFocus: Boolean) { override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v === binding.searchInput) { if (v === binding.searchInput) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).apply {
if (!hasFocus) { if (hasFocus) showSoftInput(v, 0)
imm.hideSoftInputFromWindow(windowToken, 0) else hideSoftInputFromWindow(windowToken, 0)
} else {
imm.showSoftInput(v, 0)
} }
listener?.onInputFocusChanged(hasFocus) listener?.onInputFocusChanged(hasFocus)
} }
@@ -65,27 +60,16 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
return false return false
} }
override fun onClick(v: View?) {
if (v === binding.searchCancel) {
clearSearch(true)
} else if (v === binding.searchClear) {
clearSearch(false)
}
}
fun clearSearch(clearFocus: Boolean) { fun clearSearch(clearFocus: Boolean) {
binding.searchInput.text = null binding.searchInput.text = null
setQuery("")
if (clearFocus) { if (clearFocus) {
binding.searchInput.clearFocus() binding.searchInput.clearFocus()
} }
} }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} private fun setQuery(query: String) {
_query.value = query
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
_query.value = s?.toString()
} }
interface GlobalSearchInputLayoutListener { interface GlobalSearchInputLayoutListener {

View File

@@ -2,33 +2,25 @@ package org.thoughtcrime.securesms.home.search
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.search.model.SearchResult import org.thoughtcrime.securesms.search.model.SearchResult
data class GlobalSearchResult( data class GlobalSearchResult(
val query: String, val query: String,
val contacts: List<Contact>, val contacts: List<Contact> = emptyList(),
val threads: List<GroupRecord>, val threads: List<GroupRecord> = emptyList(),
val messages: List<MessageResult> val messages: List<MessageResult> = emptyList()
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty()
companion object { companion object {
val EMPTY = GlobalSearchResult("")
val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList())
const val SEARCH_LIMIT = 5
fun from(searchResult: SearchResult): GlobalSearchResult {
val query = searchResult.query
val contactList = searchResult.contacts.toList()
val threads = searchResult.conversations.toList()
val messages = searchResult.messages.toList()
searchResult.close()
return GlobalSearchResult(query, contactList, threads, messages)
}
} }
} }
fun SearchResult.toGlobalSearchResult(): GlobalSearchResult = try {
GlobalSearchResult(query, contacts.toList(), conversations.toList(), messages.toList())
} finally {
close()
}

View File

@@ -3,15 +3,22 @@ package org.thoughtcrime.securesms.home.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.session.libsignal.utilities.SettableFuture import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.SearchRepository
@@ -19,49 +26,51 @@ import org.thoughtcrime.securesms.search.model.SearchResult
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { class GlobalSearchViewModel @Inject constructor(
private val searchRepository: SearchRepository,
) : ViewModel() {
private val scope = viewModelScope + SupervisorJob()
private val refreshes = MutableSharedFlow<Unit>()
private val _queryText = MutableStateFlow<CharSequence>("")
private val executor = viewModelScope + SupervisorJob() val result = _queryText
.reEmit(refreshes)
private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow<GlobalSearchResult> = _result
private val _queryText: MutableStateFlow<CharSequence> = MutableStateFlow("")
fun postQuery(charSequence: CharSequence?) {
charSequence ?: return
_queryText.value = charSequence
}
init {
//
_queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query -> .mapLatest { query ->
// Early exit on empty search query
if (query.trim().isEmpty()) { if (query.trim().isEmpty()) {
SearchResult.EMPTY // searching for 05 as contactDb#getAllContacts was not returning contacts
// without a nickname/name who haven't approved us.
GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList())
} else { } else {
// User input delay in case we get a new query within a few hundred ms this // User input delay in case we get a new query within a few hundred ms this
// coroutine will be cancelled and the expensive query will not be run. // coroutine will be cancelled and the expensive query will not be run.
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 {
// search repository doesn't play nicely with suspend functions (yet) // search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS) settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult()
} catch (e: Exception) { } catch (e: Exception) {
SearchResult.EMPTY GlobalSearchResult(query.toString())
} }
} }
} }
.onEach { result ->
// update the latest _result value fun setQuery(charSequence: CharSequence) {
_result.value = GlobalSearchResult.from(result) _queryText.value = charSequence
} }
.launchIn(executor)
fun refresh() {
viewModelScope.launch {
refreshes.emit(Unit)
} }
} }
}
/**
* Re-emit whenever refreshes emits.
* */
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Flow<T>.reEmit(refreshes: Flow<Unit>) = flatMapLatest { query -> merge(flowOf(query), refreshes.map { query }) }

View File

@@ -76,7 +76,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
} }
override fun doWork(): Result { override fun doWork(): Result {
if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { if (TextSecurePreferences.getLocalNumber(context) == null) {
Log.v(TAG, "User not registered yet.") Log.v(TAG, "User not registered yet.")
return Result.failure() return Result.failure()
} }

View File

@@ -43,7 +43,7 @@ import com.goterl.lazysodium.utils.KeyPair;
import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.open_groups.OpenGroup;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.utilities.SessionId; import org.session.libsession.messaging.utilities.AccountId;
import org.session.libsession.messaging.utilities.SodiumUtilities; import org.session.libsession.messaging.utilities.SodiumUtilities;
import org.session.libsession.snode.SnodeAPI; import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
@@ -269,7 +269,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
try { try {
telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here
if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) if ((telcoCursor == null || telcoCursor.isAfterLast()) || TextSecurePreferences.getLocalNumber(context) == null)
{ {
updateBadge(context, 0); updateBadge(context, 0);
cancelActiveNotifications(context); cancelActiveNotifications(context);
@@ -594,7 +594,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (openGroup != null && edKeyPair != null) { if (openGroup != null && edKeyPair != null) {
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
if (blindedKeyPair != null) { if (blindedKeyPair != null) {
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
} }
} }
return null; return null;

View File

@@ -118,11 +118,11 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
*/ */
private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) {
SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase();
String sessionID = recipient.getAddress().serialize(); String accountID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID); Contact contact = contactDB.getContactWithAccountID(accountID);
if (contact == null) { return sessionID; } if (contact == null) { return accountID; }
String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR);
if (displayName == null) { return sessionID; } if (displayName == null) { return accountID; }
return displayName; return displayName;
} }
} }

View File

@@ -339,11 +339,11 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
*/ */
private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) {
SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase();
String sessionID = recipient.getAddress().serialize(); String accountID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID); Contact contact = contactDB.getContactWithAccountID(accountID);
if (contact == null) { return sessionID; } if (contact == null) { return accountID; }
String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR);
if (displayName == null) { return sessionID; } if (displayName == null) { return accountID; }
return displayName; return displayName;
} }
} }

View File

@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView.OnEditorActionListener
import android.widget.Toast
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityDisplayNameBinding
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.session.libsession.utilities.TextSecurePreferences
class DisplayNameActivity : BaseActionBarActivity() {
private lateinit var binding: ActivityDisplayNameBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
binding = ActivityDisplayNameBinding.inflate(layoutInflater)
setContentView(binding.root)
with(binding) {
displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard
displayNameEditText.setOnEditorActionListener(
OnEditorActionListener { _, actionID, event ->
if (actionID == EditorInfo.IME_ACTION_SEARCH ||
actionID == EditorInfo.IME_ACTION_DONE ||
(event.action == KeyEvent.ACTION_DOWN &&
event.keyCode == KeyEvent.KEYCODE_ENTER)) {
register()
return@OnEditorActionListener true
}
false
})
registerButton.setOnClickListener { register() }
}
}
private fun register() {
val displayName = binding.displayNameEditText.text.toString().trim()
if (displayName.isEmpty()) {
return Toast.makeText(this, R.string.activity_display_name_display_name_missing_error, Toast.LENGTH_SHORT).show()
}
if (displayName.toByteArray().size > ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH) {
return Toast.makeText(this, R.string.activity_display_name_display_name_too_long_error, Toast.LENGTH_SHORT).show()
}
val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0)
TextSecurePreferences.setProfileName(this, displayName)
val intent = Intent(this, PNModeActivity::class.java)
push(intent)
}
}

View File

@@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.os.Handler
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ScrollView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewFakeChatBinding
import org.thoughtcrime.securesms.util.disableClipping
class FakeChatView : ScrollView {
private lateinit var binding: ViewFakeChatBinding
// region Settings
private val spacing = context.resources.getDimension(R.dimen.medium_spacing)
private val startDelay: Long = 1000
private val delayBetweenMessages: Long = 1500
private val animationDuration: Long = 400
// endregion
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
binding = ViewFakeChatBinding.inflate(LayoutInflater.from(context), this, true)
binding.root.disableClipping()
isVerticalScrollBarEnabled = false
}
// endregion
// region Animation
fun startAnimating() {
listOf( binding.bubble1, binding.bubble2, binding.bubble3, binding.bubble4, binding.bubble5 ).forEach { it.alpha = 0.0f }
fun show(bubble: View) {
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
animation.duration = animationDuration
animation.addUpdateListener { animator ->
bubble.alpha = animator.animatedValue as Float
}
animation.start()
}
Handler().postDelayed({
show(binding.bubble1)
Handler().postDelayed({
show(binding.bubble2)
Handler().postDelayed({
show(binding.bubble3)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt())
Handler().postDelayed({
show(binding.bubble4)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt())
Handler().postDelayed({
show(binding.bubble5)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt() + (binding.bubble3.height + spacing).toInt())
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, startDelay)
}
// endregion
}

View File

@@ -1,230 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityLinkDeviceBinding
import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding
import org.session.libsession.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.Log
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private val adapter = LinkDeviceActivityAdapter(this)
private var restoreJob: Job? = null
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (restoreJob?.isActive == true) return // Don't allow going back with a pending job
super.onBackPressed()
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
TextSecurePreferences.apply {
setHasViewedSeed(this@LinkDeviceActivity, true)
setConfigurationMessageSynced(this@LinkDeviceActivity, false)
setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
setLastProfileUpdateTime(this@LinkDeviceActivity, 0)
}
binding = ActivityLinkDeviceBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
}
// endregion
// region Interaction
override fun handleQRCodeScanned(mnemonic: String) {
try {
val seed = Hex.fromStringCondensed(mnemonic)
continueWithSeed(seed)
} catch (e: Exception) {
Log.e("Loki","Error getting seed from QR code", e)
Toast.makeText(this, "An error occurred.", Toast.LENGTH_LONG).show()
}
}
fun continueWithMnemonic(mnemonic: String) {
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName)
}
try {
val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
val seed = Hex.fromStringCondensed(hexEncodedSeed)
continueWithSeed(seed)
} catch (error: Exception) {
val message = if (error is MnemonicCodec.DecodingError) {
error.description
} else {
"An error occurred."
}
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
private fun continueWithSeed(seed: ByteArray) {
// only have one sync job running at a time (prevent QR from trying to spawn a new job)
if (restoreJob?.isActive == true) return
restoreJob = lifecycleScope.launch {
// 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()
// RestoreActivity handles seed this way
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
TextSecurePreferences.setLocalNumber(this@LinkDeviceActivity, userHexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
TextSecurePreferences.setHasViewedSeed(this@LinkDeviceActivity, true)
binding.loader.isVisible = true
val snackBar = Snackbar.make(binding.containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.registration_activity__skip) { register(true) }
val skipJob = launch {
delay(15_000L)
snackBar.show()
}
// start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).apply {
startPollingIfNeeded()
}
TextSecurePreferences.events.filter { it == TextSecurePreferences.CONFIGURATION_SYNCED }.collect {
// handle we've synced
snackBar.dismiss()
skipJob.cancel()
register(false)
}
binding.loader.isVisible = false
}
}
private fun register(skipped: Boolean) {
restoreJob?.cancel()
binding.loader.isVisible = false
TextSecurePreferences.setLastConfigurationSyncTime(this, System.currentTimeMillis())
val intent = Intent(this@LinkDeviceActivity, if (skipped) DisplayNameActivity::class.java else PNModeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
push(intent)
}
// endregion
}
// region Adapter
private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
val recoveryPhraseFragment = RecoveryPhraseFragment()
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> recoveryPhraseFragment
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = activity.getString(R.string.activity_link_device_qr_message)
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence {
return when (index) {
0 -> activity.getString(R.string.activity_link_device_recovery_phrase)
1 -> activity.getString(R.string.activity_link_device_scan_qr_code)
else -> throw IllegalStateException()
}
}
}
// endregion
// region Recovery Phrase Fragment
class RecoveryPhraseFragment : Fragment() {
private lateinit var binding: FragmentRecoveryPhraseBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentRecoveryPhraseBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard
mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT)
mnemonicEditText.setOnEditorActionListener { v, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_DONE) {
val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
handleContinueButtonTapped()
true
} else {
false
}
}
continueButton.setOnClickListener { handleContinueButtonTapped() }
}
}
private fun handleContinueButtonTapped() {
val mnemonic = binding.mnemonicEditText.text?.trim().toString()
(requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic)
}
}
// endregion

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.onboarding
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.color.LocalColors
@Composable
fun OnboardingBackPressAlertDialog(
dismissDialog: () -> Unit,
@StringRes textId: Int = R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit,
quit: () -> Unit
) {
AlertDialog(
onDismissRequest = dismissDialog,
title = stringResource(R.string.warning),
text = stringResource(textId),
buttons = listOf(
DialogButtonModel(
GetString(stringResource(R.string.quit)),
color = LocalColors.current.danger,
onClick = quit
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}

View File

@@ -1,179 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.content.Intent
import android.graphics.drawable.TransitionDrawable
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPnModeBinding
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.HomeActivity
import org.thoughtcrime.securesms.notifications.PushManager
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.PNModeView
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.show
import javax.inject.Inject
@AndroidEntryPoint
class PNModeActivity : BaseActionBarActivity() {
@Inject lateinit var pushRegistry: PushRegistry
private lateinit var binding: ActivityPnModeBinding
private var selectedOptionView: PNModeView? = null
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo(true)
TextSecurePreferences.setHasSeenWelcomeScreen(this, true)
binding = ActivityPnModeBinding.inflate(layoutInflater)
setContentView(binding.root)
with(binding) {
contentView.disableClipping()
fcmOptionView.setOnClickListener { toggleFCM() }
fcmOptionView.mainColor = ThemeUtil.getThemedColor(root.context, R.attr.colorPrimary)
fcmOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme)
backgroundPollingOptionView.setOnClickListener { toggleBackgroundPolling() }
backgroundPollingOptionView.mainColor = ThemeUtil.getThemedColor(root.context, R.attr.colorPrimary)
backgroundPollingOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme)
registerButton.setOnClickListener { register() }
}
toggleFCM()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_pn_mode, menu)
return true
}
// endregion
// region Animation
private fun performTransition(@DrawableRes transitionID: Int, subject: View) {
val drawable = resources.getDrawable(transitionID, theme) as TransitionDrawable
subject.background = drawable
drawable.startTransition(250)
}
// endregion
// region Interaction
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) {
R.id.learnMoreButton -> learnMore()
else -> { /* Do nothing */ }
}
return super.onOptionsItemSelected(item)
}
private fun learnMore() {
try {
val url = "https://getsession.org/faq/#privacy"
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()
}
}
private fun toggleFCM() = with(binding) {
val accentColor = getAccentColor()
when (selectedOptionView) {
null -> {
performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView)
GlowViewUtilities.animateShadowColorChange(fcmOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor)
animateStrokeColorChange(fcmOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor)
selectedOptionView = fcmOptionView
}
fcmOptionView -> {
performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView)
GlowViewUtilities.animateShadowColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme))
animateStrokeColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme))
selectedOptionView = null
}
backgroundPollingOptionView -> {
performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView)
GlowViewUtilities.animateShadowColorChange(fcmOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor)
animateStrokeColorChange(fcmOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor)
performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView)
GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme))
animateStrokeColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme))
selectedOptionView = fcmOptionView
}
}
}
private fun toggleBackgroundPolling() = with(binding) {
val accentColor = getAccentColor()
when (selectedOptionView) {
null -> {
performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView)
GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor)
animateStrokeColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor)
selectedOptionView = backgroundPollingOptionView
}
backgroundPollingOptionView -> {
performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView)
GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme))
animateStrokeColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme))
selectedOptionView = null
}
fcmOptionView -> {
performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView)
GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor)
animateStrokeColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor)
performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView)
GlowViewUtilities.animateShadowColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme))
animateStrokeColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme))
selectedOptionView = backgroundPollingOptionView
}
}
}
private fun animateStrokeColorChange(bubble: PNModeView, @ColorInt startColor: Int, @ColorInt endColor: Int) {
val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor)
animation.duration = 250
animation.addUpdateListener { animator ->
val color = animator.animatedValue as Int
bubble.strokeColor = color
}
animation.start()
}
private fun register() {
if (selectedOptionView == null) {
showSessionDialog {
title(R.string.activity_pn_mode_no_option_picked_dialog_title)
button(R.string.ok)
}
return
}
TextSecurePreferences.setPushEnabled(this, (selectedOptionView == binding.fcmOptionView))
val application = ApplicationContext.getInstance(this)
application.startPollingIfNeeded()
pushRegistry.refresh(true)
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(HomeActivity.FROM_ONBOARDING, true)
show(intent)
}
// endregion
}

View File

@@ -1,165 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.os.Handler
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 com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRegisterBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RegisterActivity : BaseActionBarActivity() {
private val temporarySeedKey = "TEMPORARY_SEED_KEY"
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRegisterBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private var seed: ByteArray? = null
private var ed25519KeyPair: KeyPair? = null
private var x25519KeyPair: ECKeyPair? = null
set(value) { field = value; updatePublicKeyTextView() }
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRegisterBinding.inflate(layoutInflater)
setContentView(binding.root)
setUpActionBarSessionLogo()
TextSecurePreferences.apply {
setHasViewedSeed(this@RegisterActivity, false)
setConfigurationMessageSynced(this@RegisterActivity, true)
setRestorationTime(this@RegisterActivity, 0)
setLastProfileUpdateTime(this@RegisterActivity, System.currentTimeMillis())
}
binding.registerButton.setOnClickListener { register() }
binding.copyButton.setOnClickListener { copyPublicKey() }
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy")
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(object : ClickableSpan() {
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
updateKeyPair(savedInstanceState?.getByteArray(temporarySeedKey))
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
seed?.let { tempSeed ->
outState.putByteArray(temporarySeedKey, tempSeed)
}
}
// endregion
// region Updating
private fun updateKeyPair(temporaryKey: ByteArray?) {
val keyPairGenerationResult = temporaryKey?.let(KeyPairUtilities::generate) ?: KeyPairUtilities.generate()
seed = keyPairGenerationResult.seed
ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair
x25519KeyPair = keyPairGenerationResult.x25519KeyPair
}
private fun updatePublicKeyTextView() {
val hexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val characterCount = hexEncodedPublicKey.count()
var count = 0
val limit = 32
fun animate() {
val numberOfIndexesToShuffle = 32 - count
val indexesToShuffle = (0 until characterCount).shuffled().subList(0, numberOfIndexesToShuffle)
var mangledHexEncodedPublicKey = hexEncodedPublicKey
for (index in indexesToShuffle) {
try {
mangledHexEncodedPublicKey = mangledHexEncodedPublicKey.substring(0, index) + "0123456789abcdef__".random() + mangledHexEncodedPublicKey.substring(index + 1, mangledHexEncodedPublicKey.count())
} catch (exception: Exception) {
// Do nothing
}
}
count += 1
if (count < limit) {
binding.publicKeyTextView.text = mangledHexEncodedPublicKey
Handler().postDelayed({
animate()
}, 32)
} else {
binding.publicKeyTextView.text = hexEncodedPublicKey
}
}
animate()
}
// endregion
// region Interaction
private fun register() {
// 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()
KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(this, 0)
TextSecurePreferences.setHasViewedSeed(this, false)
val intent = Intent(this, DisplayNameActivity::class.java)
push(intent)
}
private fun copyPublicKey() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", x25519KeyPair!!.hexEncodedPublicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, 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

@@ -1,95 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.widget.LinearLayout
import android.widget.Toast
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySeedBinding
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.util.getAccentColor
class SeedActivity : BaseActionBarActivity() {
private lateinit var binding: ActivitySeedBinding
private val seed by lazy {
var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED)
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
}
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName)
}
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySeedBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar!!.title = resources.getString(R.string.activity_seed_title)
val seedReminderViewTitle = SpannableString("You're almost finished! 90%") // Intentionally not yet translated
seedReminderViewTitle.setSpan(ForegroundColorSpan(getAccentColor()), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
with(binding) {
seedReminderView.title = seedReminderViewTitle
seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_2)
seedReminderView.setProgress(90, false)
seedReminderView.hideContinueButton()
var redactedSeed = seed
var index = 0
for (character in seed) {
if (character.isLetter()) {
redactedSeed = redactedSeed.replaceRange(index, index + 1, "")
}
index += 1
}
seedTextView.setTextColor(getAccentColor())
seedTextView.text = redactedSeed
seedTextView.setOnLongClickListener { revealSeed(); true }
revealButton.setOnLongClickListener { revealSeed(); true }
copyButton.setOnClickListener { copySeed() }
}
}
// endregion
// region Updating
private fun revealSeed() {
val seedReminderViewTitle = SpannableString("Account secured! 100%") // Intentionally not yet translated
seedReminderViewTitle.setSpan(ForegroundColorSpan(getAccentColor()), 17, 21, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
with(binding) {
seedReminderView.title = seedReminderViewTitle
seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_3)
seedReminderView.setProgress(100, true)
val seedTextViewLayoutParams = seedTextView.layoutParams as LinearLayout.LayoutParams
seedTextViewLayoutParams.height = seedTextView.height
seedTextView.layoutParams = seedTextViewLayoutParams
seedTextView.setTextColor(getColorFromAttr(android.R.attr.textColorPrimary))
seedTextView.text = seed
}
TextSecurePreferences.setHasViewedSeed(this, true)
}
// endregion
// region Interaction
private fun copySeed() {
revealSeed()
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Seed", seed)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
// endregion
}

View File

@@ -1,59 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import network.loki.messenger.databinding.ViewSeedReminderBinding
class SeedReminderView : FrameLayout {
private lateinit var binding: ViewSeedReminderBinding
var title: CharSequence
get() = binding.titleTextView.text
set(value) { binding.titleTextView.text = value }
var subtitle: CharSequence
get() = binding.subtitleTextView.text
set(value) { binding.subtitleTextView.text = value }
var delegate: SeedReminderViewDelegate? = null
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
binding = ViewSeedReminderBinding.inflate(LayoutInflater.from(context), this, true)
binding.button.setOnClickListener { delegate?.handleSeedReminderViewContinueButtonTapped() }
}
fun setProgress(progress: Int, isAnimated: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.progressBar.setProgress(progress, isAnimated)
} else {
binding.progressBar.progress = progress
}
}
fun hideContinueButton() {
binding.button.visibility = View.GONE
}
}
interface SeedReminderViewDelegate {
fun handleSeedReminderViewContinueButtonTapped()
}

View File

@@ -0,0 +1,240 @@
package org.thoughtcrime.securesms.onboarding.landing
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton
import org.thoughtcrime.securesms.ui.components.PrimaryFillButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.h4
import org.thoughtcrime.securesms.ui.large
import kotlin.time.Duration.Companion.milliseconds
@Preview
@Composable
private fun PreviewLandingScreen(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
LandingScreen({}, {}, {}, {})
}
}
@Composable
internal fun LandingScreen(
createAccount: () -> Unit,
loadAccount: () -> Unit,
openTerms: () -> Unit,
openPrivacyPolicy: () -> Unit,
) {
var count by remember { mutableStateOf(0) }
val listState = rememberLazyListState()
var isUrlDialogVisible by remember { mutableStateOf(false) }
if (isUrlDialogVisible) {
AlertDialog(
onDismissRequest = { isUrlDialogVisible = false },
title = stringResource(R.string.urlOpen),
text = stringResource(R.string.urlOpenBrowser),
buttons = listOf(
DialogButtonModel(
text = GetString(R.string.activity_landing_terms_of_service),
contentDescription = GetString(R.string.AccessibilityId_terms_of_service_button),
onClick = openTerms
),
DialogButtonModel(
text = GetString(R.string.activity_landing_privacy_policy),
contentDescription = GetString(R.string.AccessibilityId_privacy_policy_button),
onClick = openPrivacyPolicy
)
)
)
}
LaunchedEffect(Unit) {
delay(500.milliseconds)
while(count < MESSAGES.size) {
count += 1
listState.animateScrollToItem(0.coerceAtLeast((count - 1)))
delay(1500L)
}
}
Column {
Column(modifier = Modifier
.weight(1f)
.padding(horizontal = LocalDimensions.current.onboardingMargin)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
stringResource(R.string.onboardingBubblePrivacyInYourPocket),
modifier = Modifier.align(Alignment.CenterHorizontally),
style = h4,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(LocalDimensions.current.itemSpacing))
LazyColumn(
state = listState,
modifier = Modifier
.heightIn(min = LocalDimensions.current.minScrollableViewHeight)
.fillMaxWidth()
.weight(3f),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) {
items(
MESSAGES.take(count),
key = { it.stringId }
) { item ->
AnimateMessageText(
stringResource(item.stringId),
item.isOutgoing
)
}
}
Spacer(modifier = Modifier.weight(1f))
}
Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.largeMargin)) {
PrimaryFillButton(
text = stringResource(R.string.onboardingAccountCreate),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.contentDescription(R.string.AccessibilityId_create_account_button),
onClick = createAccount
)
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
PrimaryOutlineButton(
stringResource(R.string.onboardingAccountExists),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.contentDescription(R.string.AccessibilityId_restore_account_button),
onClick = loadAccount
)
BorderlessHtmlButton(
textId = R.string.onboardingTosPrivacy,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.contentDescription(R.string.AccessibilityId_open_url),
onClick = { isUrlDialogVisible = true }
)
Spacer(modifier = Modifier.height(LocalDimensions.current.xxsItemSpacing))
}
}
}
@Composable
private fun AnimateMessageText(text: String, isOutgoing: Boolean, modifier: Modifier = Modifier) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
Box {
// TODO [SES-2077] Use LazyList itemAnimation when we update to compose 1.7 or so.
MessageText(text, isOutgoing, Modifier.alpha(0f))
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(durationMillis = 300)) +
slideInVertically(animationSpec = tween(durationMillis = 300)) { it }
) {
MessageText(text, isOutgoing, modifier)
}
}
}
@Composable
private fun MessageText(text: String, isOutgoing: Boolean, modifier: Modifier) {
Box(modifier = modifier then Modifier.fillMaxWidth()) {
MessageText(
text,
color = if (isOutgoing) LocalColors.current.backgroundBubbleSent else LocalColors.current.backgroundBubbleReceived,
textColor = if (isOutgoing) LocalColors.current.textBubbleSent else LocalColors.current.textBubbleReceived,
modifier = Modifier.align(if (isOutgoing) Alignment.TopEnd else Alignment.TopStart)
)
}
}
@Composable
private fun MessageText(
text: String,
color: Color,
modifier: Modifier = Modifier,
textColor: Color = Color.Unspecified
) {
Card(
modifier = modifier.fillMaxWidth(0.666f),
shape = MaterialTheme.shapes.small,
backgroundColor = color,
elevation = 0.dp
) {
Text(
text,
style = large,
color = textColor,
modifier = Modifier.padding(
horizontal = LocalDimensions.current.smallItemSpacing,
vertical = LocalDimensions.current.xsItemSpacing
)
)
}
}
private data class TextData(
@StringRes val stringId: Int,
val isOutgoing: Boolean = false
)
private val MESSAGES = listOf(
TextData(R.string.onboardingBubbleWelcomeToSession),
TextData(R.string.onboardingBubbleSessionIsEngineered, isOutgoing = true),
TextData(R.string.onboardingBubbleNoPhoneNumber),
TextData(R.string.onboardingBubbleCreatingAnAccountIsEasy, isOutgoing = true)
)

View File

@@ -1,17 +1,26 @@
package org.thoughtcrime.securesms.onboarding package org.thoughtcrime.securesms.onboarding.landing
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import network.loki.messenger.databinding.ActivityLandingBinding import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.onboarding.loadaccount.LoadAccountActivity
import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivity
import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.start
import javax.inject.Inject
@AndroidEntryPoint
class LandingActivity: BaseActionBarActivity() { class LandingActivity: BaseActionBarActivity() {
@Inject
internal lateinit var prefs: TextSecurePreferences
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -19,28 +28,24 @@ class LandingActivity : BaseActionBarActivity() {
// Session then close this activity to resume the last activity from the previous instance. // Session then close this activity to resume the last activity from the previous instance.
if (!isTaskRoot) { finish(); return } if (!isTaskRoot) { finish(); return }
val binding = ActivityLandingBinding.inflate(layoutInflater)
setContentView(binding.root)
setUpActionBarSessionLogo(true) setUpActionBarSessionLogo(true)
with(binding) {
fakeChatView.startAnimating() setComposeContent {
registerButton.setOnClickListener { register() } LandingScreen(
restoreButton.setOnClickListener { link() } createAccount = { startPickDisplayNameActivity() },
linkButton.setOnClickListener { link() } loadAccount = { start<LoadAccountActivity>() },
openTerms = { open("https://getsession.org/terms-of-service") },
openPrivacyPolicy = { open("https://getsession.org/privacy-policy") }
)
} }
IdentityKeyUtil.generateIdentityKeyPair(this) IdentityKeyUtil.generateIdentityKeyPair(this)
TextSecurePreferences.setPasswordDisabled(this, true) TextSecurePreferences.setPasswordDisabled(this, true)
// AC: This is a temporary workaround to trick the old code that the screen is unlocked. // AC: This is a temporary workaround to trick the old code that the screen is unlocked.
KeyCachingService.setMasterSecret(applicationContext, Object()) KeyCachingService.setMasterSecret(applicationContext, Object())
} }
private fun register() { private fun open(url: String) {
val intent = Intent(this, RegisterActivity::class.java) Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity)
push(intent)
}
private fun link() {
val intent = Intent(this, LinkDeviceActivity::class.java)
push(intent)
} }
} }

View File

@@ -0,0 +1,118 @@
package org.thoughtcrime.securesms.onboarding.loadaccount
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.flow.Flow
import network.loki.messenger.R
import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.h4
private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun LoadAccountScreen(
state: State,
qrErrors: Flow<String>,
onChange: (String) -> Unit = {},
onContinue: () -> Unit = {},
onScan: (String) -> Unit = {}
) {
val pagerState = rememberPagerState { TITLES.size }
Column {
SessionTabRow(pagerState, TITLES)
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
when (TITLES[page]) {
R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue)
R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = onScan)
}
}
}
}
@Preview
@Composable
private fun PreviewRecoveryPassword() {
PreviewTheme {
RecoveryPassword(state = State())
}
}
@Composable
private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.weight(1f))
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.largeMargin)
) {
Row {
Text(
text = stringResource(R.string.sessionRecoveryPassword),
style = h4
)
Spacer(Modifier.width(LocalDimensions.current.xxsItemSpacing))
Icon(
modifier = Modifier.align(Alignment.CenterVertically),
painter = painterResource(id = R.drawable.ic_shield_outline),
contentDescription = null,
)
}
Spacer(Modifier.height(LocalDimensions.current.smallItemSpacing))
Text(
stringResource(R.string.activity_link_enter_your_recovery_password_to_load_your_account_if_you_haven_t_saved_it_you_can_find_it_in_your_app_settings),
style = base
)
Spacer(Modifier.height(LocalDimensions.current.itemSpacing))
SessionOutlinedTextField(
text = state.recoveryPhrase,
modifier = Modifier.fillMaxWidth(),
contentDescription = stringResource(R.string.AccessibilityId_recovery_phrase_input),
placeholder = stringResource(R.string.recoveryPasswordEnter),
onChange = onChange,
onContinue = onContinue,
error = state.error,
isTextErrorColor = state.isTextErrorColor
)
}
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
Spacer(Modifier.weight(2f))
ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue)
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.onboarding.loadaccount
import android.os.Bundle
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.start
import javax.inject.Inject
@AndroidEntryPoint
class LoadAccountActivity : BaseActionBarActivity() {
@Inject
internal lateinit var prefs: TextSecurePreferences
@Inject
internal lateinit var loadAccountManager: LoadAccountManager
private val viewModel: LoadAccountViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setTitle(R.string.activity_link_load_account)
prefs.setConfigurationMessageSynced(false)
prefs.setRestorationTime(System.currentTimeMillis())
prefs.setLastProfileUpdateTime(0)
lifecycleScope.launch {
viewModel.events.collect {
loadAccountManager.load(it.mnemonic)
start<MessageNotificationsActivity>()
}
}
setComposeContent {
val state by viewModel.stateFlow.collectAsState()
LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode)
}
}
}

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.onboarding.loadaccount
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InputTooShort
import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InvalidWord
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import javax.inject.Inject
class LoadAccountEvent(val mnemonic: ByteArray)
internal data class State(
val recoveryPhrase: String = "",
val isTextErrorColor: Boolean = false,
val error: String? = null
)
@HiltViewModel
internal class LoadAccountViewModel @Inject constructor(
private val application: Application
): AndroidViewModel(application) {
private val state = MutableStateFlow(State())
val stateFlow = state.asStateFlow()
private val _events = MutableSharedFlow<LoadAccountEvent>()
val events = _events.asSharedFlow()
private val _qrErrors = MutableSharedFlow<Throwable>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val qrErrors = _qrErrors.asSharedFlow()
.mapNotNull { application.getString(R.string.qrNotRecoveryPassword) }
private val codec by lazy { MnemonicCodec { MnemonicUtilities.loadFileContents(getApplication(), it) } }
fun onContinue() {
viewModelScope.launch {
try {
codec.sanitizeAndDecodeAsByteArray(state.value.recoveryPhrase).let(::onSuccess)
} catch (e: Exception) {
onFailure(e)
}
}
}
fun onScanQrCode(string: String) {
viewModelScope.launch {
try {
codec.decodeMnemonicOrHexAsByteArray(string).let(::onSuccess)
} catch (e: Exception) {
onQrCodeScanFailure(e)
}
}
}
fun onChange(recoveryPhrase: String) {
state.update { it.copy(recoveryPhrase = recoveryPhrase, isTextErrorColor = false) }
}
private fun onSuccess(seed: ByteArray) {
viewModelScope.launch { _events.emit(LoadAccountEvent(seed)) }
}
private fun onFailure(error: Throwable) {
state.update {
it.copy(
isTextErrorColor = true,
error = when (error) {
is InvalidWord -> R.string.recoveryPasswordErrorMessageIncorrect
is InputTooShort -> R.string.recoveryPasswordErrorMessageShort
else -> R.string.recoveryPasswordErrorMessageGeneric
}.let(application::getString)
)
}
}
private fun onQrCodeScanFailure(error: Throwable) {
viewModelScope.launch { _qrErrors.emit(error) }
}
}

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.onboarding.loading
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.ProgressArc
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.h7
@Composable
internal fun LoadingScreen(progress: Float) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.weight(1f))
ProgressArc(
progress,
modifier = Modifier.contentDescription(R.string.AccessibilityId_loading_animation)
)
Text(
stringResource(R.string.waitOneMoment),
style = h7
)
Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsItemSpacing))
Text(
stringResource(R.string.loadAccountProgressMessage),
style = base
)
Spacer(modifier = Modifier.weight(2f))
}
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.onboarding.loading
import android.os.Bundle
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.startHomeActivity
import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivity
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LoadingActivity: BaseActionBarActivity() {
@Inject
internal lateinit var configFactory: ConfigFactory
@Inject
internal lateinit var prefs: TextSecurePreferences
private val viewModel: LoadingViewModel by viewModels()
private fun register(loadFailed: Boolean) {
prefs.setLastConfigurationSyncTime(System.currentTimeMillis())
when {
loadFailed -> startPickDisplayNameActivity(loadFailed = true)
else -> startHomeActivity(isNewAccount = false)
}
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
setComposeContent {
val progress by viewModel.progress.collectAsState()
LoadingScreen(progress)
}
lifecycleScope.launch {
viewModel.events.collect {
when (it) {
Event.TIMEOUT -> register(loadFailed = true)
Event.SUCCESS -> register(loadFailed = false)
}
}
}
}
}

View File

@@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.onboarding.loading
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.session.libsession.utilities.TextSecurePreferences
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
enum class State {
LOADING,
SUCCESS,
FAIL
}
private val ANIMATE_TO_DONE_TIME = 500.milliseconds
private val IDLE_DONE_TIME = 1.seconds
private val TIMEOUT_TIME = 15.seconds
private val REFRESH_TIME = 50.milliseconds
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@HiltViewModel
internal class LoadingViewModel @Inject constructor(
val prefs: TextSecurePreferences
): ViewModel() {
private val state = MutableStateFlow(State.LOADING)
private val _progress = MutableStateFlow(0f)
val progress = _progress.asStateFlow()
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
state.flatMapLatest {
when (it) {
State.LOADING -> progress(0f, 1f, TIMEOUT_TIME)
else -> progress(progress.value, 1f, ANIMATE_TO_DONE_TIME)
}
}.buffer(0, BufferOverflow.DROP_OLDEST)
.collectLatest { _progress.value = it }
}
viewModelScope.launch(Dispatchers.IO) {
try {
TextSecurePreferences.events
.filter { it == TextSecurePreferences.CONFIGURATION_SYNCED }
.onStart { emit(TextSecurePreferences.CONFIGURATION_SYNCED) }
.filter { prefs.getConfigurationMessageSynced() }
.timeout(TIMEOUT_TIME)
.collectLatest { onSuccess() }
} catch (e: Exception) {
onFail()
}
}
}
private suspend fun onSuccess() {
withContext(Dispatchers.Main) {
state.value = State.SUCCESS
delay(IDLE_DONE_TIME)
_events.emit(Event.SUCCESS)
}
}
private suspend fun onFail() {
withContext(Dispatchers.Main) {
state.value = State.FAIL
delay(IDLE_DONE_TIME)
_events.emit(Event.TIMEOUT)
}
}
}
sealed interface Event {
object SUCCESS: Event
object TIMEOUT: Event
}
private fun progress(
init: Float,
target: Float,
time: Duration,
refreshRate: Duration = REFRESH_TIME
): Flow<Float> = flow {
val startMs = System.currentTimeMillis()
val timeMs = time.inWholeMilliseconds
val finishMs = startMs + timeMs
val range = target - init
generateSequence { System.currentTimeMillis() }.takeWhile { it < finishMs }.forEach {
emit((it - startMs) * range / timeMs + init)
delay(refreshRate)
}
emit(target)
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.onboarding.manager
import android.app.Application
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CreateAccountManager @Inject constructor(
private val application: Application,
private val prefs: TextSecurePreferences,
private val configFactory: ConfigFactory,
) {
private val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
fun createAccount(displayName: String) {
prefs.setProfileName(displayName)
configFactory.user?.setName(displayName)
// 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 keyPairGenerationResult = KeyPairUtilities.generate()
val seed = keyPairGenerationResult.seed
val ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(application, seed, ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
prefs.setLocalRegistrationId(registrationID)
prefs.setLocalNumber(userHexEncodedPublicKey)
prefs.setRestorationTime(0)
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.onboarding.manager
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoadAccountManager @Inject constructor(
@dagger.hilt.android.qualifiers.ApplicationContext private val context: Context,
private val configFactory: ConfigFactory,
private val prefs: TextSecurePreferences
) {
private val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private var restoreJob: Job? = null
private val scope = CoroutineScope(Dispatchers.IO)
fun load(seed: ByteArray) {
// only have one sync job running at a time (prevent QR from trying to spawn a new job)
if (restoreJob?.isActive == true) return
restoreJob = scope.launch {
// 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()
// RestoreActivity handles seed this way
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(context, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = org.session.libsignal.utilities.KeyHelper.generateRegistrationId(false)
prefs.apply {
setLocalRegistrationId(registrationID)
setLocalNumber(userHexEncodedPublicKey)
setRestorationTime(System.currentTimeMillis())
setHasViewedSeed(true)
}
ApplicationContext.getInstance(context).retrieveUserProfile()
}
}
}

View File

@@ -0,0 +1,149 @@
package org.thoughtcrime.securesms.onboarding.messagenotifications
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel.UiState
import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.color.Colors
import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator
import org.thoughtcrime.securesms.ui.components.RadioButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.h4
import org.thoughtcrime.securesms.ui.h8
import org.thoughtcrime.securesms.ui.h9
import org.thoughtcrime.securesms.ui.small
@Composable
internal fun MessageNotificationsScreen(
state: UiState = UiState(),
setEnabled: (Boolean) -> Unit = {},
onContinue: () -> Unit = {},
quit: () -> Unit = {},
dismissDialog: () -> Unit = {}
) {
if (state.clearData) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(LocalColors.current.primary)
}
return
}
if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit)
Column {
Spacer(Modifier.weight(1f))
Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.onboardingMargin)) {
Text(stringResource(R.string.notificationsMessage), style = h4)
Spacer(Modifier.height(LocalDimensions.current.xsMargin))
Text(stringResource(R.string.onboardingMessageNotificationExplaination), style = base)
Spacer(Modifier.height(LocalDimensions.current.itemSpacing))
}
NotificationRadioButton(
R.string.activity_pn_mode_fast_mode,
R.string.activity_pn_mode_fast_mode_explanation,
modifier = Modifier.contentDescription(R.string.AccessibilityId_fast_mode_notifications_button),
tag = R.string.activity_pn_mode_recommended_option_tag,
checked = state.pushEnabled,
onClick = { setEnabled(true) }
)
// spacing between buttons is provided by ripple/downstate of NotificationRadioButton
NotificationRadioButton(
R.string.activity_pn_mode_slow_mode,
R.string.activity_pn_mode_slow_mode_explanation,
modifier = Modifier.contentDescription(R.string.AccessibilityId_slow_mode_notifications_button),
checked = state.pushDisabled,
onClick = { setEnabled(false) }
)
Spacer(Modifier.weight(1f))
ContinuePrimaryOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue)
}
}
@Composable
private fun NotificationRadioButton(
@StringRes title: Int,
@StringRes explanation: Int,
modifier: Modifier = Modifier,
@StringRes tag: Int? = null,
checked: Boolean = false,
onClick: () -> Unit = {}
) {
RadioButton(
onClick = onClick,
modifier = modifier,
checked = checked,
contentPadding = PaddingValues(horizontal = LocalDimensions.current.margin, vertical = 7.dp)
) {
Box(
modifier = Modifier
.weight(1f)
.border(
LocalDimensions.current.borderStroke,
LocalColors.current.borders,
RoundedCornerShape(8.dp)
),
) {
Column(modifier = Modifier
.padding(horizontal = 15.dp)
.padding(top = 10.dp, bottom = 11.dp)) {
Text(stringResource(title), style = h8)
Text(stringResource(explanation), style = small, modifier = Modifier.padding(top = 7.dp))
tag?.let {
Text(
stringResource(it),
modifier = Modifier.padding(top = 6.dp),
color = LocalColors.current.primary,
style = h9
)
}
}
}
}
}
@Preview
@Composable
private fun MessageNotificationsScreenPreview(
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(colors) {
MessageNotificationsScreen()
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.onboarding.messagenotifications
import android.app.Activity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.startHomeActivity
import org.thoughtcrime.securesms.onboarding.loading.LoadingActivity
import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity.Companion.EXTRA_PROFILE_NAME
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.start
import javax.inject.Inject
@AndroidEntryPoint
class MessageNotificationsActivity : BaseActionBarActivity() {
companion object {
const val EXTRA_PROFILE_NAME = "EXTRA_PROFILE_NAME"
}
@Inject
internal lateinit var viewModelFactory: MessageNotificationsViewModel.AssistedFactory
@Inject lateinit var prefs: TextSecurePreferences
@Inject lateinit var loadAccountManager: LoadAccountManager
val profileName by lazy { intent.getStringExtra(EXTRA_PROFILE_NAME) }
private val viewModel: MessageNotificationsViewModel by viewModels {
viewModelFactory.create(profileName)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
setComposeContent { MessageNotificationsScreen() }
lifecycleScope.launch {
viewModel.events.collect {
when (it) {
Event.Loading -> start<LoadingActivity>()
Event.OnboardingComplete -> startHomeActivity(isNewAccount = true)
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (viewModel.onBackPressed()) return
@Suppress("DEPRECATION")
super.onBackPressed()
}
@Composable
private fun MessageNotificationsScreen() {
val uiState by viewModel.uiStates.collectAsState()
MessageNotificationsScreen(
uiState,
setEnabled = viewModel::setEnabled,
onContinue = viewModel::onContinue,
quit = viewModel::quit,
dismissDialog = viewModel::dismissDialog
)
}
}
fun Activity.startMessageNotificationsActivity(profileName: String) {
start<MessageNotificationsActivity> { putExtra(EXTRA_PROFILE_NAME, profileName) }
}

View File

@@ -0,0 +1,120 @@
package org.thoughtcrime.securesms.onboarding.messagenotifications
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.onboarding.manager.CreateAccountManager
internal class MessageNotificationsViewModel(
private val state: State,
private val application: Application,
private val prefs: TextSecurePreferences,
private val pushRegistry: PushRegistry,
private val createAccountManager: CreateAccountManager
): AndroidViewModel(application) {
private val _uiStates = MutableStateFlow(UiState())
val uiStates = _uiStates.asStateFlow()
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
fun setEnabled(enabled: Boolean) {
_uiStates.update { UiState(pushEnabled = enabled) }
}
fun onContinue() {
viewModelScope.launch(Dispatchers.IO) {
if (state is State.CreateAccount) createAccountManager.createAccount(state.displayName)
prefs.setPushEnabled(uiStates.value.pushEnabled)
pushRegistry.refresh(true)
_events.emit(
when (state) {
is State.CreateAccount -> Event.OnboardingComplete
else -> Event.Loading
}
)
}
}
/**
* @return [true] if the back press was handled.
*/
fun onBackPressed(): Boolean = when (state) {
is State.CreateAccount -> false
is State.LoadAccount -> {
_uiStates.update { it.copy(showDialog = true) }
true
}
}
fun dismissDialog() {
_uiStates.update { it.copy(showDialog = false) }
}
fun quit() {
_uiStates.update { it.copy(clearData = true) }
viewModelScope.launch(Dispatchers.IO) {
ApplicationContext.getInstance(application).clearAllData()
}
}
data class UiState(
val pushEnabled: Boolean = true,
val showDialog: Boolean = false,
val clearData: Boolean = false
) {
val pushDisabled get() = !pushEnabled
}
sealed interface State {
class CreateAccount(val displayName: String): State
object LoadAccount: State
}
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(profileName: String?): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val profileName: String?,
private val application: Application,
private val prefs: TextSecurePreferences,
private val pushRegistry: PushRegistry,
private val createAccountManager: CreateAccountManager,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MessageNotificationsViewModel(
state = profileName?.let(State::CreateAccount) ?: State.LoadAccount,
application = application,
prefs = prefs,
pushRegistry = pushRegistry,
createAccountManager = createAccountManager
) as T
}
}
}
enum class Event {
OnboardingComplete, Loading
}

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.onboarding.pickname
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.R
import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog
import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.h4
@Preview
@Composable
private fun PreviewPickDisplayName() {
PreviewTheme {
PickDisplayName(State())
}
}
@Composable
internal fun PickDisplayName(
state: State,
onChange: (String) -> Unit = {},
onContinue: () -> Unit = {},
dismissDialog: () -> Unit = {},
quit: () -> Unit = {}
) {
if (state.showDialog) OnboardingBackPressAlertDialog(
dismissDialog,
R.string.you_cannot_go_back_further_cancel_account_creation,
quit
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.weight(1f))
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.largeMargin)
) {
Text(stringResource(state.title), style = h4)
Spacer(Modifier.height(LocalDimensions.current.smallItemSpacing))
Text(
stringResource(state.description),
style = base,
modifier = Modifier.padding(bottom = LocalDimensions.current.xsItemSpacing))
Spacer(Modifier.height(LocalDimensions.current.itemSpacing))
SessionOutlinedTextField(
text = state.displayName,
modifier = Modifier.fillMaxWidth(),
contentDescription = stringResource(R.string.AccessibilityId_enter_display_name),
placeholder = stringResource(R.string.displayNameEnter),
onChange = onChange,
onContinue = onContinue,
error = state.error?.let { stringResource(it) },
isTextErrorColor = state.isTextErrorColor
)
}
Spacer(modifier = Modifier.height(LocalDimensions.current.smallItemSpacing))
Spacer(Modifier.weight(2f))
ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue)
}
}

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.onboarding.pickname
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.startHomeActivity
import org.thoughtcrime.securesms.onboarding.messagenotifications.startMessageNotificationsActivity
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
private const val EXTRA_LOAD_FAILED = "extra_load_failed"
@AndroidEntryPoint
class PickDisplayNameActivity : BaseActionBarActivity() {
@Inject
internal lateinit var viewModelFactory: PickDisplayNameViewModel.AssistedFactory
@Inject
internal lateinit var prefs: TextSecurePreferences
private val loadFailed get() = intent.getBooleanExtra(EXTRA_LOAD_FAILED, false)
private val viewModel: PickDisplayNameViewModel by viewModels {
viewModelFactory.create(loadFailed)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
setComposeContent { DisplayNameScreen(viewModel) }
lifecycleScope.launch(Dispatchers.Main) {
viewModel.events.collect {
when (it) {
is Event.CreateAccount -> startMessageNotificationsActivity(it.profileName)
Event.LoadAccountComplete -> startHomeActivity(isNewAccount = false)
}
}
}
}
@Composable
private fun DisplayNameScreen(viewModel: PickDisplayNameViewModel) {
PickDisplayName(
viewModel.states.collectAsState().value,
viewModel::onChange,
viewModel::onContinue,
viewModel::dismissDialog,
quit = { viewModel.dismissDialog(); finish() }
)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (viewModel.onBackPressed()) return
@Suppress("DEPRECATION")
super.onBackPressed()
}
}
fun Context.startPickDisplayNameActivity(loadFailed: Boolean = false, flags: Int = 0) {
Intent(this, PickDisplayNameActivity::class.java)
.apply { putExtra(EXTRA_LOAD_FAILED, loadFailed) }
.also { it.flags = flags }
.also(::startActivity)
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.onboarding.pickname
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel
internal class PickDisplayNameViewModel(
private val loadFailed: Boolean,
private val prefs: TextSecurePreferences,
private val configFactory: ConfigFactory
): ViewModel() {
private val isCreateAccount = !loadFailed
private val _states = MutableStateFlow(if (loadFailed) pickNewNameState() else State())
val states = _states.asStateFlow()
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
fun onContinue() {
_states.update { it.copy(displayName = it.displayName.trim()) }
val displayName = _states.value.displayName
when {
displayName.isEmpty() -> { _states.update { it.copy(isTextErrorColor = true, error = R.string.displayNameErrorDescription) } }
displayName.toByteArray().size > NAME_PADDED_LENGTH -> { _states.update { it.copy(isTextErrorColor = true, error = R.string.displayNameErrorDescriptionShorter) } }
else -> {
// success - clear the error as we can still see it during the transition to the
// next screen.
_states.update { it.copy(isTextErrorColor = false, error = null) }
viewModelScope.launch(Dispatchers.IO) {
if (loadFailed) {
prefs.setProfileName(displayName)
configFactory.user?.setName(displayName)
_events.emit(Event.LoadAccountComplete)
} else _events.emit(Event.CreateAccount(displayName))
}
}
}
}
fun onChange(value: String) {
_states.update { state ->
state.copy(
displayName = value,
isTextErrorColor = false
)
}
}
/**
* @return [true] if the back press was handled.
*/
fun onBackPressed(): Boolean = isCreateAccount.also {
if (it) _states.update { it.copy(showDialog = true) }
}
fun dismissDialog() {
_states.update { it.copy(showDialog = false) }
}
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(loadFailed: Boolean): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val loadFailed: Boolean,
private val prefs: TextSecurePreferences,
private val configFactory: ConfigFactory
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PickDisplayNameViewModel(loadFailed, prefs, configFactory) as T
}
}
}
data class State(
@StringRes val title: Int = R.string.displayNamePick,
@StringRes val description: Int = R.string.displayNameDescription,
val showDialog: Boolean = false,
val isTextErrorColor: Boolean = false,
@StringRes val error: Int? = null,
val displayName: String = ""
)
fun pickNewNameState() = State(
title = R.string.displayNameNew,
description = R.string.displayNameErrorNew
)
sealed interface Event {
class CreateAccount(val profileName: String): Event
object LoadAccountComplete: Event
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.onboarding.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
@Composable
fun ContinuePrimaryOutlineButton(modifier: Modifier, onContinue: () -> Unit) {
PrimaryOutlineButton(
stringResource(R.string.continue_2),
modifier = modifier
.contentDescription(R.string.AccessibilityId_continue)
.fillMaxWidth()
.padding(horizontal = LocalDimensions.current.largeMargin)
.padding(bottom = LocalDimensions.current.xxsMargin),
onClick = onContinue,
)
}

View File

@@ -125,7 +125,7 @@ class ClearAllDataDialog : DialogFragment() {
} }
return return
} }
ApplicationContext.getInstance(context).clearAllData(false).let { success -> ApplicationContext.getInstance(context).clearAllData().let { success ->
withContext(Main) { withContext(Main) {
if (success) { if (success) {
dismiss() dismiss()
@@ -162,7 +162,7 @@ class ClearAllDataDialog : DialogFragment() {
} }
else if (deletionResultMap.values.all { it }) { else if (deletionResultMap.values.all { it }) {
// ..otherwise if the network data deletion was successful proceed to delete the local data as well. // ..otherwise if the network data deletion was successful proceed to delete the local data as well.
ApplicationContext.getInstance(context).clearAllData(false) ApplicationContext.getInstance(context).clearAllData()
withContext(Main) { dismiss() } withContext(Main) { dismiss() }
} }
} }

View File

@@ -4,7 +4,6 @@ import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment
@AndroidEntryPoint @AndroidEntryPoint
class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() { class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() {

View File

@@ -1,141 +1,115 @@
package org.thoughtcrime.securesms.preferences package org.thoughtcrime.securesms.preferences
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.os.Environment import androidx.compose.foundation.ExperimentalFoundationApi
import android.view.LayoutInflater import androidx.compose.foundation.background
import android.view.View import androidx.compose.foundation.layout.Column
import android.view.ViewGroup import androidx.compose.foundation.layout.fillMaxSize
import android.widget.Toast import androidx.compose.foundation.layout.padding
import androidx.fragment.app.Fragment import androidx.compose.foundation.pager.HorizontalPager
import androidx.fragment.app.FragmentPagerAdapter import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityQrCodeBinding
import network.loki.messenger.databinding.FragmentViewMyQrCodeBinding
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
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.PublicKeyValidation import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.database.threadDatabase
import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.util.QRCodeUtilities import org.thoughtcrime.securesms.ui.color.LocalColors
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.ui.components.SessionTabRow
import java.io.File import org.thoughtcrime.securesms.ui.contentDescription
import java.io.FileOutputStream import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.ui.small
import org.thoughtcrime.securesms.util.start
class QRCodeActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { private val TITLES = listOf(R.string.view, R.string.scan)
private lateinit var binding: ActivityQrCodeBinding
private val adapter = QRCodeActivityAdapter(this) class QRCodeActivity : PassphraseRequiredActionBarActivity() {
private val errors = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivityQrCodeBinding.inflate(layoutInflater)
// Set content view
setContentView(binding.root)
// Set title
supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title) supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title)
// Set up view pager
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
}
// endregion
// region Interaction setComposeContent {
override fun handleQRCodeScanned(hexEncodedPublicKey: String) { Tabs(
createPrivateChatIfPossible(hexEncodedPublicKey) TextSecurePreferences.getLocalNumber(this)!!,
errors.asSharedFlow(),
onScan = ::onScan
)
}
} }
fun createPrivateChatIfPossible(hexEncodedPublicKey: String) { fun onScan(string: String) {
if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.invalid_session_id, Toast.LENGTH_SHORT).show() } if (!PublicKeyValidation.isValid(string)) {
val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id))
val intent = Intent(this, ConversationActivityV2::class.java) } else if (!isFinishing) {
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) val recipient = Recipient.from(this, Address.fromSerialized(string), false)
intent.setDataAndType(getIntent().data, getIntent().type) start<ConversationActivityV2> {
val existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient) putExtra(ConversationActivityV2.ADDRESS, recipient.address)
intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) setDataAndType(intent.data, intent.type)
startActivity(intent) val existingThread = threadDatabase().getThreadIdIfExistsFor(recipient)
putExtra(ConversationActivityV2.THREAD_ID, existingThread)
}
finish() finish()
} }
// endregion
}
// region Adapter
private class QRCodeActivityAdapter(val activity: QRCodeActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> ViewMyQRCodeFragment()
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = activity.resources.getString(R.string.activity_qr_code_view_scan_qr_code_explanation)
result
}
else -> throw IllegalStateException()
} }
} }
override fun getPageTitle(index: Int): CharSequence? { @OptIn(ExperimentalFoundationApi::class)
return when (index) { @Composable
0 -> activity.resources.getString(R.string.activity_qr_code_view_my_qr_code_tab_title) private fun Tabs(accountId: String, errors: Flow<String>, onScan: (String) -> Unit) {
1 -> activity.resources.getString(R.string.activity_qr_code_view_scan_qr_code_tab_title) val pagerState = rememberPagerState { TITLES.size }
else -> throw IllegalStateException()
}
}
}
// endregion
// region View My QR Code Fragment Column {
class ViewMyQRCodeFragment : Fragment() { SessionTabRow(pagerState, TITLES)
private lateinit var binding: FragmentViewMyQrCodeBinding HorizontalPager(
state = pagerState,
private val hexEncodedPublicKey: String modifier = Modifier.weight(1f)
get() { ) { page ->
return TextSecurePreferences.getLocalNumber(requireContext())!! when (TITLES[page]) {
R.string.view -> QrPage(accountId)
R.string.scan -> MaybeScanQrCode(errors, onScan = onScan)
}
}
}
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @Composable
binding = FragmentViewMyQrCodeBinding.inflate(inflater, container, false) fun QrPage(string: String) {
return binding.root Column(
} modifier = Modifier
.background(LocalColors.current.backgroundSecondary)
.padding(horizontal = LocalDimensions.current.margin)
.fillMaxSize()
) {
QrImage(
string = string,
modifier = Modifier
.padding(top = LocalDimensions.current.margin, bottom = LocalDimensions.current.xxsMargin)
.contentDescription(R.string.AccessibilityId_qr_code),
icon = R.drawable.session
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { Text(
super.onViewCreated(view, savedInstanceState) text = stringResource(R.string.this_is_your_account_id_other_users_can_scan_it_to_start_a_conversation_with_you),
val size = toPx(280, resources) color = LocalColors.current.textSecondary,
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false) textAlign = TextAlign.Center,
binding.qrCodeImageView.setImageBitmap(qrCode) style = small
// val explanation = SpannableStringBuilder("This is your unique public QR code. Other users can scan this to start a conversation with you.") )
// explanation.setSpan(StyleSpan(Typeface.BOLD), 8, 34, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.explanationTextView.text = resources.getString(R.string.fragment_view_my_qr_code_explanation)
binding.shareButton.setOnClickListener { shareQRCode() }
}
private fun shareQRCode() {
val directory = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val fileName = "$hexEncodedPublicKey.png"
val file = File(directory, fileName)
file.createNewFile()
val fos = FileOutputStream(file)
val size = toPx(280, resources)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false)
qrCode.compress(Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
val intent = Intent(Intent.ACTION_SEND)
intent.putExtra(Intent.EXTRA_STREAM, FileProviderUtil.getUriFor(requireActivity(), file))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.type = "image/png"
startActivity(Intent.createChooser(intent, resources.getString(R.string.fragment_view_my_qr_code_share_title)))
} }
} }
// endregion

View File

@@ -10,7 +10,6 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ItemSelectableBinding import network.loki.messenger.databinding.ItemSelectableBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import java.util.Objects import java.util.Objects
@@ -68,7 +67,6 @@ class RadioOptionAdapter<T>(
} }
} }
} }
} }
data class RadioOption<out T>( data class RadioOption<out T>(

View File

@@ -1,41 +0,0 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
class SeedDialog: DialogFragment() {
private val seed by lazy {
val hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED)
?: IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account
MnemonicCodec { fileName -> MnemonicUtilities.loadFileContents(requireContext(), fileName) }
.encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_seed_title)
text(R.string.dialog_seed_explanation)
text(seed, R.style.SessionIDTextView)
button(R.string.copy, R.string.AccessibilityId_copy_recovery_phrase) { copySeed() }
button(R.string.close) { dismiss() }
}
private fun copySeed() {
val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Seed", seed)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
dismiss()
}
}

View File

@@ -2,15 +2,13 @@ package org.thoughtcrime.securesms.preferences
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ClipData import android.content.BroadcastReceiver
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.AsyncTask import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Parcelable import android.os.Parcelable
import android.util.SparseArray import android.util.SparseArray
import android.view.ActionMode import android.view.ActionMode
@@ -20,26 +18,49 @@ import android.view.View
import android.view.inputmethod.EditorInfo 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.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File import kotlinx.coroutines.Dispatchers
import java.security.SecureRandom import kotlinx.coroutines.channels.awaitClose
import javax.inject.Inject import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding import network.loki.messenger.databinding.ActivitySettingsBinding
import network.loki.messenger.libsession_util.util.UserPic import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.* import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.ProfilePictureUtilities
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.avatar.AvatarSelection
@@ -47,19 +68,30 @@ import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.Cell
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable
import org.thoughtcrime.securesms.ui.LocalDimensions
import org.thoughtcrime.securesms.ui.color.destructiveButtonColors
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.disableClipping
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 java.io.File
import java.security.SecureRandom
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() { class SettingsActivity : PassphraseRequiredActionBarActivity() {
@@ -67,20 +99,17 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Inject @Inject
lateinit var configFactory: ConfigFactory lateinit var configFactory: ConfigFactory
@Inject
lateinit var prefs: TextSecurePreferences
private lateinit var binding: ActivitySettingsBinding private lateinit var binding: ActivitySettingsBinding
private var displayNameEditActionMode: ActionMode? = null private var displayNameEditActionMode: ActionMode? = null
set(value) { field = value; handleDisplayNameEditActionModeChanged() } set(value) { field = value; handleDisplayNameEditActionModeChanged() }
private lateinit var glide: GlideRequests
private var tempFile: File? = null private var tempFile: File? = null
private val hexEncodedPublicKey: String private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!!
get() {
return TextSecurePreferences.getLocalNumber(this)!!
}
companion object { companion object {
const val updatedProfileResultCode = 1234
private const val SCROLL_STATE = "SCROLL_STATE" private const val SCROLL_STATE = "SCROLL_STATE"
} }
@@ -89,31 +118,24 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivitySettingsBinding.inflate(layoutInflater) binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val displayName = getDisplayName() }
glide = GlideApp.with(this)
with(binding) { override fun onStart() {
super.onStart()
binding.run {
setupProfilePictureView(profilePictureView) setupProfilePictureView(profilePictureView)
profilePictureView.setOnClickListener { showEditProfilePictureUI() } profilePictureView.setOnClickListener { showEditProfilePictureUI() }
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
btnGroupNameDisplay.text = displayName btnGroupNameDisplay.text = getDisplayName()
publicKeyTextView.text = hexEncodedPublicKey publicKeyTextView.text = hexEncodedPublicKey
copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() }
pathButton.setOnClickListener { showPath() }
pathContainer.disableClipping()
privacyButton.setOnClickListener { showPrivacySettings() }
notificationsButton.setOnClickListener { showNotificationSettings() }
messageRequestsButton.setOnClickListener { showMessageRequests() }
chatsButton.setOnClickListener { showChatSettings() }
appearanceButton.setOnClickListener { showAppearanceSettings() }
inviteFriendButton.setOnClickListener { sendInvitation() }
helpButton.setOnClickListener { showHelp() }
seedButton.setOnClickListener { showSeed() }
clearAllDataButton.setOnClickListener { clearAllData() }
val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6)
versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)") versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)")
} }
binding.composeView.setThemedContent {
Buttons()
}
} }
private fun getDisplayName(): String = private fun getDisplayName(): String =
@@ -143,13 +165,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.settings_general, menu) menuInflater.inflate(R.menu.settings_general, menu)
if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
menu.findItem(R.id.action_qr_code)?.contentDescription = resources.getString(R.string.AccessibilityId_view_qr_code)
}
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_qr_code -> { R.id.action_qr_code -> {
showQRCode() push<QRCodeActivity>()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@@ -159,30 +184,22 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) return
when (requestCode) { when (requestCode) {
AvatarSelection.REQUEST_CODE_AVATAR -> { AvatarSelection.REQUEST_CODE_AVATAR -> {
if (resultCode != Activity.RESULT_OK) {
return
}
val outputFile = Uri.fromFile(File(cacheDir, "cropped")) val outputFile = Uri.fromFile(File(cacheDir, "cropped"))
var inputFile: Uri? = data?.data val inputFile: Uri? = data?.data ?: tempFile?.let(Uri::fromFile)
if (inputFile == null && tempFile != null) {
inputFile = Uri.fromFile(tempFile)
}
AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar) AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar)
} }
AvatarSelection.REQUEST_CODE_CROP_IMAGE -> { AvatarSelection.REQUEST_CODE_CROP_IMAGE -> {
if (resultCode != Activity.RESULT_OK) { lifecycleScope.launch(Dispatchers.IO) {
return
}
AsyncTask.execute {
try { try {
val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
Handler(Looper.getMainLooper()).post { launch(Dispatchers.Main) {
updateProfilePicture(profilePictureToBeUploaded) updateProfilePicture(profilePictureToBeUploaded)
} }
} catch (e: BitmapDecodingException) { } catch (e: BitmapDecodingException) {
e.printStackTrace() Log.e(TAG, e)
} }
} }
} }
@@ -197,10 +214,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
// region Updating // region Updating
private fun handleDisplayNameEditActionModeChanged() { private fun handleDisplayNameEditActionModeChanged() {
val isEditingDisplayName = this.displayNameEditActionMode !== null val isEditingDisplayName = this.displayNameEditActionMode != null
binding.btnGroupNameDisplay.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE binding.btnGroupNameDisplay.isInvisible = isEditingDisplayName
binding.displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE binding.displayNameEditText.isInvisible = !isEditingDisplayName
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (isEditingDisplayName) { if (isEditingDisplayName) {
@@ -277,11 +294,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
val userConfig = configFactory.user val userConfig = configFactory.user
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, profilePicture.let { SecureRandom().nextInt() } ) prefs.setProfileAvatarId(SecureRandom().nextInt() )
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// Attempt to grab the details we require to update the profile picture // Attempt to grab the details we require to update the profile picture
val url = TextSecurePreferences.getProfilePictureURL(this) val url = prefs.getProfilePictureURL()
val profileKey = ProfileKeyUtil.getProfileKey(this) val profileKey = ProfileKeyUtil.getProfileKey(this)
// If we have a URL and a profile key then set the user's profile picture // If we have a URL and a profile key then set the user's profile picture
@@ -354,24 +371,19 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show()
return false return false
} }
if (displayName.toByteArray().size > ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH) { if (displayName.toByteArray().size > ProfileManagerProtocol.NAME_PADDED_LENGTH) {
Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show()
return false return false
} }
return updateDisplayName(displayName) return updateDisplayName(displayName)
} }
private fun showQRCode() {
val intent = Intent(this, QRCodeActivity::class.java)
push(intent)
}
private fun showEditProfilePictureUI() { private fun showEditProfilePictureUI() {
showSessionDialog { showSessionDialog {
title(R.string.activity_settings_set_display_picture) title(R.string.activity_settings_set_display_picture)
view(R.layout.dialog_change_avatar) view(R.layout.dialog_change_avatar)
button(R.string.activity_settings_upload) { startAvatarSelection() } button(R.string.activity_settings_upload) { startAvatarSelection() }
if (TextSecurePreferences.getProfileAvatarId(context) != 0) { if (prefs.getProfileAvatarId() != 0) {
button(R.string.activity_settings_remove) { removeProfilePicture() } button(R.string.activity_settings_remove) { removeProfilePicture() }
} }
cancelButton() cancelButton()
@@ -399,76 +411,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
} }
.execute() .execute()
} }
private fun copyPublicKey() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
private fun sharePublicKey() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey)
intent.type = "text/plain"
val chooser = Intent.createChooser(intent, getString(R.string.share))
startActivity(chooser)
}
private fun showPrivacySettings() {
val intent = Intent(this, PrivacySettingsActivity::class.java)
push(intent)
}
private fun showNotificationSettings() {
val intent = Intent(this, NotificationSettingsActivity::class.java)
push(intent)
}
private fun showMessageRequests() {
val intent = Intent(this, MessageRequestsActivity::class.java)
push(intent)
}
private fun showChatSettings() {
val intent = Intent(this, ChatSettingsActivity::class.java)
push(intent)
}
private fun showAppearanceSettings() {
val intent = Intent(this, AppearanceSettingsActivity::class.java)
push(intent)
}
private fun sendInvitation() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
val invitation = "Hey, I've been using Session to chat with complete privacy and security. Come join me! Download it at https://getsession.org/. My Session ID is $hexEncodedPublicKey !"
intent.putExtra(Intent.EXTRA_TEXT, invitation)
intent.type = "text/plain"
val chooser = Intent.createChooser(intent, getString(R.string.activity_settings_invite_button_title))
startActivity(chooser)
}
private fun showHelp() {
val intent = Intent(this, HelpSettingsActivity::class.java)
push(intent)
}
private fun showPath() {
val intent = Intent(this, PathActivity::class.java)
show(intent)
}
private fun showSeed() {
SeedDialog().show(supportFragmentManager, "Recovery Phrase Dialog")
}
private fun clearAllData() {
ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog")
}
// endregion // endregion
private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { private inner class DisplayNameEditActionModeCallback: ActionMode.Callback {
@@ -497,7 +439,74 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
return true return true
} }
} }
return false; return false
}
}
@Composable
fun Buttons() {
Column {
Row(
modifier = Modifier
.padding(horizontal = LocalDimensions.current.smallMargin)
.padding(top = LocalDimensions.current.xxxsMargin),
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing),
) {
PrimaryOutlineButton(
stringResource(R.string.share),
modifier = Modifier.weight(1f),
onClick = ::sendInvitationToUseSession
)
PrimaryOutlineCopyButton(
modifier = Modifier.weight(1f),
onClick = ::copyPublicKey,
)
}
Spacer(modifier = Modifier.height(LocalDimensions.current.itemSpacing))
val hasPaths by hasPaths().collectAsState(initial = false)
Cell {
Column {
Crossfade(if (hasPaths) R.drawable.ic_status else R.drawable.ic_path_yellow, label = "path") {
LargeItemButtonWithDrawable(R.string.activity_path_title, it) { show<PathActivity>() }
}
Divider()
LargeItemButton(R.string.activity_settings_privacy_button_title, R.drawable.ic_privacy_icon) { show<PrivacySettingsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_notifications_button_title, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { show<NotificationSettingsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_conversations_button_title, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_conversations)) { show<ChatSettingsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_message_requests_button_title, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_message_requests)) { show<MessageRequestsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_message_appearance_button_title, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_appearance)) { show<AppearanceSettingsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_invite_button_title, R.drawable.ic_invite_friend, Modifier.contentDescription(R.string.AccessibilityId_invite_friend)) { sendInvitationToUseSession() }
Divider()
if (!prefs.getHidePassword()) {
LargeItemButton(R.string.sessionRecoveryPassword, R.drawable.ic_shield_outline, Modifier.contentDescription(R.string.AccessibilityId_recovery_password_menu_item)) { show<RecoveryPasswordActivity>() }
Divider()
}
LargeItemButton(R.string.activity_settings_help_button, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { show<HelpSettingsActivity>() }
Divider()
LargeItemButton(R.string.activity_settings_clear_all_data_button_title, R.drawable.ic_clear_data, Modifier.contentDescription(R.string.AccessibilityId_clear_data), destructiveButtonColors()) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") }
} }
} }
} }
}
}
private fun Context.hasPaths(): Flow<Boolean> = LocalBroadcastManager.getInstance(this).hasPaths()
private fun LocalBroadcastManager.hasPaths(): Flow<Boolean> = callbackFlow {
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { trySend(Unit) }
}
registerReceiver(receiver, IntentFilter("buildingPaths"))
registerReceiver(receiver, IntentFilter("pathsBuilt"))
awaitClose { unregisterReceiver(receiver) }
}.onStart { emit(Unit) }.map { OnionRequestAPI.paths.isNotEmpty() }

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.preferences
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
fun Context.sendInvitationToUseSession() {
Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
getString(
R.string.accountIdShare,
TextSecurePreferences.getLocalNumber(this@sendInvitationToUseSession)
)
)
type = "text/plain"
}.let { Intent.createChooser(it, getString(R.string.activity_settings_invite_button_title)) }
.let(::startActivity)
}
fun Context.copyPublicKey() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Account ID", TextSecurePreferences.getLocalNumber(this))
clipboard.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}

View File

@@ -5,7 +5,6 @@ import android.os.Parcelable
import android.util.SparseArray import android.util.SparseArray
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.SwitchCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -31,8 +30,8 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
var currentTheme: ThemeState? = null var currentTheme: ThemeState? = null
private val accentColors private val accentColors by lazy {
get() = mapOf( mapOf(
binding.accentGreen to R.style.PrimaryGreen, binding.accentGreen to R.style.PrimaryGreen,
binding.accentBlue to R.style.PrimaryBlue, binding.accentBlue to R.style.PrimaryBlue,
binding.accentYellow to R.style.PrimaryYellow, binding.accentYellow to R.style.PrimaryYellow,
@@ -41,9 +40,10 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
binding.accentOrange to R.style.PrimaryOrange, binding.accentOrange to R.style.PrimaryOrange,
binding.accentRed to R.style.PrimaryRed binding.accentRed to R.style.PrimaryRed
) )
}
private val themeViews private val themeViews by lazy {
get() = listOf( listOf(
binding.themeOptionClassicDark, binding.themeOptionClassicDark,
binding.themeRadioClassicDark, binding.themeRadioClassicDark,
binding.themeOptionClassicLight, binding.themeOptionClassicLight,
@@ -53,6 +53,7 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
binding.themeOptionOceanLight, binding.themeOptionOceanLight,
binding.themeRadioOceanLight binding.themeRadioOceanLight
) )
}
override fun onClick(v: View?) { override fun onClick(v: View?) {
v ?: return v ?: return

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