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 'androidx.compose.ui:ui:1.5.2'
implementation 'androidx.compose.ui:ui-tooling:1.5.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.animation:animation:$composeVersion"
implementation "androidx.compose.ui:ui-tooling:$composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
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 'androidx.compose.material:material:1.5.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
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() {

View File

@@ -22,6 +22,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
@@ -49,9 +51,14 @@ class HomeActivityTests {
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
fun setUp() {
InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor)
}
@After
@@ -72,25 +79,34 @@ class HomeActivityTests {
onView(isRoot()).perform(waitFor(500))
}
private fun objectFromDesc(id: Int) = device.findObject(By.desc(context.getString(id)))
private fun setupLoggedInState(hasViewedSeed: Boolean = false) {
// landing activity
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// session ID - register activity
onView(withId(R.id.registerButton)).perform(ViewActions.click())
objectFromDesc(R.string.onboardingAccountCreate).click()
// display name selection
onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123"))
onView(withId(R.id.registerButton)).perform(ViewActions.click())
objectFromDesc(R.string.displayNameEnter).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
if (hasViewedSeed) {
// has viewed seed is set to false after register activity
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
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
@@ -111,8 +127,8 @@ class HomeActivityTests {
@Test
fun testLaunches_dismiss_seedView() {
setupLoggedInState()
onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click())
onView(withId(R.id.copyButton)).perform(ViewActions.click())
objectFromDesc(R.string.continue_2).click()
objectFromDesc(R.string.copy).click()
pressBack()
onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
}
@@ -133,7 +149,7 @@ class HomeActivityTests {
fun testChat_withSelf() {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
sendMessage("howdy")
sendMessage("test")
// 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 randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
@@ -102,7 +102,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
val newContactId = randomSessionId()
val newContactId = randomAccountId()
val singleContact = Contact(
id = newContactId,
approved = true,
@@ -123,7 +123,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
val randomRecipient = randomSessionId()
val randomRecipient = randomAccountId()
val newContact = Contact(
id = randomRecipient,
approved = true,
@@ -158,7 +158,7 @@ class LibSessionTests {
app.storage = storageSpy
// Initial state
val randomRecipient = randomSessionId()
val randomRecipient = randomAccountId()
val currentContact = Contact(
id = randomRecipient,
approved = true,

View File

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

View File

@@ -100,31 +100,32 @@
android:value="false" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LandingActivity"
android:name="org.thoughtcrime.securesms.onboarding.landing.LandingActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:name="org.thoughtcrime.securesms.onboarding.loadaccount.LoadAccountActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.DisplayNameActivity"
android:name="org.thoughtcrime.securesms.onboarding.loading.LoadingActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<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:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.home.HomeActivity"
android:screenOrientation="portrait"
android:launchMode="singleTask"
android:launchMode="standard"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity
android:name="org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity"
@@ -153,7 +154,7 @@
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.SeedActivity"
android:name="org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity"
android:screenOrientation="portrait" />
<activity
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.stopKovenant;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
@@ -137,7 +138,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public MessageNotifier messageNotifier = null;
public Poller poller = null;
public Broadcaster broadcaster = null;
private Job firebaseInstanceIdJob;
private WindowDebouncer conversationListDebouncer;
private HandlerThread conversationListHandlerThread;
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
// the pollers
if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) {
if (textSecurePreferences.getLocalNumber() == null) {
return;
}
@@ -451,6 +451,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
ClosedGroupPollerV2.getShared().start();
}
public void retrieveUserProfile() {
setUpPollingIfNeeded();
if (poller != null) {
poller.retrieveUserProfile();
}
}
private void resubmitProfilePictureIfNeeded() {
// 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.
@@ -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.
* @param isMigratingToV2KeyPair whether we're upgrading to a more recent V2 key pair or not.
* @return true on success, false otherwise.
*/
public boolean clearAllData(boolean isMigratingToV2KeyPair) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
@SuppressLint("ApplySharedPref")
public boolean clearAllData() {
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
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.libsignal.utilities.Log;
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 java.util.Locale;
@@ -125,12 +125,12 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
}
private int getApplicationState(boolean locked) {
if (locked) {
if (TextSecurePreferences.getLocalNumber(this) == null) {
return STATE_WELCOME_SCREEN;
} else if (locked) {
return STATE_PROMPT_PASSPHRASE;
} else if (DatabaseUpgradeActivity.isUpdate(this)) {
return STATE_UPGRADE_DATABASE;
} else if (!TextSecurePreferences.hasSeenWelcomeScreen(this)) {
return STATE_WELCOME_SCREEN;
} else {
return STATE_NORMAL;
}

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
@@ -15,7 +17,7 @@ import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.core.text.HtmlCompat
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
@@ -80,6 +82,10 @@ class SessionDialogBuilder(val context: Context) {
}.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(@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 =
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 =
SessionDialogBuilder(requireContext()).apply { build() }.show()

View File

@@ -445,7 +445,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
private fun getUserDisplayName(publicKey: String): String {
val contact =
DatabaseComponent.get(this).sessionContactDatabase().getContactWithSessionID(publicKey)
DatabaseComponent.get(this).sessionContactDatabase().getContactWithAccountID(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.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
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 glide: GlideRequests = GlideApp.with(this)
private val prefs = AppTextSecurePreferences(context)
private val userPublicKey = prefs.getLocalNumber()
var publicKey: String? = null
var displayName: String? = null
var additionalPublicKey: String? = null
@@ -40,25 +43,28 @@ class ProfilePictureView @JvmOverloads constructor(
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion
constructor(context: Context, sender: Recipient): this(context) {
update(sender)
}
// region Updating
fun update(recipient: Recipient) {
fun getUserDisplayName(publicKey: String): String {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
}
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()
.getGroupMemberAddresses(recipient.address.toGroupString(), true)
.sorted()
.take(2)
.toMutableList()
.getGroupMemberAddresses(address.toGroupString(), true)
.sorted()
.take(2)
if (members.size <= 1) {
publicKey = ""
displayName = ""
@@ -72,13 +78,13 @@ class ProfilePictureView @JvmOverloads constructor(
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
} else if(isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
} else {
val publicKey = recipient.address.toString()
val publicKey = address.serialize()
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null

View File

@@ -49,7 +49,7 @@ class UserView : LinearLayout {
val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
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.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.setThemedContent
import javax.inject.Inject
@AndroidEntryPoint
@@ -45,7 +45,7 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
setUpToolbar()
binding.container.setContent { DisappearingMessagesScreen() }
binding.container.setThemedContent { DisappearingMessagesScreen() }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -87,8 +87,6 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
@Composable
fun DisappearingMessagesScreen() {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
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.OptionsCard
import org.thoughtcrime.securesms.ui.OutlineButton
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.extraSmall
import org.thoughtcrime.securesms.ui.fadingEdges
typealias ExpiryCallbacks = Callbacks<ExpiryMode>
@@ -40,33 +39,34 @@ fun DisappearingMessages(
) {
val scrollState = rememberScrollState()
Column(modifier = modifier.padding(horizontal = 32.dp)) {
Column(modifier = modifier.padding(horizontal = LocalDimensions.current.margin)) {
Box(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.padding(bottom = 20.dp)
.verticalScroll(scrollState)
.fadingEdges(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) {
state.cards.forEach {
OptionsCard(it, callbacks)
}
if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer),
style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight(400),
color = Color(0xFFA1A2A1),
textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth())
if (state.showGroupFooter) Text(
text = stringResource(R.string.activity_disappearing_messages_group_footer),
style = extraSmall,
fontWeight = FontWeight(400),
color = LocalColors.current.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
if (state.showSetButton) OutlineButton(
GetString(R.string.disappearing_messages_set_button_title),
if (state.showSetButton) SlimOutlineButton(
stringResource(R.string.disappearing_messages_set_button_title),
modifier = Modifier
.contentDescription(GetString(R.string.AccessibilityId_set_button))
.contentDescription(R.string.AccessibilityId_set_button)
.align(Alignment.CenterHorizontally)
.padding(bottom = 20.dp),
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.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.conversation.disappearingmessages.State
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)
@Composable
fun PreviewStates(
@PreviewParameter(StatePreviewParameterProvider::class) state: State
) {
PreviewTheme(R.style.Classic_Dark) {
PreviewTheme {
DisappearingMessages(
state.toUiState()
)
@@ -51,9 +51,9 @@ class StatePreviewParameterProvider : PreviewParameterProvider<State> {
@Preview
@Composable
fun PreviewThemes(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(themeResId) {
PreviewTheme(colors) {
DisappearingMessages(
State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(),
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
interface NewConversationDelegate {
interface StartConversationDelegate {
fun onNewMessageSelected()
fun onCreateGroupSelected()
fun onJoinCommunitySelected()
fun onContactSelected(address: String)
fun onDialogBackPressed()
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.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -15,13 +16,16 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
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.dms.NewMessageFragment
import org.thoughtcrime.securesms.groups.CreateGroupFragment
import org.thoughtcrime.securesms.groups.JoinCommunityFragment
@AndroidEntryPoint
class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDelegate {
class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate {
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?) {
super.onViewCreated(view, savedInstanceState)
replaceFragment(
fragment = NewConversationHomeFragment().apply { delegate = this@NewConversationFragment },
fragmentKey = NewConversationHomeFragment::class.java.simpleName
fragment = StartConversationHomeFragment().also { it.delegate.value = this },
fragmentKey = StartConversationHomeFragment::class.java.simpleName
)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet)
dialog.setOnShowListener {
val bottomSheetDialog = it as BottomSheetDialog
val parentLayout =
bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
parentLayout?.let { it ->
val behaviour = BottomSheetBehavior.from(it)
val layoutParams = it.layoutParams
layoutParams.height = defaultPeekHeight
it.layoutParams = layoutParams
behaviour.state = BottomSheetBehavior.STATE_EXPANDED
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet).apply {
setOnShowListener { _ ->
findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.apply {
modifyLayoutParams<LayoutParams> { height = defaultPeekHeight }
}?.let { BottomSheetBehavior.from(it) }?.apply {
skipCollapsed = true
state = BottomSheetBehavior.STATE_EXPANDED
}
}
}
return dialog
}
override fun onNewMessageSelected() {
replaceFragment(NewMessageFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(NewMessageFragment().also { it.delegate = this })
}
override fun onCreateGroupSelected() {
replaceFragment(CreateGroupFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(CreateGroupFragment().also { it.delegate = this })
}
override fun onJoinCommunitySelected() {
replaceFragment(JoinCommunityFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(JoinCommunityFragment().also { it.delegate = this })
}
override fun onContactSelected(address: String) {
@@ -80,6 +80,10 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
childFragmentManager.popBackStack()
}
override fun onInviteFriend() {
replaceFragment(InviteFriendFragment().also { it.delegate = this })
}
override fun onDialogClosePressed() {
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.link_preview.LinkPreview
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.SnodeAPI
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.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
@@ -166,6 +165,7 @@ import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
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.NetworkUtils
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.start
import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver
import java.lang.ref.WeakReference
import java.util.Locale
import java.util.concurrent.ExecutionException
@@ -240,12 +239,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
threadId = threadDb.getThreadIdIfExistsFor(it.serialize())
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 address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
val address = if (accountId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(accountId.hexString, openGroup.server, openGroup.publicKey).accountId?.let {
fromSerialized(it)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, accountId)
} else {
it
}
@@ -733,12 +732,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
viewContainer.setTypists(recipients)
}
if (textSecurePreferences.isTypingIndicatorsEnabled()) {
binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(text: String?) {
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId)
}
})
binding.inputBar.addTextChangedListener {
ApplicationContext.getInstance(this).typingStatusSender.onTypingStarted(viewModel.threadId)
}
}
}
@@ -761,8 +757,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate
private fun setUpBlockedBanner() {
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
val sessionID = recipient.address.toString()
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val accountID = recipient.address.toString()
val name = sessionContactDb.getContactWithAccountID(accountID)?.displayName(Contact.ContactContext.REGULAR) ?: accountID
binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding.blockedBanner.isVisible = recipient.isBlocked
binding.blockedBanner.setOnClickListener { viewModel.unblock() }
@@ -1135,8 +1131,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
override fun copySessionID(sessionId: String) {
val clip = ClipData.newPlainText("Session ID", sessionId)
override fun copyAccountID(accountId: String) {
val clip = ClipData.newPlainText("Account ID", accountId)
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
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 isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) {
val dialog = SendSeedDialog { sendTextOnlyMessage(true) }
dialog.show(supportFragmentManager, "Send Seed Dialog")
return null
start<RecoveryPasswordActivity>()
}
// Create the message
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
@@ -2045,9 +2039,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode()
}
override fun copySessionID(messages: Set<MessageRecord>) {
val sessionID = messages.first().individualRecipient.address.toString()
val clip = ClipData.newPlainText("Session ID", sessionID)
override fun copyAccountID(messages: Set<MessageRecord>) {
val accountID = messages.first().individualRecipient.address.toString()
val clip = ClipData.newPlainText("Account ID", accountID)
val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
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.BAN_AND_DELETE_ALL -> banAndDeleteAll(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
private fun getSenderInfo(sender: String): Contact? {
return contactDB.getContactWithSessionID(sender)
return contactDB.getContactWithAccountID(sender)
}
sealed class ViewType(val rawValue: Int) {

View File

@@ -539,9 +539,9 @@ class ConversationReactionOverlay : FrameLayout {
if (!containsControlMessage && hasText) {
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) {
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
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
@@ -689,7 +689,7 @@ class ConversationReactionOverlay : FrameLayout {
RESYNC,
DOWNLOAD,
COPY_MESSAGE,
COPY_SESSION_ID,
COPY_ACCOUNT_ID,
VIEW_INFO,
SELECT,
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.OpenGroupApi
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.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
@@ -77,7 +77,7 @@ class ConversationViewModel(
val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
}
val isMessageRequestThread : Boolean

View File

@@ -26,7 +26,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
val contact by lazy {
val senderId = recipient.address.serialize()
// this dialog won't show for open group contacts
contactDatabase.getContactWithSessionID(senderId)
contactDatabase.getContactWithAccountID(senderId)
?.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.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -38,15 +37,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
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.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton
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.HorizontalPagerIndicator
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.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.ui.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.TitledText
import org.thoughtcrime.securesms.ui.blackAlpha40
import org.thoughtcrime.securesms.ui.colorDestructive
import org.thoughtcrime.securesms.ui.destructiveButtonColors
import org.thoughtcrime.securesms.ui.base
import org.thoughtcrime.securesms.ui.baseBold
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
@AndroidEntryPoint
@@ -102,9 +103,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
ComposeView(this)
.apply { setContent { MessageDetailsScreen() } }
.let(::setContentView)
setComposeContent { MessageDetailsScreen() }
lifecycleScope.launch {
viewModel.eventFlow.collect {
@@ -121,16 +120,14 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable
private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState()
AppTheme {
MessageDetails(
state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
}
MessageDetails(
state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
}
private fun setResultAndFinish(code: Int) {
@@ -155,12 +152,12 @@ fun MessageDetails(
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(vertical = LocalDimensions.current.smallItemSpacing),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) {
state.record?.let { message ->
AndroidView(
modifier = Modifier.padding(horizontal = 32.dp),
modifier = Modifier.padding(horizontal = LocalDimensions.current.margin),
factory = {
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
bind(
@@ -196,7 +193,7 @@ fun CellMetadata(
state.apply {
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
CellWithPaddingAndMargin {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)) {
TitledText(sent)
TitledText(received)
TitledErrorText(error)
@@ -222,23 +219,23 @@ fun CellButtons(
Cell {
Column {
onReply?.let {
ItemButton(
stringResource(R.string.reply),
LargeItemButton(
R.string.reply,
R.drawable.ic_message_details__reply,
onClick = it
)
Divider()
}
onResend?.let {
ItemButton(
stringResource(R.string.resend),
LargeItemButton(
R.string.resend,
R.drawable.ic_message_details__refresh,
onClick = it
)
Divider()
}
ItemButton(
stringResource(R.string.delete),
LargeItemButton(
R.string.delete,
R.drawable.ic_message_details__trash,
colors = destructiveButtonColors(),
onClick = onDelete
@@ -254,7 +251,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
val pagerState = rememberPagerState { attachments.size }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)) {
Row {
CarouselPrevButton(pagerState)
Box(modifier = Modifier.weight(1f)) {
@@ -263,7 +260,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
ExpandButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.padding(LocalDimensions.current.xxsItemSpacing)
) { onClick(pagerState.currentPage) }
}
CarouselNextButton(pagerState)
@@ -316,9 +313,9 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
@Preview
@Composable
fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
@PreviewParameter(SessionColorsParameterProvider::class) colors: Colors
) {
PreviewTheme(themeResId) {
PreviewTheme(colors) {
MessageDetails(
state = MessageDetailsState(
nonImageAttachmentFileDetails = listOf(
@@ -341,10 +338,10 @@ fun PreviewMessageDetails(
fun FileDetails(fileDetails: List<TitledText>) {
if (fileDetails.isEmpty()) return
CellWithPaddingAndMargin(padding = 0.dp) {
Cell {
FlowRow(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier.padding(horizontal = LocalDimensions.current.xsItemSpacing, vertical = LocalDimensions.current.itemSpacing),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallItemSpacing)
) {
fileDetails.forEach {
BoxWithConstraints {
@@ -352,7 +349,7 @@ fun FileDetails(fileDetails: List<TitledText>) {
it,
modifier = Modifier
.widthIn(min = maxWidth.div(2))
.padding(horizontal = 12.dp)
.padding(horizontal = LocalDimensions.current.xsItemSpacing)
.width(IntrinsicSize.Max)
)
}
@@ -365,7 +362,8 @@ fun FileDetails(fileDetails: List<TitledText>) {
fun TitledErrorText(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?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace)
style = baseMonospace
)
}
@@ -381,24 +379,25 @@ fun TitledMonospaceText(titledText: TitledText?) {
fun TitledText(
titledText: TitledText?,
modifier: Modifier = Modifier,
valueStyle: TextStyle = LocalTextStyle.current,
style: TextStyle = base,
color: Color = Color.Unspecified
) {
titledText?.apply {
TitledView(title, modifier) {
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth())
Text(
text,
style = style,
color = color,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Title(title)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsItemSpacing)) {
Text(title.string(), style = baseBold)
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 {
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation)

View File

@@ -26,9 +26,9 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
title(resources.getString(R.string.dialog_download_title, 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() {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) ?: return
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID) ?: return
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID)
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.content.Context
import android.content.res.Resources
import android.graphics.PointF
import android.net.Uri
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
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.MmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.addTextChangedListener
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
// manipulate the UI faster than we can setup & teardown.
@@ -43,16 +40,24 @@ enum class VoiceRecorderState {
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 {
private lateinit var binding: ViewInputBarBinding
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val vMargin by lazy { toDp(4, resources) }
private val minHeight by lazy { toPx(56, resources) }
private var binding: ViewInputBarBinding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
private var linkPreviewDraftView: LinkPreviewDraftView? = null
private var quoteView: QuoteView? = null
var delegate: InputBarDelegate? = null
var additionalContentHeight = 0
var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null
var showInput: Boolean = true
@@ -65,7 +70,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
}
var text: String
get() { return binding.inputBarEditText.text?.toString() ?: "" }
get() = binding.inputBarEditText.text?.toString() ?: ""
set(value) { binding.inputBarEditText.setText(value) }
// 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 voiceRecorderState = VoiceRecorderState.Idle
val attachmentButtonsContainerHeight: Int
get() = binding.attachmentsButtonContainer.height
private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)}
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)} }
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)
init {
// Attachments button
binding.attachmentsButtonContainer.addView(attachmentsButton)
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.setOnTouchListener(object : OnTouchListener {
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
if (event.pointerCount > 1) return true
@@ -157,12 +153,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.setOnEditorActionListener(this)
if (TextSecurePreferences.isEnterSendsEnabled(context)) {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND
binding.inputBarEditText.inputType =
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
binding.inputBarEditText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} else {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
binding.inputBarEditText.inputType =
binding.inputBarEditText.inputType or
binding.inputBarEditText.inputType
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
}
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
@@ -179,9 +174,6 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
return false
}
// endregion
// region Updating
override fun inputBarEditTextContentChanged(text: CharSequence) {
microphoneButton.isVisible = text.trim().isEmpty()
sendButton.isVisible = microphoneButton.isGone
@@ -276,19 +268,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls }
}
fun addTextChangedListener(textWatcher: TextWatcher) {
binding.inputBarEditText.addTextChangedListener(textWatcher)
fun addTextChangedListener(listener: (String) -> Unit) {
binding.inputBarEditText.addTextChangedListener(listener)
}
fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setEditableFactory(factory)
}
// endregion
}
interface InputBarDelegate {
fun inputBarEditTextContentChanged(newContent: CharSequence)
fun toggleAttachmentOptions()
fun showVoiceMessageUI()
@@ -298,4 +287,4 @@ interface InputBarDelegate {
fun onMicrophoneButtonUp(event: MotionEvent)
fun sendMessage()
fun commitInputContent(contentUri: Uri)
}
}

View File

@@ -117,9 +117,9 @@ class MentionViewModel(
contactDatabase.getContacts(memberIDs).map { contact ->
Member(
publicKey = contact.sessionID,
publicKey = contact.accountID,
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 network.loki.messenger.R
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.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
@@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
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 {
val allSentByCurrentUser = 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()
// Copy message text
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 =
(thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// 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_and_delete_all -> delegate?.banAndDeleteAll(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_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
@@ -115,7 +115,7 @@ interface ConversationActionModeCallbackDelegate {
fun banUser(messages: Set<MessageRecord>)
fun banAndDeleteAll(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>)
fun copyAccountID(messages: Set<MessageRecord>)
fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>)

View File

@@ -57,9 +57,9 @@ object ConversationMenuHelper {
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
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) {
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)
if (thread.isContactRecipient) {
@@ -135,7 +135,7 @@ object ConversationMenuHelper {
R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) }
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_edit_group -> { editClosedGroup(context, thread) }
R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
@@ -246,10 +246,10 @@ object ConversationMenuHelper {
listener.block(deleteThread = true)
}
private fun copySessionID(context: Context, thread: Recipient) {
private fun copyAccountID(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return }
val listener = context as? ConversationMenuListener ?: return
listener.copySessionID(thread.address.toString())
listener.copyAccountID(thread.address.toString())
}
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 admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == sessionID }
val accountID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == accountID }
val message = if (isCurrentUserAdmin) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."
} else {
@@ -325,7 +325,7 @@ object ConversationMenuHelper {
interface ConversationMenuListener {
fun block(deleteThread: Boolean = false)
fun unblock()
fun copySessionID(sessionId: String)
fun copyAccountID(accountId: String)
fun copyOpenGroupUrl(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,
isOriginalMissing: Boolean, glide: GlideRequests) {
// Author
val author = contactDb.getContactWithSessionID(authorPublicKey)
val author = contactDb.getContactWithAccountID(authorPublicKey)
val localNumber = TextSecurePreferences.getLocalNumber(context)
val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber

View File

@@ -143,7 +143,7 @@ class VisibleMessageView : FrameLayout {
glide: GlideRequests = GlideApp.with(this),
searchQuery: String? = null,
contact: Contact? = null,
senderSessionID: String,
senderAccountID: String,
lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
@@ -178,30 +178,30 @@ class VisibleMessageView : FrameLayout {
if (isGroupThread && !message.isOutgoing) {
if (isEndOfMessageCluster) {
binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.publicKey = senderAccountID
binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener {
if (thread.isCommunityRecipient) {
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
val intent = Intent(context, ConversationActivityV2::class.java)
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)
}
} else {
maybeShowUserDetails(senderSessionID, threadID)
maybeShowUserDetails(senderAccountID, threadID)
}
}
if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = ""
var blindedPublicKey: String? = null
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID
if (IdPrefix.fromValue(senderAccountID)?.isBlinded() == true) {
blindedPublicKey = senderAccountID
} else {
standardPublicKey = senderSessionID
standardPublicKey = senderAccountID
}
val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey)
binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator
@@ -211,7 +211,7 @@ class VisibleMessageView : FrameLayout {
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext =
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
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) {
context.getString(R.string.MessageRecord_you)
} 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
contact?.displayName(context) ?: truncateIdForDisplay(publicKey)
}
@@ -157,7 +157,7 @@ object MentionUtilities {
}
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
}
}

View File

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

View File

@@ -6,7 +6,7 @@ import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray
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.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -15,7 +15,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
companion object {
private const val sessionContactTable = "session_contact_database"
const val sessionID = "session_id"
const val accountID = "session_id"
const val name = "name"
const val nickname = "nickname"
const val profilePictureURL = "profile_picture_url"
@@ -25,7 +25,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
const val isTrusted = "is_trusted"
@JvmStatic val createSessionContactTableCommand =
"CREATE TABLE $sessionContactTable " +
"($sessionID STRING PRIMARY KEY, " +
"($accountID STRING PRIMARY KEY, " +
"$name TEXT DEFAULT NULL, " +
"$nickname TEXT DEFAULT NULL, " +
"$profilePictureURL TEXT DEFAULT NULL, " +
@@ -35,19 +35,19 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
"$isTrusted INTEGER DEFAULT 0);"
}
fun getContactWithSessionID(sessionID: String): Contact? {
fun getContactWithAccountID(accountID: String): Contact? {
val database = databaseHelper.readableDatabase
return database.get(sessionContactTable, "${Companion.sessionID} = ?", arrayOf( sessionID )) { cursor ->
return database.get(sessionContactTable, "${Companion.accountID} = ?", arrayOf( accountID )) { cursor ->
contactFromCursor(cursor)
}
}
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
fun getContacts(accountIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(
sessionContactTable,
"$sessionID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString())
"$accountID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(accountIDs).toString())
) { cursor -> contactFromCursor(cursor) }
}
@@ -56,8 +56,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor)
}.filter { contact ->
val sessionId = SessionId(contact.sessionID)
sessionId.prefix == IdPrefix.STANDARD
contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD
}.toSet()
}
@@ -65,7 +64,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(1)
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) {
notifyConversationListeners(threadID)
}
@@ -75,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
fun setContact(contact: Contact) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(8)
contentValues.put(sessionID, contact.sessionID)
contentValues.put(accountID, contact.accountID)
contentValues.put(name, contact.name)
contentValues.put(nickname, contact.nickname)
contentValues.put(profilePictureURL, contact.profilePictureURL)
@@ -85,13 +84,13 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
}
contentValues.put(threadID, contact.threadID)
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()
}
fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
val contact = Contact(sessionID)
val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
val contact = Contact(accountID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
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.Companion.PRIORITY_HIDDEN
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.ConversationVolatileConfig
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.pollers.ClosedGroupPollerV2
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.UpdateMessageData
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.ProfileKeyUtil
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.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
@@ -110,12 +112,12 @@ open class Storage(
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId)
groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy(
lastRead = SnodeAPI.nowWithOffset,
)
volatile.set(newVolatileParams)
@@ -126,16 +128,16 @@ open class Storage(
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE
priority = PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
userProfile.setNtsPriority(PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
@@ -148,16 +150,16 @@ open class Storage(
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(accountId)
groups.eraseLegacyGroup(accountId)
} else if (address.isCommunity) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
@@ -264,10 +266,8 @@ open class Storage(
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = SessionId(recipient.address.serialize())
if (sessionId.prefix != IdPrefix.STANDARD) return
// don't process non-standard account IDs though
if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(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
val senderAddress = fromSerialized(message.sender!!)
val isUserSender = (message.sender!! == getUserPublicKey())
val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let { getOpenGroup(it)?.publicKey }
?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey
?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val group: Optional<SignalServiceGroup> = when {
openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT))
groupPublicKey != null -> {
@@ -476,9 +476,11 @@ open class Storage(
val name = userProfile.getName() ?: return
val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name)
profileManager.setName(context, recipient, name)
name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let {
TextSecurePreferences.setProfileName(context, it)
profileManager.setName(context, recipient, it)
if (it != name) userProfile.setName(it)
}
// Update profile picture
@@ -537,7 +539,7 @@ open class Storage(
val extracted = convos.all()
for (conversation in extracted) {
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.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 existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId }
val lgcIds = lgc.map { it.accountId }
val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
@@ -602,8 +604,8 @@ open class Storage(
}
for (group in lgc) {
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
@@ -622,19 +624,19 @@ open class Storage(
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId)
addClosedGroupPublicKey(group.accountId)
// Store the encryption key pair
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
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
ClosedGroupPollerV2.shared.startPolling(group.accountId)
}
getThreadId(Address.fromSerialized(groupId))?.let {
setExpirationConfiguration(
@@ -935,10 +937,10 @@ open class Storage(
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
accountId = groupPublicKey,
name = name,
members = members,
priority = ConfigBase.PRIORITY_VISIBLE,
priority = PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = expirationTimer.toLong(),
@@ -972,7 +974,7 @@ open class Storage(
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE,
disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
@@ -1177,8 +1179,8 @@ open class Storage(
return threadId ?: -1
}
override fun getContactWithSessionID(sessionID: String): Contact? {
return DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(sessionID)
override fun getContactWithAccountID(accountID: String): Contact? {
return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID)
}
override fun getAllContacts(): Set<Contact> {
@@ -1187,7 +1189,7 @@ open class Storage(
override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID)
val address = fromSerialized(contact.accountID)
if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
@@ -1205,8 +1207,8 @@ open class Storage(
override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
val id = AccountId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
@@ -1258,8 +1260,8 @@ open class Storage(
val threadDatabase = DatabaseComponent.get(context).threadDatabase()
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null }
val id = AccountId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null }
}
for (contact in moreContacts) {
val address = fromSerialized(contact.publicKey)
@@ -1326,25 +1328,25 @@ open class Storage(
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
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) {
val contacts = configFactory.contacts ?: return
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) {
val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
threadRecipient.address.serialize()
.let(GroupUtil::doubleDecodeGroupId)
.let(groups::getOrConstructLegacyGroupInfo)
.copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
.let(groups::set)
} else if (threadRecipient.isCommunityRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
}
@@ -1493,14 +1495,8 @@ open class Storage(
val address = recipient.address.serialize()
val blindedId = when {
recipient.isGroupRecipient -> null
recipient.isOpenGroupInboxRecipient -> {
GroupUtil.getDecodedOpenGroupInboxSessionId(address)
}
else -> {
if (SessionId(address).prefix == IdPrefix.BLINDED) {
address
} else null
}
recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address)
else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED }
} ?: continue
mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let {
mappings[address] = it
@@ -1508,18 +1504,18 @@ open class Storage(
}
}
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
}
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))
mmsDb.updateThreadId(blindedThreadId, threadId)
smsDb.updateThreadId(blindedThreadId, threadId)
threadDB.deleteConversation(blindedThreadId)
}
recipientDb.setApproved(sender, true)
recipientDb.setApprovedMe(sender, true)
setRecipientApproved(sender, true)
setRecipientApprovedMe(sender, true)
val message = IncomingMediaMessage(
sender.address,
response.sentTimestamp!!,
@@ -1617,20 +1613,20 @@ open class Storage(
): BlindedIdMapping {
val db = DatabaseComponent.get(context).blindedIdMappingDatabase()
val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey)
if (mapping.sessionId != null) {
if (mapping.accountId != null) {
return mapping
}
getAllContacts().forEach { contact ->
val sessionId = SessionId(contact.sessionID)
if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) {
val contactMapping = mapping.copy(sessionId = sessionId.hexString)
val accountId = AccountId(contact.accountID)
if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) {
val contactMapping = mapping.copy(accountId = accountId.hexString)
db.addBlindedIdMapping(contactMapping)
return contactMapping
}
}
db.getBlindedIdMappingsExceptFor(server).forEach {
if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) {
val otherMapping = mapping.copy(sessionId = it.sessionId)
if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) {
val otherMapping = mapping.copy(accountId = it.accountId)
db.addBlindedIdMapping(otherMapping)
return otherMapping
}
@@ -1746,7 +1742,7 @@ open class Storage(
if (recipient.isClosedGroupRecipient) {
val userGroups = configFactory.userGroups ?: return
val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address)
val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address)
val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
userGroups.set(groupInfo)
@@ -1806,4 +1802,12 @@ open class Storage(
lokiDb.setLastLegacySenderAddress(recipientAddress, null)
}
}
}
}
/**
* 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.recipients.Recipient
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.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
@@ -43,7 +43,7 @@ class CreateGroupFragment : Fragment() {
private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: NewConversationDelegate
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
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.recipients.Recipient
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.util.ConfigurationMessageUtilities
@@ -33,7 +33,7 @@ class JoinCommunityFragment : Fragment() {
private lateinit var binding: FragmentJoinCommunityBinding
lateinit var delegate: NewConversationDelegate
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.text.SpannableString
import android.text.TextUtils
import android.util.AttributeSet
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.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread
@@ -50,7 +48,7 @@ class ConversationView : LinearLayout {
// endregion
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) {
fun bind(thread: ThreadRecord, isTyping: Boolean) {
this.thread = thread
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
@@ -141,11 +139,10 @@ class ConversationView : LinearLayout {
else -> recipient.toShortString() // Internally uses the Contact API
}
private fun ThreadRecord.getSnippet(): CharSequence =
concatSnippet(getSnippetPrefix(), getDisplayBody(context))
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull(
getSnippetPrefix(),
getDisplayBody(context)
).joinToString(": ")
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
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.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -18,11 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
@@ -44,7 +44,7 @@ import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext
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.utilities.NotificationUtils
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.home.search.GlobalSearchAdapter
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.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
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.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.start
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class HomeActivity : PassphraseRequiredActionBarActivity(),
ConversationClickListener,
SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT"
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests
@@ -104,8 +105,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
private val publicKey: String
get() = textSecurePreferences.getLocalNumber()!!
private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! }
private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests)
@@ -113,47 +113,38 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchAdapter = GlobalSearchAdapter { model ->
when (model) {
is GlobalSearchAdapter.Model.Message -> {
val threadId = model.messageResult.threadId
val timestamp = model.messageResult.sentTimestampMs
val author = model.messageResult.messageRecipient.address
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author)
push(intent)
}
is GlobalSearchAdapter.Model.SavedMessages -> {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
push(intent)
}
is GlobalSearchAdapter.Model.Contact -> {
val address = model.contact.sessionID
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent)
}
is GlobalSearchAdapter.Model.GroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
push(intent)
is GlobalSearchAdapter.Model.Message -> push<ConversationActivityV2> {
model.messageResult.run {
putExtra(ConversationActivityV2.THREAD_ID, threadId)
putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs)
putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address)
}
}
else -> {
Log.d("Loki", "callback with model: $model")
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")
}
}
private val isNewAccount: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
if (!isTaskRoot) { finish(); return }
// Set content view
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -164,20 +155,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up toolbar buttons
binding.profileButton.setOnClickListener { openSettings() }
binding.searchViewContainer.setOnClickListener {
globalSearchViewModel.refresh()
binding.globalSearchInputLayout.requestFocus()
}
binding.sessionToolbar.disableClipping()
// Set up seed reminder view
lifecycleScope.launchWhenStarted {
val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
if (!hasViewedSeed) {
binding.seedReminderView.isVisible = true
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.setProgress(80, false)
binding.seedReminderView.delegate = this@HomeActivity
} else {
binding.seedReminderView.isVisible = false
binding.seedReminderView.setThemedContent {
if (!textSecurePreferences.getHasViewedSeed()) SeedReminder { start<RecoveryPasswordActivity>() }
}
}
// Set up recycler view
@@ -193,11 +178,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
binding.emptyStateContainer.setThemedContent {
EmptyView(isNewAccount)
}
IP2Country.configureIfNeeded(this@HomeActivity)
// Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() }
binding.newConversationButton.setOnClickListener { showStartConversation() }
// Observe blocked contacts changed events
// 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
launch {
binding.globalSearchInputLayout.query
.onEach(globalSearchViewModel::postQuery)
.collect()
.collect(globalSearchViewModel::setQuery)
}
// Get group results and display them
launch {
globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } +
result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
val contactResults = contactAndGroupList.toMutableList()
if (contactResults.isEmpty()) {
contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey))
globalSearchViewModel.result.map { result ->
result.query to when {
result.query.isEmpty() -> buildList {
add(GlobalSearchAdapter.Model.Header(R.string.contacts))
add(GlobalSearchAdapter.Model.SavedMessages(publicKey))
addAll(result.groupedContacts)
}
else -> buildList {
result.contactAndGroupList.takeUnless { it.isEmpty() }?.let {
add(GlobalSearchAdapter.Model.Header(R.string.contacts))
addAll(it)
}
result.messageResults.takeUnless { it.isEmpty() }?.let {
add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
addAll(it)
}
}
}
val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey }
if (userIndex >= 0) {
contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)
}
if (contactResults.isNotEmpty()) {
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups))
}
val unreadThreadMap = result.messages
.groupBy { it.threadId }.keys
.map { it to mmsSmsDatabase.getUnreadCount(it) }
.toMap()
val messageResults: MutableList<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)
@@ -308,20 +274,62 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
.request(Manifest.permission.POST_NOTIFICATIONS)
.execute()
}
configFactory.user?.let { user ->
if (!user.isBlockCommunityMessageRequestsSet()) {
user.setCommunityMessageRequests(false)
configFactory.user
?.takeUnless { it.isBlockCommunityMessageRequestsSet() }
?.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) {
if (hasFocus) {
setSearchShown(true)
} else {
setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty())
}
setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty())
}
private fun setSearchShown(isShown: Boolean) {
@@ -330,7 +338,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.globalSearchRecycler.isVisible = isShown
binding.globalSearchRecycler.isInvisible = !isShown
binding.newConversationButton.isVisible = !isShown
}
@@ -397,16 +405,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// region Interaction
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) {
binding.globalSearchInputLayout.clearSearch(true)
return
}
super.onBackPressed()
}
override fun handleSeedReminderViewContinueButtonTapped() {
val intent = Intent(this, SeedActivity::class.java)
show(intent)
if (binding.globalSearchRecycler.isVisible) binding.globalSearchInputLayout.clearSearch(true)
else super.onBackPressed()
}
override fun onConversationClick(thread: ThreadRecord) {
@@ -431,17 +431,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
bottomSheet.onCopyConversationId = onCopyConversationId@{
bottomSheet.dismiss()
if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) {
val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString())
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString())
val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
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 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)
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 group = groupDatabase.getGroup(recipient.address.toString()).orNull()
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 {
resources.getString(R.string.activity_home_leave_group_dialog_message)
}
@@ -625,7 +625,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun hideMessageRequests() {
showSessionDialog {
text("Hide message requests?")
text(getString(R.string.hide_message_requests))
button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests()
homeViewModel.tryReload()
@@ -634,9 +634,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
private fun showNewConversation() {
NewConversationFragment().show(supportFragmentManager, "NewConversationFragment")
private fun showStartConversation() {
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.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
@@ -12,8 +11,6 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
class HomeAdapter(
private val context: Context,
@@ -115,7 +112,7 @@ class HomeAdapter(
val offset = if (hasHeaderView()) position - 1 else position
val thread = data.threads[offset]
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 {
val clipboard =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", publicKey)
val clip = ClipData.newPlainText("Account ID", publicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT)
.show()
@@ -137,7 +137,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
else { newNickName = previousContactNickname }
val publicKey = recipient.address.serialize()
val storage = MessagingModuleConfiguration.shared.storage
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName
storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally

View File

@@ -9,23 +9,27 @@ import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel
class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
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 query: String? = null
fun setNewData(data: Pair<String, List<Model>>) = setNewData(data.first, data.second)
fun setNewData(query: String, newData: List<Model>) {
val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData))
this.query = query
@@ -34,21 +38,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
}
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 onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == HEADER_VIEW_TYPE) {
HeaderView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_header, parent, false)
when (viewType) {
HEADER_VIEW_TYPE -> HeaderView(
LayoutInflater.from(parent.context).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(
@@ -61,10 +70,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
holder.bindPayload(newUpdateQuery, data[position])
return
}
if (holder is HeaderView) {
holder.bind(data[position] as Model.Header)
} else if (holder is ContentView) {
holder.bind(query.orEmpty(), data[position])
when (holder) {
is HeaderView -> holder.bind(data[position] as Model.Header)
is SubHeaderView -> holder.bind(data[position] as Model.SubHeader)
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)
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.Message -> bindModel(query, 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) }
}
}
data class MessageModel(
val threadRecipient: Recipient,
val messageRecipient: Recipient,
val messageSnippet: String
)
sealed class Model {
data class Header(@StringRes val title: Int) : Model()
data class 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 Contact(val contact: ContactModel) : Model()
data class GroupConversation(val groupRecord: GroupRecord) : Model()
data class Message(val messageResult: MessageResult, val unread: Int) : Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
data class GroupConversation(val groupRecord: GroupRecord): 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.utilities.Address
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.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil
import java.util.Locale
@@ -63,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
}
is GroupConversation -> {
binding.searchResultTitle.text = getHighlight(
@@ -72,12 +74,12 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
)
val membersString = model.groupRecord.members.joinToString { address ->
val recipient = Recipient.from(binding.root.context, address, false)
recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}"
Recipient.from(binding.root.context, address, false).getSearchName()
}
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
is Header, // do nothing for header
is SubHeader, // do nothing for subheader
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) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
@@ -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 membersString = groupRecipients.joinToString {
val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
}
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
if (model.groupRecord.isClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
fun ContentView.bindModel(query: String?, model: ContactModel) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultSubtitle.text = null
val recipient =
Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false)
binding.searchResultProfilePicture.update(recipient)
val nameString = model.contact.getSearchName()
binding.searchResultTitle.text = getHighlight(query, nameString)
fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run {
searchResultProfilePicture.isVisible = true
searchResultSubtitle.isVisible = false
searchResultTimestamp.isVisible = false
searchResultSubtitle.text = null
val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false)
searchResultProfilePicture.update(recipient)
val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self)
else model.contact.getSearchName()
searchResultTitle.text = getHighlight(query, nameString)
}
fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.note_to_self)
binding.searchResultProfilePicture.isVisible = false
binding.searchResultSavedMessages.isVisible = true
binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey))
binding.searchResultProfilePicture.isVisible = true
}
fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
searchResultProfilePicture.isVisible = true
searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0
// binding.unreadCountIndicator.isVisible = hasUnreads
// unreadCountIndicator.isVisible = 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)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind
val text = "${model.messageResult.messageRecipient.getSearchName()}: "
val text = "${model.messageResult.messageRecipient.toShortString()}: "
textSpannable.append(text)
}
textSpannable.append(getHighlight(
query,
model.messageResult.bodySnippet
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
binding.searchResultSubtitle.isVisible = true
searchResultSubtitle.text = textSpannable
searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.note_to_self)
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 =
if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"
else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)"
nickname?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: 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.StateFlow
import network.loki.messenger.databinding.ViewGlobalSearchInputBinding
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.addTextChangedListener
class GlobalSearchInputLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs),
View.OnFocusChangeListener,
View.OnClickListener,
TextWatcher, TextView.OnEditorActionListener {
TextView.OnEditorActionListener {
var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true)
var listener: GlobalSearchInputLayoutListener? = null
private val _query = MutableStateFlow<CharSequence?>(null)
val query: StateFlow<CharSequence?> = _query
private val _query = MutableStateFlow<CharSequence>("")
val query: StateFlow<CharSequence> = _query
override fun onAttachedToWindow() {
super.onAttachedToWindow()
binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this)
binding.searchInput.addTextChangedListener(::setQuery)
binding.searchInput.setOnEditorActionListener(this)
binding.searchInput.setFilters( arrayOf<InputFilter>(LengthFilter(100)) ) // 100 char search limit
binding.searchCancel.setOnClickListener(this)
binding.searchClear.setOnClickListener(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
binding.searchInput.filters = arrayOf<InputFilter>(LengthFilter(100)) // 100 char search limit
binding.searchCancel.setOnClickListener { clearSearch(true) }
binding.searchClear.setOnClickListener { clearSearch(false) }
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v === binding.searchInput) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (!hasFocus) {
imm.hideSoftInputFromWindow(windowToken, 0)
} else {
imm.showSoftInput(v, 0)
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).apply {
if (hasFocus) showSoftInput(v, 0)
else hideSoftInputFromWindow(windowToken, 0)
}
listener?.onInputFocusChanged(hasFocus)
}
@@ -65,27 +60,16 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
return false
}
override fun onClick(v: View?) {
if (v === binding.searchCancel) {
clearSearch(true)
} else if (v === binding.searchClear) {
clearSearch(false)
}
}
fun clearSearch(clearFocus: Boolean) {
binding.searchInput.text = null
setQuery("")
if (clearFocus) {
binding.searchInput.clearFocus()
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
_query.value = s?.toString()
private fun setQuery(query: String) {
_query.value = query
}
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.utilities.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.search.model.SearchResult
data class GlobalSearchResult(
val query: String,
val contacts: List<Contact>,
val threads: List<GroupRecord>,
val messages: List<MessageResult>
val query: String,
val contacts: List<Contact> = emptyList(),
val threads: List<GroupRecord> = emptyList(),
val messages: List<MessageResult> = emptyList()
) {
val isEmpty: Boolean
get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty()
companion object {
val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList())
const val SEARCH_LIMIT = 5
fun from(searchResult: SearchResult): GlobalSearchResult {
val query = searchResult.query
val contactList = searchResult.contacts.toList()
val threads = searchResult.conversations.toList()
val messages = searchResult.messages.toList()
searchResult.close()
return GlobalSearchResult(query, contactList, threads, messages)
}
val EMPTY = GlobalSearchResult("")
}
}
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.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
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.StateFlow
import kotlinx.coroutines.flow.SharingStarted
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.onEach
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.search.SearchRepository
@@ -19,49 +26,51 @@ import org.thoughtcrime.securesms.search.model.SearchResult
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@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)
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
if (query.trim().isEmpty()) {
// 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 {
// 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.
delay(300)
val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set)
try {
// search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult()
} catch (e: Exception) {
GlobalSearchResult(query.toString())
}
}
}
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
fun setQuery(charSequence: CharSequence) {
_queryText.value = charSequence
}
init {
//
_queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
// Early exit on empty search query
if (query.trim().isEmpty()) {
SearchResult.EMPTY
} else {
// 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.
delay(300)
val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set)
try {
// search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
SearchResult.EMPTY
}
}
}
.onEach { result ->
// update the latest _result value
_result.value = GlobalSearchResult.from(result)
}
.launchIn(executor)
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 {
if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) {
if (TextSecurePreferences.getLocalNumber(context) == null) {
Log.v(TAG, "User not registered yet.")
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.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.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
@@ -269,7 +269,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
try {
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);
cancelActiveNotifications(context);
@@ -594,7 +594,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (openGroup != null && edKeyPair != null) {
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
if (blindedKeyPair != null) {
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
}
}
return null;

View File

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

View File

@@ -339,11 +339,11 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
*/
private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) {
SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase();
String sessionID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID);
if (contact == null) { return sessionID; }
String accountID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithAccountID(accountID);
if (contact == null) { return accountID; }
String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR);
if (displayName == null) { return sessionID; }
if (displayName == null) { return accountID; }
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,16 +1,25 @@
package org.thoughtcrime.securesms.onboarding
package org.thoughtcrime.securesms.onboarding.landing
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import network.loki.messenger.databinding.ActivityLandingBinding
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity
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.util.push
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.start
import javax.inject.Inject
class LandingActivity : BaseActionBarActivity() {
@AndroidEntryPoint
class LandingActivity: BaseActionBarActivity() {
@Inject
internal lateinit var prefs: TextSecurePreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -19,28 +28,24 @@ class LandingActivity : BaseActionBarActivity() {
// Session then close this activity to resume the last activity from the previous instance.
if (!isTaskRoot) { finish(); return }
val binding = ActivityLandingBinding.inflate(layoutInflater)
setContentView(binding.root)
setUpActionBarSessionLogo(true)
with(binding) {
fakeChatView.startAnimating()
registerButton.setOnClickListener { register() }
restoreButton.setOnClickListener { link() }
linkButton.setOnClickListener { link() }
setComposeContent {
LandingScreen(
createAccount = { startPickDisplayNameActivity() },
loadAccount = { start<LoadAccountActivity>() },
openTerms = { open("https://getsession.org/terms-of-service") },
openPrivacyPolicy = { open("https://getsession.org/privacy-policy") }
)
}
IdentityKeyUtil.generateIdentityKeyPair(this)
TextSecurePreferences.setPasswordDisabled(this, true)
// AC: This is a temporary workaround to trick the old code that the screen is unlocked.
KeyCachingService.setMasterSecret(applicationContext, Object())
}
private fun register() {
val intent = Intent(this, RegisterActivity::class.java)
push(intent)
private fun open(url: String) {
Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity)
}
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
}
ApplicationContext.getInstance(context).clearAllData(false).let { success ->
ApplicationContext.getInstance(context).clearAllData().let { success ->
withContext(Main) {
if (success) {
dismiss()
@@ -162,7 +162,7 @@ class ClearAllDataDialog : DialogFragment() {
}
else if (deletionResultMap.values.all { it }) {
// ..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() }
}
}

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ItemSelectableBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.ui.GetString
import java.util.Objects
@@ -68,7 +67,6 @@ class RadioOptionAdapter<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.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Parcelable
import android.util.SparseArray
import android.view.ActionMode
@@ -20,26 +18,49 @@ import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
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.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.security.SecureRandom
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
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.R
import network.loki.messenger.databinding.ActivitySettingsBinding
import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
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.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
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.home.PathActivity
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.preferences.appearance.AppearanceSettingsActivity
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
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.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import java.io.File
import java.security.SecureRandom
import javax.inject.Inject
@AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() {
@@ -67,20 +99,17 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
@Inject
lateinit var prefs: TextSecurePreferences
private lateinit var binding: ActivitySettingsBinding
private var displayNameEditActionMode: ActionMode? = null
set(value) { field = value; handleDisplayNameEditActionModeChanged() }
private lateinit var glide: GlideRequests
private var tempFile: File? = null
private val hexEncodedPublicKey: String
get() {
return TextSecurePreferences.getLocalNumber(this)!!
}
private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!!
companion object {
const val updatedProfileResultCode = 1234
private const val SCROLL_STATE = "SCROLL_STATE"
}
@@ -89,31 +118,24 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
super.onCreate(savedInstanceState, isReady)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
val displayName = getDisplayName()
glide = GlideApp.with(this)
with(binding) {
}
override fun onStart() {
super.onStart()
binding.run {
setupProfilePictureView(profilePictureView)
profilePictureView.setOnClickListener { showEditProfilePictureUI() }
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
btnGroupNameDisplay.text = displayName
btnGroupNameDisplay.text = getDisplayName()
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)
versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)")
}
binding.composeView.setThemedContent {
Buttons()
}
}
private fun getDisplayName(): String =
@@ -143,13 +165,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
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
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_qr_code -> {
showQRCode()
push<QRCodeActivity>()
true
}
else -> super.onOptionsItemSelected(item)
@@ -159,30 +184,22 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) return
when (requestCode) {
AvatarSelection.REQUEST_CODE_AVATAR -> {
if (resultCode != Activity.RESULT_OK) {
return
}
val outputFile = Uri.fromFile(File(cacheDir, "cropped"))
var inputFile: Uri? = data?.data
if (inputFile == null && tempFile != null) {
inputFile = Uri.fromFile(tempFile)
}
val inputFile: Uri? = data?.data ?: tempFile?.let(Uri::fromFile)
AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar)
}
AvatarSelection.REQUEST_CODE_CROP_IMAGE -> {
if (resultCode != Activity.RESULT_OK) {
return
}
AsyncTask.execute {
lifecycleScope.launch(Dispatchers.IO) {
try {
val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
Handler(Looper.getMainLooper()).post {
launch(Dispatchers.Main) {
updateProfilePicture(profilePictureToBeUploaded)
}
} catch (e: BitmapDecodingException) {
e.printStackTrace()
Log.e(TAG, e)
}
}
}
@@ -197,10 +214,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
// region Updating
private fun handleDisplayNameEditActionModeChanged() {
val isEditingDisplayName = this.displayNameEditActionMode !== null
val isEditingDisplayName = this.displayNameEditActionMode != null
binding.btnGroupNameDisplay.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE
binding.displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE
binding.btnGroupNameDisplay.isInvisible = isEditingDisplayName
binding.displayNameEditText.isInvisible = !isEditingDisplayName
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (isEditingDisplayName) {
@@ -277,11 +294,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
val userConfig = configFactory.user
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, profilePicture.let { SecureRandom().nextInt() } )
prefs.setProfileAvatarId(SecureRandom().nextInt() )
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// 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)
// If we have a URL and a profile key then set the user's profile picture
@@ -354,40 +371,35 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show()
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()
return false
}
return updateDisplayName(displayName)
}
private fun showQRCode() {
val intent = Intent(this, QRCodeActivity::class.java)
push(intent)
}
private fun showEditProfilePictureUI() {
showSessionDialog {
title(R.string.activity_settings_set_display_picture)
view(R.layout.dialog_change_avatar)
button(R.string.activity_settings_upload) { startAvatarSelection() }
if (TextSecurePreferences.getProfileAvatarId(context) != 0) {
if (prefs.getProfileAvatarId() != 0) {
button(R.string.activity_settings_remove) { removeProfilePicture() }
}
cancelButton()
}.apply {
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
?.also(::setupProfilePictureView)
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
?.also(::setupProfilePictureView)
val pictureIcon = findViewById<View>(R.id.ic_pictures)
val pictureIcon = findViewById<View>(R.id.ic_pictures)
val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false)
val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false)
val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "")
val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "")
profilePic?.isVisible = photoSet
pictureIcon?.isVisible = !photoSet
}
profilePic?.isVisible = photoSet
pictureIcon?.isVisible = !photoSet
}
}
private fun startAvatarSelection() {
@@ -399,76 +411,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
}
.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
private inner class DisplayNameEditActionModeCallback: ActionMode.Callback {
@@ -497,7 +439,74 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
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.view.View
import androidx.activity.viewModels
import androidx.appcompat.widget.SwitchCompat
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
@@ -31,8 +30,8 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
var currentTheme: ThemeState? = null
private val accentColors
get() = mapOf(
private val accentColors by lazy {
mapOf(
binding.accentGreen to R.style.PrimaryGreen,
binding.accentBlue to R.style.PrimaryBlue,
binding.accentYellow to R.style.PrimaryYellow,
@@ -41,9 +40,10 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
binding.accentOrange to R.style.PrimaryOrange,
binding.accentRed to R.style.PrimaryRed
)
}
private val themeViews
get() = listOf(
private val themeViews by lazy {
listOf(
binding.themeOptionClassicDark,
binding.themeRadioClassicDark,
binding.themeOptionClassicLight,
@@ -53,6 +53,7 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On
binding.themeOptionOceanLight,
binding.themeRadioOceanLight
)
}
override fun onClick(v: View?) {
v ?: return

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